From 09b6ad5c91d34cdf261f0f865a6d9019be37cfaa Mon Sep 17 00:00:00 2001 From: "Asaph M. Kotzin" Date: Tue, 24 Mar 2026 12:49:28 +0000 Subject: [PATCH 1/7] feat: add GitLab integration for project-level repository linking Add GitLab integration to Flagsmith, allowing users to link GitLab issues and merge requests to feature flags. Supports both GitLab.com and self-managed instances via Group/Project Access Tokens. ## Backend - New Django app `integrations/gitlab/` with models, views, client, webhook handling, async tasks, and serialisers - `GitLabConfiguration` model (per-project) stores instance URL, access token, webhook secret, and linked GitLab project - Webhook receiver at `/api/v1/gitlab-webhook//` handles merge request and issue events for automatic feature tagging - Comment posting to GitLab issues/MRs when feature flags change - Extend `FeatureExternalResource` with GITLAB_ISSUE and GITLAB_MR resource types, with lifecycle hooks dispatching to GitHub or GitLab - Add `GITLAB` to `TagType` enum for feature tagging ## Frontend - RTK Query services for GitLab integration and resource browsing - GitLabSetupPage component with credentials form, repo selection, tagging toggle, and webhook URL display with copy-to-clipboard - GitLabResourcesSelect for linking issues/MRs to feature flags - Extend IntegrationList, ExternalResourcesLinkTab, and ExternalResourcesTable to support GitLab alongside GitHub Co-Authored-By: Claude Opus 4.6 (1M context) --- api/api/urls/v1.py | 7 + api/app/settings/common.py | 1 + api/conftest.py | 14 + ...0003_alter_featureexternalresource_type.py | 18 + .../feature_external_resources/models.py | 113 +- .../feature_external_resources/views.py | 117 +- api/features/models.py | 35 + api/features/serializers.py | 18 + api/features/versioning/serializers.py | 19 + api/integrations/gitlab/__init__.py | 0 api/integrations/gitlab/apps.py | 5 + api/integrations/gitlab/client.py | 264 +++ api/integrations/gitlab/constants.py | 61 + api/integrations/gitlab/dataclasses.py | 52 + api/integrations/gitlab/exceptions.py | 6 + api/integrations/gitlab/gitlab.py | 281 +++ api/integrations/gitlab/helpers.py | 7 + .../gitlab/migrations/0001_initial.py | 104 + .../gitlab/migrations/__init__.py | 0 api/integrations/gitlab/models.py | 108 ++ api/integrations/gitlab/permissions.py | 18 + api/integrations/gitlab/serializers.py | 63 + api/integrations/gitlab/tasks.py | 192 ++ api/integrations/gitlab/views.py | 351 ++++ ...tureflagcodereferencesscan_vcs_provider.py | 18 + api/projects/code_references/types.py | 1 + .../migrations/0009_add_gitlab_tag_type.py | 27 + api/projects/tags/models.py | 1 + api/projects/urls.py | 38 + ...t_unit_feature_external_resources_views.py | 435 +++++ .../unit/integrations/gitlab/__init__.py | 0 .../gitlab/test_unit_gitlab_client.py | 459 +++++ .../gitlab/test_unit_gitlab_gitlab.py | 1669 +++++++++++++++++ .../gitlab/test_unit_gitlab_views.py | 1138 +++++++++++ frontend/common/constants.ts | 16 + frontend/common/services/useGitlab.ts | 73 + .../common/services/useGitlabIntegration.ts | 124 ++ frontend/common/stores/default-flags.ts | 26 + frontend/common/types/requests.ts | 30 + frontend/common/types/responses.ts | 21 + frontend/common/utils/utils.tsx | 4 +- .../components/ExternalResourcesLinkTab.tsx | 125 +- .../web/components/ExternalResourcesTable.tsx | 14 +- .../web/components/GitLabResourcesSelect.tsx | 152 ++ frontend/web/components/IntegrationList.tsx | 138 +- .../modals/CreateEditIntegrationModal.tsx | 14 +- .../components/modals/create-feature/index.js | 17 +- .../web/components/pages/GitLabSetupPage.tsx | 316 ++++ 48 files changed, 6605 insertions(+), 105 deletions(-) create mode 100644 api/features/feature_external_resources/migrations/0003_alter_featureexternalresource_type.py create mode 100644 api/integrations/gitlab/__init__.py create mode 100644 api/integrations/gitlab/apps.py create mode 100644 api/integrations/gitlab/client.py create mode 100644 api/integrations/gitlab/constants.py create mode 100644 api/integrations/gitlab/dataclasses.py create mode 100644 api/integrations/gitlab/exceptions.py create mode 100644 api/integrations/gitlab/gitlab.py create mode 100644 api/integrations/gitlab/helpers.py create mode 100644 api/integrations/gitlab/migrations/0001_initial.py create mode 100644 api/integrations/gitlab/migrations/__init__.py create mode 100644 api/integrations/gitlab/models.py create mode 100644 api/integrations/gitlab/permissions.py create mode 100644 api/integrations/gitlab/serializers.py create mode 100644 api/integrations/gitlab/tasks.py create mode 100644 api/integrations/gitlab/views.py create mode 100644 api/projects/code_references/migrations/0003_alter_featureflagcodereferencesscan_vcs_provider.py create mode 100644 api/projects/tags/migrations/0009_add_gitlab_tag_type.py create mode 100644 api/tests/unit/integrations/gitlab/__init__.py create mode 100644 api/tests/unit/integrations/gitlab/test_unit_gitlab_client.py create mode 100644 api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py create mode 100644 api/tests/unit/integrations/gitlab/test_unit_gitlab_views.py create mode 100644 frontend/common/services/useGitlab.ts create mode 100644 frontend/common/services/useGitlabIntegration.ts create mode 100644 frontend/web/components/GitLabResourcesSelect.tsx create mode 100644 frontend/web/components/pages/GitLabSetupPage.tsx diff --git a/api/api/urls/v1.py b/api/api/urls/v1.py index 043776bc732a..a43413cbadb2 100644 --- a/api/api/urls/v1.py +++ b/api/api/urls/v1.py @@ -11,6 +11,7 @@ from features.feature_health.views import feature_health_webhook from features.views import SDKFeatureStates, get_multivariate_options from integrations.github.views import github_webhook +from integrations.gitlab.views import gitlab_webhook from organisations.views import chargebee_webhook schema_view_permission_class = ( # pragma: no cover @@ -42,6 +43,12 @@ re_path(r"cb-webhook/", chargebee_webhook, name="chargebee-webhook"), # GitHub integration webhook re_path(r"github-webhook/", github_webhook, name="github-webhook"), + # GitLab integration webhook + re_path( + r"gitlab-webhook/(?P\d+)/", + gitlab_webhook, + name="gitlab-webhook", + ), re_path(r"cb-webhook/", chargebee_webhook, name="chargebee-webhook"), # Feature health webhook re_path( diff --git a/api/app/settings/common.py b/api/app/settings/common.py index c75fae2fbf34..1b9b009c2e1c 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -154,6 +154,7 @@ "integrations.flagsmith", "integrations.launch_darkly", "integrations.github", + "integrations.gitlab", "integrations.grafana", # Rate limiting admin endpoints "axes", diff --git a/api/conftest.py b/api/conftest.py index 09b61c539016..fd4a194aa6fa 100644 --- a/api/conftest.py +++ b/api/conftest.py @@ -51,6 +51,7 @@ from features.versioning.tasks import enable_v2_versioning from features.workflows.core.models import ChangeRequest from integrations.github.models import GithubConfiguration, GitHubRepository +from integrations.gitlab.models import GitLabConfiguration from metadata.models import ( Metadata, MetadataField, @@ -1219,6 +1220,19 @@ def github_repository( ) +@pytest.fixture() +def gitlab_configuration(project: Project) -> GitLabConfiguration: + return GitLabConfiguration.objects.create( # type: ignore[no-any-return] + project=project, + gitlab_instance_url="https://gitlab.example.com", + access_token="test-gitlab-token", + webhook_secret="test-webhook-secret", + gitlab_project_id=1, + project_name="testgroup/testrepo", + tagging_enabled=True, + ) + + @pytest.fixture(params=AdminClientAuthType.__args__) # type: ignore[attr-defined] def admin_client_auth_type( request: pytest.FixtureRequest, diff --git a/api/features/feature_external_resources/migrations/0003_alter_featureexternalresource_type.py b/api/features/feature_external_resources/migrations/0003_alter_featureexternalresource_type.py new file mode 100644 index 000000000000..85c9404bba6a --- /dev/null +++ b/api/features/feature_external_resources/migrations/0003_alter_featureexternalresource_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.12 on 2026-03-24 14:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('feature_external_resources', '0002_featureexternalresource_feature_ext_type_2b2068_idx'), + ] + + operations = [ + migrations.AlterField( + model_name='featureexternalresource', + name='type', + field=models.CharField(choices=[('GITHUB_ISSUE', 'GitHub Issue'), ('GITHUB_PR', 'GitHub PR'), ('GITLAB_ISSUE', 'GitLab Issue'), ('GITLAB_MR', 'GitLab MR')], max_length=20), + ), + ] diff --git a/api/features/feature_external_resources/models.py b/api/features/feature_external_resources/models.py index 54699a0d76d2..b9fe24eb9a8a 100644 --- a/api/features/feature_external_resources/models.py +++ b/api/features/feature_external_resources/models.py @@ -14,8 +14,7 @@ from environments.models import Environment from features.models import Feature, FeatureState from integrations.github.constants import GitHubEventType, GitHubTag -from integrations.github.github import call_github_task -from integrations.github.models import GitHubRepository +from integrations.gitlab.constants import GitLabEventType, GitLabTag from organisations.models import Organisation from projects.tags.models import Tag, TagType @@ -26,6 +25,9 @@ class ResourceType(models.TextChoices): # GitHub external resource types GITHUB_ISSUE = "GITHUB_ISSUE", "GitHub Issue" GITHUB_PR = "GITHUB_PR", "GitHub PR" + # GitLab external resource types + GITLAB_ISSUE = "GITLAB_ISSUE", "GitLab Issue" + GITLAB_MR = "GITLAB_MR", "GitLab MR" tag_by_type_and_state = { @@ -39,6 +41,15 @@ class ResourceType(models.TextChoices): "merged": GitHubTag.PR_MERGED.value, "draft": GitHubTag.PR_DRAFT.value, }, + ResourceType.GITLAB_ISSUE.value: { + "opened": GitLabTag.ISSUE_OPEN.value, + "closed": GitLabTag.ISSUE_CLOSED.value, + }, + ResourceType.GITLAB_MR.value: { + "opened": GitLabTag.MR_OPEN.value, + "closed": GitLabTag.MR_CLOSED.value, + "merged": GitLabTag.MR_MERGED.value, + }, } @@ -67,12 +78,18 @@ class Meta: @hook(AFTER_SAVE) def execute_after_save_actions(self): # type: ignore[no-untyped-def] - # Tag the feature with the external resource type metadata = json.loads(self.metadata) if self.metadata else {} state = metadata.get("state", "open") - # Add a comment to GitHub Issue/PR when feature is linked to the GH external resource - # and tag the feature with the corresponding tag if tagging is enabled + if self.type in (ResourceType.GITHUB_ISSUE, ResourceType.GITHUB_PR): + self._handle_github_after_save(state) + elif self.type in (ResourceType.GITLAB_ISSUE, ResourceType.GITLAB_MR): + self._handle_gitlab_after_save(state) + + def _handle_github_after_save(self, state: str) -> None: + from integrations.github.github import call_github_task + from integrations.github.models import GitHubRepository + if ( github_configuration := Organisation.objects.prefetch_related( "github_config" @@ -130,17 +147,85 @@ def execute_after_save_actions(self): # type: ignore[no-untyped-def] feature_states=feature_states, ) + def _handle_gitlab_after_save(self, state: str) -> None: + from integrations.gitlab.gitlab import call_gitlab_task + from integrations.gitlab.models import GitLabConfiguration + + try: + gitlab_config = GitLabConfiguration.objects.get( + project=self.feature.project, + deleted_at__isnull=True, + ) + except GitLabConfiguration.DoesNotExist: + return + + if gitlab_config.tagging_enabled: + gitlab_tag, _ = Tag.objects.get_or_create( + label=tag_by_type_and_state[self.type][state], + project=self.feature.project, + is_system_tag=True, + type=TagType.GITLAB.value, + ) + self.feature.tags.add(gitlab_tag) + self.feature.save() + + feature_states: list[FeatureState] = [] + environments = Environment.objects.filter( + project_id=self.feature.project_id + ) + for environment in environments: + q = Q( + feature_id=self.feature_id, + identity__isnull=True, + ) + feature_states.extend( + FeatureState.objects.get_live_feature_states( + environment=environment, additional_filters=q + ) + ) + + call_gitlab_task( + project_id=self.feature.project_id, + type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value, + feature=self.feature, + segment_name=None, + url=None, + feature_states=feature_states, + ) + @hook(BEFORE_DELETE) # type: ignore[misc] def execute_before_save_actions(self) -> None: - # Add a comment to GitHub Issue/PR when feature is unlinked to the GH external resource - if ( - Organisation.objects.prefetch_related("github_config") - .get(id=self.feature.project.organisation_id) - .github_config.first() - ): - call_github_task( - organisation_id=self.feature.project.organisation_id, # type: ignore[arg-type] - type=GitHubEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value, + if self.type in (ResourceType.GITHUB_ISSUE, ResourceType.GITHUB_PR): + from integrations.github.github import call_github_task + + if ( + Organisation.objects.prefetch_related("github_config") + .get(id=self.feature.project.organisation_id) + .github_config.first() + ): + call_github_task( + organisation_id=self.feature.project.organisation_id, # type: ignore[arg-type] + type=GitHubEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value, + feature=self.feature, + segment_name=None, + url=self.url, + feature_states=None, + ) + elif self.type in (ResourceType.GITLAB_ISSUE, ResourceType.GITLAB_MR): + from integrations.gitlab.gitlab import call_gitlab_task + from integrations.gitlab.models import GitLabConfiguration + + try: + GitLabConfiguration.objects.get( + project=self.feature.project, + deleted_at__isnull=True, + ) + except GitLabConfiguration.DoesNotExist: + return + + call_gitlab_task( + project_id=self.feature.project_id, + type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value, feature=self.feature, segment_name=None, url=self.url, diff --git a/api/features/feature_external_resources/views.py b/api/features/feature_external_resources/views.py index 87d8d8b7844b..06207420b36d 100644 --- a/api/features/feature_external_resources/views.py +++ b/api/features/feature_external_resources/views.py @@ -1,4 +1,5 @@ import re +from typing import Any from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator @@ -13,6 +14,9 @@ label_github_issue_pr, ) from integrations.github.models import GitHubRepository +from integrations.gitlab.client import ( + label_gitlab_issue_mr, +) from organisations.models import Organisation from .models import FeatureExternalResource @@ -58,19 +62,94 @@ def list(self, request, *args, **kwargs) -> Response: # type: ignore[no-untyped for resource in data if isinstance(data, list) else []: if resource_url := resource.get("url"): - resource["metadata"] = get_github_issue_pr_title_and_state( - organisation_id=organisation_id, resource_url=resource_url - ) + resource_type = resource.get("type", "") + if resource_type.startswith("GITHUB_"): + resource["metadata"] = get_github_issue_pr_title_and_state( + organisation_id=organisation_id, resource_url=resource_url + ) + elif resource_type.startswith("GITLAB_"): + try: + from integrations.gitlab.client import ( + get_gitlab_issue_mr_title_and_state as get_gitlab_metadata, + ) + from integrations.gitlab.models import ( + GitLabConfiguration, + ) + import re as _re + + feature_obj = get_object_or_404( + Feature.objects.filter(id=self.kwargs["feature_pk"]), + ) + gitlab_config = GitLabConfiguration.objects.filter( + project=feature_obj.project, deleted_at__isnull=True + ).first() + if gitlab_config and gitlab_config.gitlab_project_id: + # Parse resource IID from URL + if resource_type == "GITLAB_MR": + match = _re.search(r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/merge_requests/(\d+)$", resource_url) + api_type = "merge_requests" + else: + match = _re.search(r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/(?:issues|work_items)/(\d+)$", resource_url) + api_type = "issues" + + if match: + _project_path, iid = match.group(1), int(match.group(2)) + resource["metadata"] = get_gitlab_metadata( + instance_url=gitlab_config.gitlab_instance_url, + access_token=gitlab_config.access_token, + gitlab_project_id=gitlab_config.gitlab_project_id, + resource_type=api_type, + resource_iid=iid, + ) + except Exception: + pass return Response(data={"results": data}) - def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def] - feature = get_object_or_404( - Feature.objects.filter( - id=self.kwargs["feature_pk"], - ), - ) + def _create_gitlab_resource(self, request: Any, feature: Any, resource_type: str, *args: Any, **kwargs: Any) -> Response: + from integrations.gitlab.models import GitLabConfiguration + + try: + gitlab_config = GitLabConfiguration.objects.get( + project=feature.project, + deleted_at__isnull=True, + ) + except GitLabConfiguration.DoesNotExist: + return Response( + data={ + "detail": "This Project doesn't have a valid GitLab integration configuration" + }, + content_type="application/json", + status=status.HTTP_400_BAD_REQUEST, + ) + + url = request.data.get("url") + if resource_type == "GITLAB_MR": + pattern = r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/merge_requests/(\d+)$" + else: + pattern = r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/(?:issues|work_items)/(\d+)$" + url_match = re.search(pattern, url) + if url_match: + _project_path, resource_iid = url_match.groups() + api_resource_type = "merge_requests" if resource_type == "GITLAB_MR" else "issues" + if gitlab_config.tagging_enabled and gitlab_config.gitlab_project_id: + label_gitlab_issue_mr( + instance_url=gitlab_config.gitlab_instance_url, + access_token=gitlab_config.access_token, + gitlab_project_id=gitlab_config.gitlab_project_id, + resource_type=api_resource_type, + resource_iid=int(resource_iid), + ) + return super().create(request, *args, **kwargs) + else: + return Response( + data={"detail": "Invalid GitLab Issue/MR URL"}, + content_type="application/json", + status=status.HTTP_400_BAD_REQUEST, + ) + + def _create_github_resource(self, request: Any, feature: Any, resource_type: str, *args: Any, **kwargs: Any) -> Response: github_configuration = ( Organisation.objects.prefetch_related("github_config") .get(id=feature.project.organisation_id) @@ -88,9 +167,9 @@ def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def] # Get repository owner and name, and issue/PR number from the external resource URL url = request.data.get("url") - if request.data.get("type") == "GITHUB_PR": + if resource_type == "GITHUB_PR": pattern = r"github.com/([^/]+)/([^/]+)/pull/(\d+)$" - elif request.data.get("type") == "GITHUB_ISSUE": + elif resource_type == "GITHUB_ISSUE": pattern = r"github.com/([^/]+)/([^/]+)/issues/(\d+)$" else: return Response( @@ -122,6 +201,22 @@ def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def] status=status.HTTP_400_BAD_REQUEST, ) + def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def] + feature = get_object_or_404( + Feature.objects.filter( + id=self.kwargs["feature_pk"], + ), + ) + + resource_type = request.data.get("type", "") + + # Handle GitLab resources + if resource_type in ("GITLAB_MR", "GITLAB_ISSUE"): + return self._create_gitlab_resource(request, feature, resource_type, *args, **kwargs) + + # Handle GitHub resources + return self._create_github_resource(request, feature, resource_type, *args, **kwargs) + def perform_update(self, serializer): # type: ignore[no-untyped-def] external_resource_id = int(self.kwargs["pk"]) serializer.save(id=external_resource_id) diff --git a/api/features/models.py b/api/features/models.py index 43ab7f986bbb..83ab035baf52 100644 --- a/api/features/models.py +++ b/api/features/models.py @@ -158,6 +158,24 @@ def create_github_comment(self) -> None: feature_states=None, ) + # GitLab comment posting + from integrations.gitlab.constants import GitLabEventType + from integrations.gitlab.gitlab import call_gitlab_task + + if ( + self.external_resources.exists() + and self.project.gitlab_project.exists() + and self.deleted_at + ): + call_gitlab_task( + project_id=self.project_id, + type=GitLabEventType.FLAG_DELETED.value, + feature=self, + segment_name=None, + url=None, + feature_states=None, + ) + @hook(AFTER_CREATE) def create_feature_states(self): # type: ignore[no-untyped-def] FeatureState.create_initial_feature_states_for_feature(feature=self) @@ -437,6 +455,23 @@ def create_github_comment(self) -> None: None, ) + # GitLab comment posting + from integrations.gitlab.constants import GitLabEventType + from integrations.gitlab.gitlab import call_gitlab_task + + if ( + self.feature.external_resources.exists() + and self.feature.project.gitlab_project.exists() + ): + call_gitlab_task( + self.feature.project_id, + GitLabEventType.SEGMENT_OVERRIDE_DELETED.value, + self.feature, + self.segment.name, + None, + None, + ) + class FeatureState( SoftDeleteExportableModel, diff --git a/api/features/serializers.py b/api/features/serializers.py index a37c6b6ce7fb..0679f93e9801 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -583,6 +583,24 @@ def save(self, **kwargs): # type: ignore[no-untyped-def] feature_states=[feature_state], ) + # GitLab comment posting + if ( + not feature_state.identity_id # type: ignore[union-attr] + and feature_state.feature.external_resources.exists() # type: ignore[union-attr] + and feature_state.environment.project.gitlab_project.exists() # type: ignore[union-attr] + ): + from integrations.gitlab.constants import GitLabEventType + from integrations.gitlab.gitlab import call_gitlab_task + + call_gitlab_task( + project_id=feature_state.feature.project_id, # type: ignore[union-attr] + type=GitLabEventType.FLAG_UPDATED.value, + feature=feature_state.feature, # type: ignore[union-attr] + segment_name=None, + url=None, + feature_states=[feature_state], + ) + return response except django.core.exceptions.ValidationError as e: diff --git a/api/features/versioning/serializers.py b/api/features/versioning/serializers.py index 329270757506..c5a216813ae5 100644 --- a/api/features/versioning/serializers.py +++ b/api/features/versioning/serializers.py @@ -54,6 +54,25 @@ def save(self, **kwargs): # type: ignore[no-untyped-def] feature_states=[feature_state], ) + # GitLab comment posting + if ( + not feature_state.identity_id + and feature_state.feature.external_resources.exists() + and feature_state.environment + and feature_state.environment.project.gitlab_project.exists() + ): + from integrations.gitlab.constants import GitLabEventType + from integrations.gitlab.gitlab import call_gitlab_task + + call_gitlab_task( + project_id=feature_state.feature.project_id, + type=GitLabEventType.FLAG_UPDATED.value, + feature=feature_state.feature, + segment_name=None, + url=None, + feature_states=[feature_state], + ) + return response diff --git a/api/integrations/gitlab/__init__.py b/api/integrations/gitlab/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/gitlab/apps.py b/api/integrations/gitlab/apps.py new file mode 100644 index 000000000000..7f805cf1304c --- /dev/null +++ b/api/integrations/gitlab/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class GitlabIntegrationConfig(AppConfig): + name = "integrations.gitlab" diff --git a/api/integrations/gitlab/client.py b/api/integrations/gitlab/client.py new file mode 100644 index 000000000000..abc4c61eae1f --- /dev/null +++ b/api/integrations/gitlab/client.py @@ -0,0 +1,264 @@ +import json +import logging +from typing import Any + +import requests +from requests.exceptions import HTTPError + +from integrations.gitlab.constants import ( + GITLAB_API_CALLS_TIMEOUT, + GITLAB_FLAGSMITH_LABEL, + GITLAB_FLAGSMITH_LABEL_COLOR, + GITLAB_FLAGSMITH_LABEL_DESCRIPTION, +) +from integrations.gitlab.dataclasses import ( + IssueQueryParams, + PaginatedQueryParams, + ProjectQueryParams, +) + +logger = logging.getLogger(__name__) + + +def build_request_headers(access_token: str) -> dict[str, str]: + return {"PRIVATE-TOKEN": access_token} + + +def build_paginated_response( + results: list[dict[str, Any]], + response: requests.Response, + total_count: int | None = None, +) -> dict[str, Any]: + data: dict[str, Any] = {"results": results} + + # GitLab uses X-Page and X-Total-Pages headers for pagination + current_page = int(response.headers.get("x-page", 1)) + total_pages = int(response.headers.get("x-total-pages", 1)) + + if current_page > 1: + data["previous"] = current_page - 1 + if current_page < total_pages: + data["next"] = current_page + 1 + + if total_count is not None: + data["total_count"] = total_count + + return data + + +def fetch_gitlab_projects( + instance_url: str, + access_token: str, + params: PaginatedQueryParams, +) -> dict[str, Any]: + url = ( + f"{instance_url}/api/v4/projects" + f"?membership=true&per_page={params.page_size}&page={params.page}" + ) + headers = build_request_headers(access_token) + response = requests.get(url, headers=headers, timeout=GITLAB_API_CALLS_TIMEOUT) + response.raise_for_status() + json_response = response.json() + + results = [ + { + "id": project["id"], + "name": project["name"], + "path_with_namespace": project["path_with_namespace"], + } + for project in json_response + ] + + total_count = int(response.headers.get("x-total", len(results))) + return build_paginated_response(results, response, total_count) + + +def fetch_search_gitlab_resource( + resource_type: str, + instance_url: str, + access_token: str, + params: IssueQueryParams, +) -> dict[str, Any]: + """Search issues or merge requests in a GitLab project. + + resource_type: "issue" or "merge_request" + """ + endpoint = "issues" if resource_type == "issue" else "merge_requests" + url = ( + f"{instance_url}/api/v4/projects/{params.gitlab_project_id}/{endpoint}" + f"?per_page={params.page_size}&page={params.page}" + ) + if params.search_text: + url += f"&search={params.search_text}" + if params.state: + url += f"&state={params.state}" + if params.author: + url += f"&author_username={params.author}" + if params.assignee: + url += f"&assignee_username={params.assignee}" + + headers = build_request_headers(access_token) + response = requests.get(url, headers=headers, timeout=GITLAB_API_CALLS_TIMEOUT) + response.raise_for_status() + json_response = response.json() + + results = [ + { + "web_url": item["web_url"], + "id": item["id"], + "title": item["title"], + "iid": item["iid"], + "state": item["state"], + "merged": item.get("merged_at") is not None + if resource_type == "merge_request" + else False, + "draft": item.get("draft", False) + if resource_type == "merge_request" + else False, + } + for item in json_response + ] + + total_count = int(response.headers.get("x-total", len(results))) + return build_paginated_response(results, response, total_count) + + +def fetch_gitlab_project_members( + instance_url: str, + access_token: str, + params: ProjectQueryParams, +) -> dict[str, Any]: + url = ( + f"{instance_url}/api/v4/projects/{params.gitlab_project_id}/members" + f"?per_page={params.page_size}&page={params.page}" + ) + headers = build_request_headers(access_token) + response = requests.get(url, headers=headers, timeout=GITLAB_API_CALLS_TIMEOUT) + response.raise_for_status() + json_response = response.json() + + results = [ + { + "username": member["username"], + "avatar_url": member["avatar_url"], + "name": member["name"], + } + for member in json_response + ] + + return build_paginated_response(results, response) + + +def create_gitlab_issue( + instance_url: str, + access_token: str, + gitlab_project_id: int, + title: str, + body: str, +) -> dict[str, Any]: + url = f"{instance_url}/api/v4/projects/{gitlab_project_id}/issues" + headers = build_request_headers(access_token) + payload = {"title": title, "description": body} + response = requests.post( + url, json=payload, headers=headers, timeout=GITLAB_API_CALLS_TIMEOUT + ) + response.raise_for_status() + return response.json() # type: ignore[no-any-return] + + +def post_comment_to_gitlab( + instance_url: str, + access_token: str, + gitlab_project_id: int, + resource_type: str, + resource_iid: int, + body: str, +) -> dict[str, Any]: + """Post a note (comment) on a GitLab issue or merge request. + + resource_type: "issues" or "merge_requests" + """ + url = ( + f"{instance_url}/api/v4/projects/{gitlab_project_id}" + f"/{resource_type}/{resource_iid}/notes" + ) + headers = build_request_headers(access_token) + payload = {"body": body} + response = requests.post( + url, json=payload, headers=headers, timeout=GITLAB_API_CALLS_TIMEOUT + ) + response.raise_for_status() + return response.json() # type: ignore[no-any-return] + + +def get_gitlab_issue_mr_title_and_state( + instance_url: str, + access_token: str, + gitlab_project_id: int, + resource_type: str, + resource_iid: int, +) -> dict[str, str]: + """Fetch title and state for a GitLab issue or MR. + + resource_type: "issues" or "merge_requests" + """ + url = ( + f"{instance_url}/api/v4/projects/{gitlab_project_id}" + f"/{resource_type}/{resource_iid}" + ) + headers = build_request_headers(access_token) + response = requests.get(url, headers=headers, timeout=GITLAB_API_CALLS_TIMEOUT) + response.raise_for_status() + json_response = response.json() + return {"title": json_response["title"], "state": json_response["state"]} + + +def create_flagsmith_flag_label( + instance_url: str, + access_token: str, + gitlab_project_id: int, +) -> dict[str, Any] | None: + url = f"{instance_url}/api/v4/projects/{gitlab_project_id}/labels" + headers = build_request_headers(access_token) + payload = { + "name": GITLAB_FLAGSMITH_LABEL, + "color": f"#{GITLAB_FLAGSMITH_LABEL_COLOR}", + "description": GITLAB_FLAGSMITH_LABEL_DESCRIPTION, + } + try: + response = requests.post( + url, json=payload, headers=headers, timeout=GITLAB_API_CALLS_TIMEOUT + ) + response.raise_for_status() + return response.json() # type: ignore[no-any-return] + except HTTPError: + response_content = response.content.decode("utf-8") + error_data = json.loads(response_content) + if "already exists" in str(error_data.get("message", "")): + logger.warning("Label already exists") + return None + raise + + +def label_gitlab_issue_mr( + instance_url: str, + access_token: str, + gitlab_project_id: int, + resource_type: str, + resource_iid: int, +) -> dict[str, Any]: + """Add the Flagsmith Flag label to a GitLab issue or MR. + + resource_type: "issues" or "merge_requests" + """ + url = ( + f"{instance_url}/api/v4/projects/{gitlab_project_id}" + f"/{resource_type}/{resource_iid}" + ) + headers = build_request_headers(access_token) + payload = {"add_labels": GITLAB_FLAGSMITH_LABEL} + response = requests.put( + url, json=payload, headers=headers, timeout=GITLAB_API_CALLS_TIMEOUT + ) + response.raise_for_status() + return response.json() # type: ignore[no-any-return] diff --git a/api/integrations/gitlab/constants.py b/api/integrations/gitlab/constants.py new file mode 100644 index 000000000000..bfd1067d1724 --- /dev/null +++ b/api/integrations/gitlab/constants.py @@ -0,0 +1,61 @@ +from enum import Enum + +GITLAB_API_VERSION = "v4" +GITLAB_API_CALLS_TIMEOUT = 10 + +LINK_FEATURE_TITLE = """**Flagsmith feature linked:** `%s` +Default Values:\n""" +FEATURE_TABLE_HEADER = """| Environment | Enabled | Value | Last Updated (UTC) | +| :--- | :----- | :------ | :------ |\n""" +FEATURE_TABLE_ROW = "| [%s](%s) | %s | %s | %s |\n" +LINK_SEGMENT_TITLE = "Segment `%s` values:\n" +UNLINKED_FEATURE_TEXT = "**The feature flag `%s` was unlinked from the issue/MR**" +UPDATED_FEATURE_TEXT = "**Flagsmith Feature `%s` has been updated:**\n" +DELETED_FEATURE_TEXT = "**The Feature Flag `%s` was deleted**" +DELETED_SEGMENT_OVERRIDE_TEXT = ( + "**The Segment Override `%s` for Feature Flag `%s` was deleted**" +) +FEATURE_ENVIRONMENT_URL = "%s/project/%s/environment/%s/features?feature=%s&tab=%s" + +CLEANUP_ISSUE_TITLE = "Remove stale feature flag: %s" +CLEANUP_ISSUE_BODY = """\ +We need to clean up feature flag usage in the code. +Our goal is to delete references of the "%s" feature. +We need to delete the feature flag check so that the code path \ +is no longer guarded by the feature flag. +These are the occurrences of this feature flag in this repository: +%s""" + +GITLAB_TAG_COLOR = "#838992" +GITLAB_FLAGSMITH_LABEL = "Flagsmith Flag" +GITLAB_FLAGSMITH_LABEL_DESCRIPTION = ( + "This GitLab Issue/MR is linked to a Flagsmith Feature Flag" +) +GITLAB_FLAGSMITH_LABEL_COLOR = "6633FF" + + +class GitLabEventType(Enum): + FLAG_UPDATED = "FLAG_UPDATED" + FLAG_DELETED = "FLAG_DELETED" + FEATURE_EXTERNAL_RESOURCE_ADDED = "FEATURE_EXTERNAL_RESOURCE_ADDED" + FEATURE_EXTERNAL_RESOURCE_REMOVED = "FEATURE_EXTERNAL_RESOURCE_REMOVED" + SEGMENT_OVERRIDE_DELETED = "SEGMENT_OVERRIDE_DELETED" + + +class GitLabTag(Enum): + MR_OPEN = "MR Open" + MR_MERGED = "MR Merged" + MR_CLOSED = "MR Closed" + MR_DRAFT = "MR Draft" + ISSUE_OPEN = "Issue Open" + ISSUE_CLOSED = "Issue Closed" + + +gitlab_tag_description = { + GitLabTag.MR_OPEN.value: "This feature has a linked MR open", + GitLabTag.MR_MERGED.value: "This feature has a linked MR merged", + GitLabTag.MR_CLOSED.value: "This feature has a linked MR closed", + GitLabTag.MR_DRAFT.value: "This feature has a linked MR draft", + GitLabTag.ISSUE_OPEN.value: "This feature has a linked issue open", + GitLabTag.ISSUE_CLOSED.value: "This feature has a linked issue closed", +} diff --git a/api/integrations/gitlab/dataclasses.py b/api/integrations/gitlab/dataclasses.py new file mode 100644 index 000000000000..a92cd37ceef5 --- /dev/null +++ b/api/integrations/gitlab/dataclasses.py @@ -0,0 +1,52 @@ +from dataclasses import dataclass, field +from typing import Any, Optional + + +@dataclass +class GitLabData: + gitlab_instance_url: str + access_token: str + feature_id: int + feature_name: str + type: str + feature_states: list[dict[str, Any]] = field(default_factory=list) + url: str | None = None + project_id: int | None = None + segment_name: str | None = None + + @classmethod + def from_dict(cls, data_dict: dict[str, Any]) -> "GitLabData": + return cls(**data_dict) + + +@dataclass +class CallGitLabData: + event_type: str + gitlab_data: GitLabData + feature_external_resources: list[dict[str, Any]] + + +@dataclass +class PaginatedQueryParams: + page: int = field(default=1, kw_only=True) + page_size: int = field(default=100, kw_only=True) + + def __post_init__(self) -> None: + if self.page < 1: + raise ValueError("Page must be greater or equal than 1") + if self.page_size < 1 or self.page_size > 100: + raise ValueError("Page size must be an integer between 1 and 100") + + +@dataclass +class ProjectQueryParams(PaginatedQueryParams): + gitlab_project_id: int = 0 + project_name: str = "" + + +@dataclass +class IssueQueryParams(ProjectQueryParams): + search_text: Optional[str] = None + state: Optional[str] = "opened" + author: Optional[str] = None + assignee: Optional[str] = None diff --git a/api/integrations/gitlab/exceptions.py b/api/integrations/gitlab/exceptions.py new file mode 100644 index 000000000000..7c4eeba23196 --- /dev/null +++ b/api/integrations/gitlab/exceptions.py @@ -0,0 +1,6 @@ +from rest_framework.exceptions import APIException + + +class DuplicateGitLabIntegration(APIException): + status_code = 400 + default_detail = "Duplication error. The GitLab integration already created" diff --git a/api/integrations/gitlab/gitlab.py b/api/integrations/gitlab/gitlab.py new file mode 100644 index 000000000000..596e0bfa993a --- /dev/null +++ b/api/integrations/gitlab/gitlab.py @@ -0,0 +1,281 @@ +import logging +import typing +from dataclasses import asdict +from typing import Any + +from django.db.models import Q +from django.utils.formats import get_format + +from core.helpers import get_current_site_url +from features.models import Feature, FeatureState, FeatureStateValue +from integrations.gitlab.constants import ( + DELETED_FEATURE_TEXT, + DELETED_SEGMENT_OVERRIDE_TEXT, + FEATURE_ENVIRONMENT_URL, + FEATURE_TABLE_HEADER, + FEATURE_TABLE_ROW, + GITLAB_TAG_COLOR, + LINK_FEATURE_TITLE, + LINK_SEGMENT_TITLE, + UNLINKED_FEATURE_TEXT, + UPDATED_FEATURE_TEXT, + GitLabEventType, + GitLabTag, + gitlab_tag_description, +) +from integrations.gitlab.dataclasses import GitLabData +from integrations.gitlab.models import GitLabConfiguration +from integrations.gitlab.tasks import call_gitlab_app_webhook_for_feature_state +from projects.tags.models import Tag, TagType + +logger = logging.getLogger(__name__) + +tag_by_event_type: dict[str, dict[str, str | None]] = { + "merge_request": { + "close": GitLabTag.MR_CLOSED.value, + "merge": GitLabTag.MR_MERGED.value, + "open": GitLabTag.MR_OPEN.value, + "reopen": GitLabTag.MR_OPEN.value, + "update": None, # handled separately for draft detection + }, + "issue": { + "close": GitLabTag.ISSUE_CLOSED.value, + "open": GitLabTag.ISSUE_OPEN.value, + "reopen": GitLabTag.ISSUE_OPEN.value, + }, +} + + +def _get_tag_value_for_event( + event_type: str, action: str, metadata: dict[str, Any] +) -> str | None: + """Return the tag value string for a GitLab event, or None if no tag change is needed.""" + if event_type == "merge_request" and action == "update": + if metadata.get("draft", False): + return GitLabTag.MR_DRAFT.value + # Generic update — no tag change needed + return None + if event_type in tag_by_event_type and action in tag_by_event_type[event_type]: + return tag_by_event_type[event_type][action] + return None + + +def tag_feature_per_gitlab_event( + event_type: str, action: str, metadata: dict[str, Any], project_path: str, +) -> None: + web_url = metadata.get("web_url", "") + + # GitLab webhooks send /-/issues/N but the stored URL might be /-/work_items/N + # Try both URL variants to find the linked feature + url_variants = [web_url] + if "/-/issues/" in web_url: + url_variants.append(web_url.replace("/-/issues/", "/-/work_items/")) + elif "/-/work_items/" in web_url: + url_variants.append(web_url.replace("/-/work_items/", "/-/issues/")) + + feature = None + for url in url_variants: + feature = Feature.objects.filter( + Q(external_resources__type="GITLAB_MR") + | Q(external_resources__type="GITLAB_ISSUE"), + external_resources__url=url, + ).first() + if feature: + break + + if not feature: + return None + + try: + gitlab_config = GitLabConfiguration.objects.get( + project=feature.project, + project_name=project_path, + ) + except GitLabConfiguration.DoesNotExist: + return None + + if gitlab_config.tagging_enabled: + tag_value = _get_tag_value_for_event(event_type, action, metadata) + + if tag_value is None: + return None + + gitlab_tag, _ = Tag.objects.get_or_create( + color=GITLAB_TAG_COLOR, + description=gitlab_tag_description[tag_value], + label=tag_value, + project=feature.project, + is_system_tag=True, + type=TagType.GITLAB.value, + ) + tag_label_pattern = "Issue" if event_type == "issue" else "MR" + feature.tags.remove( + *feature.tags.filter( + Q(type=TagType.GITLAB.value) & Q(label__startswith=tag_label_pattern) + ) + ) + feature.tags.add(gitlab_tag) + feature.save() + + return None + + +def handle_gitlab_webhook_event(event_type: str, payload: dict[str, Any]) -> None: + if event_type == "merge_request": + attrs = payload.get("object_attributes", {}) + action = attrs.get("action", "") + project_path = payload.get("project", {}).get("path_with_namespace", "") + metadata = { + "web_url": attrs.get("url", ""), + "draft": attrs.get("work_in_progress", False), + "merged": attrs.get("state") == "merged", + } + tag_feature_per_gitlab_event(event_type, action, metadata, project_path) + elif event_type == "issue": + attrs = payload.get("object_attributes", {}) + action = attrs.get("action", "") + project_path = payload.get("project", {}).get("path_with_namespace", "") + metadata = {"web_url": attrs.get("url", "")} + tag_feature_per_gitlab_event(event_type, action, metadata, project_path) + + +def generate_body_comment( + name: str, + event_type: str, + feature_id: int, + feature_states: list[dict[str, typing.Any]], + project_id: int | None = None, + segment_name: str | None = None, +) -> str: + is_removed = event_type == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value + is_segment_override_deleted = ( + event_type == GitLabEventType.SEGMENT_OVERRIDE_DELETED.value + ) + + if event_type == GitLabEventType.FLAG_DELETED.value: + return DELETED_FEATURE_TEXT % (name) + + if is_removed: + return UNLINKED_FEATURE_TEXT % (name) + + if is_segment_override_deleted and segment_name is not None: + return DELETED_SEGMENT_OVERRIDE_TEXT % (segment_name, name) + + result = "" + if event_type == GitLabEventType.FLAG_UPDATED.value: + result = UPDATED_FEATURE_TEXT % (name) + else: + result = LINK_FEATURE_TITLE % (name) + + last_segment_name = "" + if len(feature_states) > 0 and not feature_states[0].get("segment_name"): + result += FEATURE_TABLE_HEADER + + for fs in feature_states: + feature_value = fs.get("feature_state_value") + tab = "segment-overrides" if fs.get("segment_name") is not None else "value" + environment_link_url = FEATURE_ENVIRONMENT_URL % ( + get_current_site_url(), + project_id, + fs.get("environment_api_key"), + feature_id, + tab, + ) + if ( + fs.get("segment_name") is not None + and fs["segment_name"] != last_segment_name + ): + result += "\n" + LINK_SEGMENT_TITLE % (fs["segment_name"]) + last_segment_name = fs["segment_name"] + result += FEATURE_TABLE_HEADER + table_row = FEATURE_TABLE_ROW % ( + fs["environment_name"], + environment_link_url, + "✅ Enabled" if fs["enabled"] else "❌ Disabled", + f"`{feature_value}`" if feature_value else "", + fs["last_updated"], + ) + result += table_row + + return result + + +def generate_data( + gitlab_configuration: GitLabConfiguration, + feature: Feature, + type: str, + feature_states: ( + typing.Union[list[FeatureState], list[FeatureStateValue]] | None + ) = None, + url: str | None = None, + segment_name: str | None = None, +) -> GitLabData: + feature_states_list: list[dict[str, Any]] = [] + + if feature_states: + for feature_state in feature_states: + feature_state_value = feature_state.get_feature_state_value() + feature_env_data: dict[str, Any] = {} + + if feature_state_value is not None: + feature_env_data["feature_state_value"] = feature_state_value + + if type is not GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value: + feature_env_data["environment_name"] = feature_state.environment.name # type: ignore[union-attr] + feature_env_data["enabled"] = feature_state.enabled + feature_env_data["last_updated"] = feature_state.updated_at.strftime( + get_format("DATETIME_INPUT_FORMATS")[0] + ) + feature_env_data["environment_api_key"] = ( + feature_state.environment.api_key # type: ignore[union-attr] + ) + if ( + hasattr(feature_state, "feature_segment") + and feature_state.feature_segment is not None + ): + feature_env_data["segment_name"] = ( + feature_state.feature_segment.segment.name + ) + feature_states_list.append(feature_env_data) + + return GitLabData( + feature_id=feature.id, + feature_name=feature.name, + gitlab_instance_url=gitlab_configuration.gitlab_instance_url, + access_token=gitlab_configuration.access_token, + type=type, + url=( + url + if type == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value + else None + ), + feature_states=feature_states_list, + project_id=feature.project_id, + segment_name=segment_name, + ) + + +def call_gitlab_task( + project_id: int, + type: str, + feature: Feature, + segment_name: str | None, + url: str | None, + feature_states: typing.Union[list[typing.Any], list[typing.Any]] | None, +) -> None: + gitlab_configuration = GitLabConfiguration.objects.get( + project_id=project_id + ) + + feature_data: GitLabData = generate_data( + gitlab_configuration=gitlab_configuration, + feature=feature, + type=type, + url=url, + segment_name=segment_name, + feature_states=feature_states, + ) + + call_gitlab_app_webhook_for_feature_state.delay( + args=(asdict(feature_data),), + ) diff --git a/api/integrations/gitlab/helpers.py b/api/integrations/gitlab/helpers.py new file mode 100644 index 000000000000..37de6c0d379d --- /dev/null +++ b/api/integrations/gitlab/helpers.py @@ -0,0 +1,7 @@ +def gitlab_webhook_payload_is_valid( + secret_token: str, gitlab_token_header: str | None, +) -> bool: + """Verify that the webhook was sent from GitLab by comparing the secret token.""" + if not gitlab_token_header: + return False + return secret_token == gitlab_token_header diff --git a/api/integrations/gitlab/migrations/0001_initial.py b/api/integrations/gitlab/migrations/0001_initial.py new file mode 100644 index 000000000000..15f1feb97089 --- /dev/null +++ b/api/integrations/gitlab/migrations/0001_initial.py @@ -0,0 +1,104 @@ +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("projects", "0027_add_create_project_level_change_requests_permission"), + ] + + operations = [ + migrations.CreateModel( + name="GitLabConfiguration", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, + db_index=True, + default=None, + editable=False, + null=True, + ), + ), + ( + "uuid", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ( + "gitlab_instance_url", + models.URLField( + help_text="Base URL of the GitLab instance, e.g. https://gitlab.com", + max_length=200, + ), + ), + ( + "access_token", + models.CharField( + help_text="GitLab Group or Project Access Token with api scope", + max_length=255, + ), + ), + ( + "webhook_secret", + models.CharField( + help_text="Secret token for validating incoming GitLab webhooks", + max_length=255, + ), + ), + ( + "gitlab_project_id", + models.IntegerField( + blank=True, + help_text="GitLab's numeric project ID", + null=True, + ), + ), + ( + "project_name", + models.CharField( + blank=True, + default="", + help_text="GitLab project path with namespace, e.g. my-group/my-project", + max_length=200, + ), + ), + ( + "tagging_enabled", + models.BooleanField(default=False), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="gitlab_project", + to="projects.project", + ), + ), + ], + options={ + "ordering": ("id",), + }, + ), + migrations.AddConstraint( + model_name="gitlabconfiguration", + constraint=models.UniqueConstraint( + condition=models.Q(deleted_at__isnull=True), + fields=("project",), + name="gitlabconf_project_id_idx", + ), + ), + ] diff --git a/api/integrations/gitlab/migrations/__init__.py b/api/integrations/gitlab/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/gitlab/models.py b/api/integrations/gitlab/models.py new file mode 100644 index 000000000000..763d6783f3b7 --- /dev/null +++ b/api/integrations/gitlab/models.py @@ -0,0 +1,108 @@ +import logging + +from django.db import models +from django_lifecycle import ( # type: ignore[import-untyped] + AFTER_CREATE, + AFTER_UPDATE, + BEFORE_DELETE, + LifecycleModelMixin, + hook, +) + +from core.models import SoftDeleteExportableModel +from integrations.gitlab.constants import GITLAB_TAG_COLOR + +logger: logging.Logger = logging.getLogger(name=__name__) + + +class GitLabConfiguration(LifecycleModelMixin, SoftDeleteExportableModel): # type: ignore[misc] + project = models.ForeignKey( + "projects.Project", + on_delete=models.CASCADE, + related_name="gitlab_project", + ) + gitlab_instance_url = models.URLField( + max_length=200, + blank=False, + null=False, + help_text="Base URL of the GitLab instance, e.g. https://gitlab.com", + ) + access_token = models.CharField( + max_length=255, + blank=False, + null=False, + help_text="GitLab Group or Project Access Token with api scope", + ) + webhook_secret = models.CharField( + max_length=255, + blank=False, + null=False, + help_text="Secret token for validating incoming GitLab webhooks", + ) + gitlab_project_id = models.IntegerField( + blank=True, + null=True, + help_text="GitLab's numeric project ID", + ) + project_name = models.CharField( + max_length=200, + blank=True, + default="", + help_text="GitLab project path with namespace, e.g. my-group/my-project", + ) + tagging_enabled = models.BooleanField(default=False) + + @staticmethod + def has_gitlab_configuration(project_id: int) -> bool: + return GitLabConfiguration.objects.filter( # type: ignore[no-any-return] + project_id=project_id + ).exists() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["project"], + name="gitlabconf_project_id_idx", + condition=models.Q(deleted_at__isnull=True), + ) + ] + ordering = ("id",) + + @hook(BEFORE_DELETE) # type: ignore[misc] + def delete_feature_external_resources(self) -> None: + import re + + from features.feature_external_resources.models import ( + FeatureExternalResource, + ResourceType, + ) + + if self.project_name: + pattern = re.escape(f"/{self.project_name}/-/") + FeatureExternalResource.objects.filter( + feature_id__in=self.project.features.values_list("id", flat=True), + type__in=[ + ResourceType.GITLAB_ISSUE, + ResourceType.GITLAB_MR, + ], + url__regex=pattern, + ).delete() + + @hook(AFTER_CREATE) # type: ignore[misc] + @hook(AFTER_UPDATE, when="tagging_enabled", has_changed=True, was=False) # type: ignore[misc] + def create_gitlab_tags(self) -> None: + from integrations.gitlab.constants import ( + GitLabTag, + gitlab_tag_description, + ) + from projects.tags.models import Tag, TagType + + for tag_label in GitLabTag: + Tag.objects.get_or_create( + color=GITLAB_TAG_COLOR, + description=gitlab_tag_description[tag_label.value], + label=tag_label.value, + project=self.project, + is_system_tag=True, + type=TagType.GITLAB.value, + ) diff --git a/api/integrations/gitlab/permissions.py b/api/integrations/gitlab/permissions.py new file mode 100644 index 000000000000..71e60b7b7da8 --- /dev/null +++ b/api/integrations/gitlab/permissions.py @@ -0,0 +1,18 @@ +from rest_framework.permissions import BasePermission + +from projects.models import Project + + +class HasPermissionToGitlabConfiguration(BasePermission): + """ + Custom permission to only allow users with permission to access + GitLabConfiguration related to their project's organisation. + """ + + def has_permission(self, request, view): # type: ignore[no-untyped-def] + project_pk = view.kwargs.get("project_pk") + try: + project = Project.objects.get(id=int(project_pk)) + except Project.DoesNotExist: + return False + return request.user.belongs_to(organisation_id=project.organisation_id) diff --git a/api/integrations/gitlab/serializers.py b/api/integrations/gitlab/serializers.py new file mode 100644 index 000000000000..818e819bc1ff --- /dev/null +++ b/api/integrations/gitlab/serializers.py @@ -0,0 +1,63 @@ +from rest_framework import serializers +from rest_framework.serializers import ModelSerializer +from rest_framework_dataclasses.serializers import DataclassSerializer + +from integrations.gitlab.dataclasses import ( + IssueQueryParams, + PaginatedQueryParams, + ProjectQueryParams, +) +from integrations.gitlab.models import GitLabConfiguration + + +class GitLabConfigurationSerializer(ModelSerializer): # type: ignore[type-arg] + class Meta: + model = GitLabConfiguration + fields = ( + "id", + "gitlab_instance_url", + "webhook_secret", + "gitlab_project_id", + "project_name", + "tagging_enabled", + "project", + ) + read_only_fields = ("project",) + extra_kwargs = { + "webhook_secret": {"write_only": False}, + } + + +class GitLabConfigurationCreateSerializer(ModelSerializer): # type: ignore[type-arg] + class Meta: + model = GitLabConfiguration + fields = ( + "id", + "gitlab_instance_url", + "access_token", + "webhook_secret", + "gitlab_project_id", + "project_name", + "tagging_enabled", + "project", + ) + read_only_fields = ("project",) + + +class CreateCleanupIssueSerializer(serializers.Serializer): # type: ignore[type-arg] + feature_id = serializers.IntegerField() + + +class PaginatedQueryParamsSerializer(DataclassSerializer): # type: ignore[type-arg] + class Meta: + dataclass = PaginatedQueryParams + + +class ProjectQueryParamsSerializer(DataclassSerializer): # type: ignore[type-arg] + class Meta: + dataclass = ProjectQueryParams + + +class IssueQueryParamsSerializer(DataclassSerializer): # type: ignore[type-arg] + class Meta: + dataclass = IssueQueryParams diff --git a/api/integrations/gitlab/tasks.py b/api/integrations/gitlab/tasks.py new file mode 100644 index 000000000000..2a63ac77ef9e --- /dev/null +++ b/api/integrations/gitlab/tasks.py @@ -0,0 +1,192 @@ +import logging +import re +from typing import Any, List +from urllib.parse import urlparse + +from task_processor.decorators import register_task_handler + +from features.models import Feature +from integrations.gitlab.client import post_comment_to_gitlab +from integrations.gitlab.constants import GitLabEventType +from integrations.gitlab.dataclasses import CallGitLabData + +logger = logging.getLogger(__name__) + + +def _resolve_resource_urls_for_event(data: CallGitLabData) -> list[str]: + """Return the list of resource URLs to post a comment to, based on event type.""" + event_type = data.event_type + + if ( + event_type == GitLabEventType.FLAG_UPDATED.value + or event_type == GitLabEventType.FLAG_DELETED.value + ): + return [r.get("url", "") for r in data.feature_external_resources] + + if event_type == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value: + if data.gitlab_data.url: + return [data.gitlab_data.url] + return [] + + # Default: use the last linked resource (e.g. newly added resource) + if data.feature_external_resources: + return [data.feature_external_resources[-1].get("url", "")] + return [] + + +def _post_to_resource( + resource_url: str, + instance_url: str, + access_token: str, + body: str, +) -> None: + """Parse a GitLab resource URL and post a comment.""" + from integrations.gitlab.models import GitLabConfiguration + + parsed = urlparse(resource_url) + path = parsed.path + + # Determine resource type from URL path + if "/-/merge_requests/" in path: + resource_type = "merge_requests" + iid_match = re.search(r"/-/merge_requests/(\d+)", path) + elif "/-/issues/" in path or "/-/work_items/" in path: + resource_type = "issues" + iid_match = re.search(r"/-/(?:issues|work_items)/(\d+)", path) + else: + logger.warning("Unknown GitLab resource URL format: %s", resource_url) + return + + if not iid_match: + return + + resource_iid = int(iid_match.group(1)) + + # Extract project path from URL (everything between host and /-/) + project_path_match = re.search(r"^/([^/-]+(?:/[^/-]+)*)/-/", path) + if not project_path_match: + return + project_path = project_path_match.group(1) + + # Look up the GitLab project ID from our repository model + try: + gitlab_config = GitLabConfiguration.objects.get(project_name=project_path) + gitlab_project_id = gitlab_config.gitlab_project_id + except GitLabConfiguration.DoesNotExist: + logger.warning( + "No GitLabConfiguration found for project path: %s", project_path + ) + return + + post_comment_to_gitlab( + instance_url=instance_url, + access_token=access_token, + gitlab_project_id=gitlab_project_id, + resource_type=resource_type, + resource_iid=resource_iid, + body=body, + ) + + +def send_post_request(data: CallGitLabData) -> None: + from integrations.gitlab.gitlab import generate_body_comment + + feature_name = data.gitlab_data.feature_name + feature_id = data.gitlab_data.feature_id + project_id = data.gitlab_data.project_id + event_type = data.event_type + feature_states = data.gitlab_data.feature_states or [] + instance_url = data.gitlab_data.gitlab_instance_url + access_token = data.gitlab_data.access_token + segment_name = data.gitlab_data.segment_name + + body = generate_body_comment( + name=feature_name, + event_type=event_type, + project_id=project_id, + feature_id=feature_id, + feature_states=feature_states, + segment_name=segment_name, + ) + + for resource_url in _resolve_resource_urls_for_event(data): + _post_to_resource( + resource_url=resource_url, + instance_url=instance_url, + access_token=access_token, + body=body, + ) + + +@register_task_handler() +def call_gitlab_app_webhook_for_feature_state(event_data: dict[str, Any]) -> None: + from features.feature_external_resources.models import ( + FeatureExternalResource, + ResourceType, + ) + from integrations.gitlab.dataclasses import GitLabData as GitLabDataClass + + gitlab_event_data = GitLabDataClass.from_dict(event_data) + + def generate_feature_external_resources( + feature_external_resources: List[FeatureExternalResource], + ) -> list[dict[str, Any]]: + return [ + { + "type": resource.type, + "url": resource.url, + } + for resource in feature_external_resources + if resource.type in (ResourceType.GITLAB_ISSUE, ResourceType.GITLAB_MR) + ] + + if ( + gitlab_event_data.type == GitLabEventType.FLAG_DELETED.value + or gitlab_event_data.type == GitLabEventType.SEGMENT_OVERRIDE_DELETED.value + ): + feature_external_resources = generate_feature_external_resources( + list( + FeatureExternalResource.objects.filter( + feature_id=gitlab_event_data.feature_id + ) + ) + ) + data = CallGitLabData( + event_type=gitlab_event_data.type, + gitlab_data=gitlab_event_data, + feature_external_resources=feature_external_resources, + ) + send_post_request(data) + return + + if ( + gitlab_event_data.type + == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value + ): + data = CallGitLabData( + event_type=gitlab_event_data.type, + gitlab_data=gitlab_event_data, + feature_external_resources=[], + ) + send_post_request(data) + return + + feature = Feature.objects.get(id=gitlab_event_data.feature_id) + feature_external_resources = generate_feature_external_resources( + list(feature.external_resources.all()) + ) + data = CallGitLabData( + event_type=gitlab_event_data.type, + gitlab_data=gitlab_event_data, + feature_external_resources=feature_external_resources, + ) + + if not feature_external_resources: + logger.debug( + "No GitLab external resources are associated with this feature id %d. " + "Not calling webhooks.", + gitlab_event_data.feature_id, + ) + return + + send_post_request(data) diff --git a/api/integrations/gitlab/views.py b/api/integrations/gitlab/views.py new file mode 100644 index 000000000000..b72a5bbaf4b5 --- /dev/null +++ b/api/integrations/gitlab/views.py @@ -0,0 +1,351 @@ +import json +import logging +from functools import wraps +from typing import Any, Callable + +import requests +from django.db.utils import IntegrityError +from rest_framework import status, viewsets +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response + +from features.feature_external_resources.models import ( + FeatureExternalResource, +) +from features.feature_external_resources.models import ( + ResourceType as ExternalResourceType, +) +from features.models import Feature +from integrations.gitlab.client import ( + create_flagsmith_flag_label, + create_gitlab_issue, + fetch_gitlab_project_members, + fetch_gitlab_projects, + fetch_search_gitlab_resource, +) +from integrations.gitlab.constants import ( + CLEANUP_ISSUE_BODY, + CLEANUP_ISSUE_TITLE, +) +from integrations.gitlab.exceptions import DuplicateGitLabIntegration +from integrations.gitlab.gitlab import ( + handle_gitlab_webhook_event, +) +from integrations.gitlab.helpers import gitlab_webhook_payload_is_valid +from integrations.gitlab.models import GitLabConfiguration +from integrations.gitlab.permissions import HasPermissionToGitlabConfiguration +from integrations.gitlab.serializers import ( + CreateCleanupIssueSerializer, + GitLabConfigurationCreateSerializer, + GitLabConfigurationSerializer, + IssueQueryParamsSerializer, + PaginatedQueryParamsSerializer, + ProjectQueryParamsSerializer, +) +from projects.code_references.services import get_code_references_for_feature_flag + +logger = logging.getLogger(__name__) + + +def gitlab_auth_required(func): # type: ignore[no-untyped-def] + @wraps(func) + def wrapper(request, project_pk): # type: ignore[no-untyped-def] + if not GitLabConfiguration.has_gitlab_configuration( + project_id=project_pk + ): + return Response( + data={ + "detail": "This Project doesn't have a valid GitLab Configuration" + }, + content_type="application/json", + status=status.HTTP_400_BAD_REQUEST, + ) + return func(request, project_pk) + + return wrapper + + +def gitlab_api_call_error_handler( + error: str | None = None, +) -> Callable[..., Callable[..., Any]]: + def decorator(func): # type: ignore[no-untyped-def] + @wraps(func) + def wrapper(*args, **kwargs) -> Response: # type: ignore[no-untyped-def] + default_error = "Failed to retrieve requested information from GitLab API." + try: + return func(*args, **kwargs) # type: ignore[no-any-return] + except ValueError as e: + logger.error(f"{error or default_error} Error: {str(e)}", exc_info=e) + return Response( + data={"detail": error or default_error}, + content_type="application/json", + status=status.HTTP_400_BAD_REQUEST, + ) + except requests.RequestException as e: + logger.error(f"{error or default_error} Error: {str(e)}", exc_info=e) + return Response( + data={"detail": error or default_error}, + content_type="application/json", + status=status.HTTP_502_BAD_GATEWAY, + ) + + return wrapper + + return decorator + + +class GitLabConfigurationViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] + permission_classes = ( + IsAuthenticated, + HasPermissionToGitlabConfiguration, + ) + model_class = GitLabConfiguration + + def get_serializer_class(self): # type: ignore[no-untyped-def] + if self.action == "create": + return GitLabConfigurationCreateSerializer + return GitLabConfigurationSerializer + + def perform_create(self, serializer): # type: ignore[no-untyped-def] + project_id = self.kwargs["project_pk"] + serializer.save(project_id=project_id) + if serializer.validated_data.get("tagging_enabled", False) and serializer.validated_data.get("gitlab_project_id"): + create_flagsmith_flag_label( + instance_url=serializer.validated_data["gitlab_instance_url"], + access_token=serializer.validated_data["access_token"], + gitlab_project_id=serializer.validated_data["gitlab_project_id"], + ) + + def get_queryset(self): # type: ignore[no-untyped-def] + if getattr(self, "swagger_fake_view", False): + return GitLabConfiguration.objects.none() + + return GitLabConfiguration.objects.filter( + project_id=self.kwargs["project_pk"] + ) + + def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def] + try: + return super().create(request, *args, **kwargs) + except IntegrityError as e: + if "already exists" in str(e): + raise DuplicateGitLabIntegration + + def update(self, request, *args, **kwargs): # type: ignore[no-untyped-def] + response: Response = super().update(request, *args, **kwargs) + instance = self.get_object() + if request.data.get("tagging_enabled", False) and instance.gitlab_project_id: + create_flagsmith_flag_label( + instance_url=instance.gitlab_instance_url, + access_token=instance.access_token, + gitlab_project_id=instance.gitlab_project_id, + ) + return response + + def destroy(self, request, *args, **kwargs): # type: ignore[no-untyped-def] + return super().destroy(request, *args, **kwargs) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated, HasPermissionToGitlabConfiguration]) +@gitlab_auth_required # type: ignore[misc] +@gitlab_api_call_error_handler(error="Failed to retrieve GitLab merge requests.") +def fetch_merge_requests(request, project_pk) -> Response: # type: ignore[no-untyped-def] + query_serializer = IssueQueryParamsSerializer(data=request.query_params) + if not query_serializer.is_valid(): + return Response({"error": query_serializer.errors}, status=400) + + gitlab_config = GitLabConfiguration.objects.get( + project_id=project_pk, deleted_at__isnull=True + ) + data = fetch_search_gitlab_resource( + resource_type="merge_request", + instance_url=gitlab_config.gitlab_instance_url, + access_token=gitlab_config.access_token, + params=query_serializer.validated_data, + ) + return Response(data=data, content_type="application/json", status=status.HTTP_200_OK) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated, HasPermissionToGitlabConfiguration]) +@gitlab_auth_required # type: ignore[misc] +@gitlab_api_call_error_handler(error="Failed to retrieve GitLab issues.") +def fetch_issues(request, project_pk) -> Response: # type: ignore[no-untyped-def] + query_serializer = IssueQueryParamsSerializer(data=request.query_params) + if not query_serializer.is_valid(): + return Response({"error": query_serializer.errors}, status=400) + + gitlab_config = GitLabConfiguration.objects.get( + project_id=project_pk, deleted_at__isnull=True + ) + data = fetch_search_gitlab_resource( + resource_type="issue", + instance_url=gitlab_config.gitlab_instance_url, + access_token=gitlab_config.access_token, + params=query_serializer.validated_data, + ) + return Response(data=data, content_type="application/json", status=status.HTTP_200_OK) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated, HasPermissionToGitlabConfiguration]) +@gitlab_api_call_error_handler(error="Failed to retrieve GitLab projects.") +def fetch_projects(request, project_pk: int) -> Response | None: # type: ignore[no-untyped-def] + query_serializer = PaginatedQueryParamsSerializer(data=request.query_params) + if not query_serializer.is_valid(): + return Response({"error": query_serializer.errors}, status=400) + + gitlab_config = GitLabConfiguration.objects.get( + project_id=project_pk, deleted_at__isnull=True + ) + data = fetch_gitlab_projects( + instance_url=gitlab_config.gitlab_instance_url, + access_token=gitlab_config.access_token, + params=query_serializer.validated_data, + ) + return Response(data=data, content_type="application/json", status=status.HTTP_200_OK) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated, HasPermissionToGitlabConfiguration]) +@gitlab_auth_required # type: ignore[misc] +@gitlab_api_call_error_handler(error="Failed to retrieve GitLab project members.") +def fetch_project_members(request, project_pk) -> Response: # type: ignore[no-untyped-def] + query_serializer = ProjectQueryParamsSerializer(data=request.query_params) + if not query_serializer.is_valid(): + return Response({"error": query_serializer.errors}, status=400) + + gitlab_config = GitLabConfiguration.objects.get( + project_id=project_pk, deleted_at__isnull=True + ) + response = fetch_gitlab_project_members( + instance_url=gitlab_config.gitlab_instance_url, + access_token=gitlab_config.access_token, + params=query_serializer.validated_data, + ) + return Response(data=response, content_type="application/json", status=status.HTTP_200_OK) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated, HasPermissionToGitlabConfiguration]) +@gitlab_api_call_error_handler(error="Failed to create GitLab cleanup issue.") +def create_cleanup_issue(request, project_pk: int) -> Response: # type: ignore[no-untyped-def] + serializer = CreateCleanupIssueSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"error": serializer.errors}, status=status.HTTP_400_BAD_REQUEST + ) + + gitlab_config = GitLabConfiguration.objects.get( + project_id=project_pk, deleted_at__isnull=True + ) + + feature_id: int = serializer.validated_data["feature_id"] + + try: + feature = Feature.objects.get( + id=feature_id, + project_id=project_pk, + ) + except Feature.DoesNotExist: + return Response( + data={"detail": "Feature not found in this project."}, + status=status.HTTP_404_NOT_FOUND, + ) + + summaries = [ + summary + for summary in get_code_references_for_feature_flag(feature) + if summary.code_references + ] + if not summaries: + return Response( + data={"detail": "No code references found for this feature."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + issue_title = CLEANUP_ISSUE_TITLE % feature.name + + for summary in summaries: + references_text = "\n".join( + f"- [`{ref.file_path}#L{ref.line_number}`]({ref.permalink})" + for ref in summary.code_references + ) + issue_body = CLEANUP_ISSUE_BODY % (feature.name, references_text) + + # Try to match repository URL to the GitLab config + url_parts = summary.repository_url.rstrip("/").split("/") + repo_path = "/".join(url_parts[-2:]) # e.g. "group/project" + + if not gitlab_config.project_name or not gitlab_config.project_name.endswith(repo_path): + continue + + if not gitlab_config.gitlab_project_id: + continue + + gitlab_response = create_gitlab_issue( + instance_url=gitlab_config.gitlab_instance_url, + access_token=gitlab_config.access_token, + gitlab_project_id=gitlab_config.gitlab_project_id, + title=issue_title, + body=issue_body, + ) + + issue_url: str = gitlab_response["web_url"] + metadata = json.dumps( + { + "title": gitlab_response["title"], + "state": gitlab_response["state"], + } + ) + try: + FeatureExternalResource.objects.create( + url=issue_url, + type=ExternalResourceType.GITLAB_ISSUE, + feature=feature, + metadata=metadata, + ) + except IntegrityError: + pass + + return Response(status=status.HTTP_204_NO_CONTENT) + + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def gitlab_webhook(request, project_pk: int) -> Response: # type: ignore[no-untyped-def] + gitlab_token = request.headers.get("X-Gitlab-Token") + gitlab_event = request.headers.get("X-Gitlab-Event") + + try: + gitlab_config = GitLabConfiguration.objects.get( + project_id=project_pk, deleted_at__isnull=True + ) + except GitLabConfiguration.DoesNotExist: + return Response( + {"error": "No GitLab configuration found for this project"}, + status=404, + ) + + if not gitlab_webhook_payload_is_valid( + secret_token=gitlab_config.webhook_secret, + gitlab_token_header=gitlab_token, + ): + return Response({"error": "Invalid token"}, status=400) + + data = json.loads(request.body.decode("utf-8")) + + # Map GitLab event header to our event types + event_type_map = { + "Merge Request Hook": "merge_request", + "Issue Hook": "issue", + } + + event_type = event_type_map.get(gitlab_event or "") + if event_type: + handle_gitlab_webhook_event(event_type=event_type, payload=data) + return Response({"detail": "Event processed"}, status=200) + else: + return Response({"detail": "Event bypassed"}, status=200) diff --git a/api/projects/code_references/migrations/0003_alter_featureflagcodereferencesscan_vcs_provider.py b/api/projects/code_references/migrations/0003_alter_featureflagcodereferencesscan_vcs_provider.py new file mode 100644 index 000000000000..00aff7b0e32b --- /dev/null +++ b/api/projects/code_references/migrations/0003_alter_featureflagcodereferencesscan_vcs_provider.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.12 on 2026-03-24 16:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('code_references', '0002_add_project_repo_created_index'), + ] + + operations = [ + migrations.AlterField( + model_name='featureflagcodereferencesscan', + name='vcs_provider', + field=models.CharField(choices=[('github', 'GitHub'), ('gitlab', 'GitLab')], default='github', max_length=50), + ), + ] diff --git a/api/projects/code_references/types.py b/api/projects/code_references/types.py index 346dde597742..d8277ec28c98 100644 --- a/api/projects/code_references/types.py +++ b/api/projects/code_references/types.py @@ -7,6 +7,7 @@ class VCSProvider(TextChoices): GITHUB = "github", "GitHub" + GITLAB = "gitlab", "GitLab" class JSONCodeReference(TypedDict): diff --git a/api/projects/tags/migrations/0009_add_gitlab_tag_type.py b/api/projects/tags/migrations/0009_add_gitlab_tag_type.py new file mode 100644 index 000000000000..7e8ddcd213cb --- /dev/null +++ b/api/projects/tags/migrations/0009_add_gitlab_tag_type.py @@ -0,0 +1,27 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tags", "0008_alter_tag_type"), + ] + + operations = [ + migrations.AlterField( + model_name="tag", + name="type", + field=models.CharField( + choices=[ + ("NONE", "None"), + ("STALE", "Stale"), + ("GITHUB", "Github"), + ("GITLAB", "Gitlab"), + ("UNHEALTHY", "Unhealthy"), + ], + default="NONE", + help_text="Field used to provide a consistent identifier for the FE and API to use for business logic.", + max_length=100, + ), + ), + ] diff --git a/api/projects/tags/models.py b/api/projects/tags/models.py index 2c3de7f32293..e29719947056 100644 --- a/api/projects/tags/models.py +++ b/api/projects/tags/models.py @@ -8,6 +8,7 @@ class TagType(models.Choices): NONE = "NONE" STALE = "STALE" GITHUB = "GITHUB" + GITLAB = "GITLAB" UNHEALTHY = "UNHEALTHY" diff --git a/api/projects/urls.py b/api/projects/urls.py index e65b86ffb19f..e342f23ce20f 100644 --- a/api/projects/urls.py +++ b/api/projects/urls.py @@ -19,6 +19,14 @@ from features.multivariate.views import MultivariateFeatureOptionViewSet from features.views import FeatureViewSet from integrations.datadog.views import DataDogConfigurationViewSet +from integrations.gitlab.views import ( + GitLabConfigurationViewSet, + create_cleanup_issue as gitlab_create_cleanup_issue, + fetch_issues as gitlab_fetch_issues, + fetch_merge_requests, + fetch_project_members, + fetch_projects, +) from integrations.grafana.views import GrafanaProjectConfigurationViewSet from integrations.launch_darkly.views import LaunchDarklyImportRequestViewSet from integrations.new_relic.views import NewRelicConfigurationViewSet @@ -70,6 +78,11 @@ GrafanaProjectConfigurationViewSet, basename="integrations-grafana", ) +projects_router.register( + r"integrations/gitlab", + GitLabConfigurationViewSet, + basename="integrations-gitlab", +) projects_router.register( "audit", ProjectAuditLogViewSet, @@ -139,4 +152,29 @@ FeatureImportListView.as_view(), name="feature-imports", ), + path( + "/gitlab/issues/", + gitlab_fetch_issues, + name="get-gitlab-issues", + ), + path( + "/gitlab/project-members/", + fetch_project_members, + name="get-gitlab-project-members", + ), + path( + "/gitlab/merge-requests/", + fetch_merge_requests, + name="get-gitlab-merge-requests", + ), + path( + "/gitlab/projects/", + fetch_projects, + name="get-gitlab-projects", + ), + path( + "/gitlab/create-cleanup-issue/", + gitlab_create_cleanup_issue, + name="create-gitlab-cleanup-issue", + ), ] diff --git a/api/tests/unit/features/test_unit_feature_external_resources_views.py b/api/tests/unit/features/test_unit_feature_external_resources_views.py index 668035d916a0..ed575a8445b9 100644 --- a/api/tests/unit/features/test_unit_feature_external_resources_views.py +++ b/api/tests/unit/features/test_unit_feature_external_resources_views.py @@ -24,6 +24,7 @@ from features.versioning.models import EnvironmentFeatureVersion from integrations.github.constants import GITHUB_API_URL, GITHUB_API_VERSION from integrations.github.models import GithubConfiguration, GitHubRepository +from integrations.gitlab.models import GitLabConfiguration from projects.models import Project from projects.tags.models import Tag from segments.models import Segment @@ -929,3 +930,437 @@ def test_create_feature_external_resource__duplicate_feature_and_url__returns_40 response.json()["non_field_errors"][0] == "The fields feature, url must make a unique set." ) + + +# --------------------------------------------------------------- +# GitLab external resource tests +# --------------------------------------------------------------- + + +@responses.activate +def test_create_feature_external_resource__valid_gitlab_issue__creates_resource( + admin_client_new: APIClient, + feature: Feature, + project: Project, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + mock_label = mocker.patch( + "features.feature_external_resources.views.label_gitlab_issue_mr" + ) + feature_external_resource_data = { + "type": "GITLAB_ISSUE", + "url": "https://gitlab.example.com/testgroup/testrepo/-/issues/5", + "feature": feature.id, + "metadata": {"state": "opened"}, + } + url = reverse( + "api-v1:projects:feature-external-resources-list", + kwargs={"project_pk": project.id, "feature_pk": feature.id}, + ) + + # When + response = admin_client_new.post( + url, data=feature_external_resource_data, format="json" + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert FeatureExternalResource.objects.filter( + feature=feature, type="GITLAB_ISSUE" + ).exists() + mock_label.assert_called_once_with( + instance_url=gitlab_configuration.gitlab_instance_url, + access_token=gitlab_configuration.access_token, + gitlab_project_id=gitlab_configuration.gitlab_project_id, + resource_type="issues", + resource_iid=5, + ) + + +@responses.activate +def test_create_feature_external_resource__valid_gitlab_mr__creates_resource( + admin_client_new: APIClient, + feature: Feature, + project: Project, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + mock_label = mocker.patch( + "features.feature_external_resources.views.label_gitlab_issue_mr" + ) + feature_external_resource_data = { + "type": "GITLAB_MR", + "url": "https://gitlab.example.com/testgroup/testrepo/-/merge_requests/3", + "feature": feature.id, + "metadata": {"state": "opened"}, + } + url = reverse( + "api-v1:projects:feature-external-resources-list", + kwargs={"project_pk": project.id, "feature_pk": feature.id}, + ) + + # When + response = admin_client_new.post( + url, data=feature_external_resource_data, format="json" + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert FeatureExternalResource.objects.filter( + feature=feature, type="GITLAB_MR" + ).exists() + mock_label.assert_called_once_with( + instance_url=gitlab_configuration.gitlab_instance_url, + access_token=gitlab_configuration.access_token, + gitlab_project_id=gitlab_configuration.gitlab_project_id, + resource_type="merge_requests", + resource_iid=3, + ) + + +def test_create_feature_external_resource__invalid_gitlab_url__returns_400( + admin_client_new: APIClient, + feature: Feature, + project: Project, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + feature_external_resource_data = { + "type": "GITLAB_ISSUE", + "url": "https://gitlab.example.com/not-a-valid-issue-url", + "feature": feature.id, + "metadata": {"state": "opened"}, + } + url = reverse( + "api-v1:projects:feature-external-resources-list", + kwargs={"project_pk": project.id, "feature_pk": feature.id}, + ) + + # When + response = admin_client_new.post( + url, data=feature_external_resource_data, format="json" + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["detail"] == "Invalid GitLab Issue/MR URL" + + +def test_create_feature_external_resource__no_gitlab_config__returns_400( + admin_client_new: APIClient, + feature: Feature, + project: Project, +) -> None: + # Given + feature_external_resource_data = { + "type": "GITLAB_ISSUE", + "url": "https://gitlab.example.com/testgroup/testrepo/-/issues/5", + "feature": feature.id, + "metadata": {"state": "opened"}, + } + url = reverse( + "api-v1:projects:feature-external-resources-list", + kwargs={"project_pk": project.id, "feature_pk": feature.id}, + ) + + # When + response = admin_client_new.post( + url, data=feature_external_resource_data, format="json" + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "doesn't have a valid GitLab integration" in response.json()["detail"] + + +@responses.activate +def test_list_feature_external_resources__gitlab_resource__returns_metadata( + admin_client_new: APIClient, + feature: Feature, + project: Project, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/issues/7", + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened"}', + ) + mock_get_metadata = mocker.patch( + "integrations.gitlab.client.get_gitlab_issue_mr_title_and_state", + return_value={"title": "My GitLab Issue", "state": "opened"}, + ) + + url = reverse( + "api-v1:projects:feature-external-resources-list", + kwargs={"project_pk": project.id, "feature_pk": feature.id}, + ) + + # When + response = admin_client_new.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + results = response.json()["results"] + assert len(results) == 1 + assert results[0]["metadata"]["title"] == "My GitLab Issue" + assert results[0]["metadata"]["state"] == "opened" + mock_get_metadata.assert_called_once() + + +# --------------------------------------------------------------- +# GitLab call_gitlab_task via serializer save tests +# --------------------------------------------------------------- + + +def test_call_gitlab_task__feature_state_updated__calls_task( + staff_user: FFAdminUser, + staff_client: APIClient, + with_environment_permissions: WithEnvironmentPermissionsCallable, + feature: Feature, + project: Project, + gitlab_configuration: GitLabConfiguration, + environment: Environment, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/issues/1", + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened"}', + ) + mock_call_gitlab = mocker.patch( + "integrations.gitlab.gitlab.call_gitlab_task" + ) + with_environment_permissions([UPDATE_FEATURE_STATE], environment.id, False) + feature_state = FeatureState.objects.get( + feature=feature, environment=environment.id + ) + payload = dict(FeatureStateSerializerBasic(instance=feature_state).data) + payload["enabled"] = not feature_state.enabled + url = reverse( + viewname="api-v1:environments:environment-featurestates-detail", + kwargs={"environment_api_key": environment.api_key, "pk": feature_state.id}, + ) + + # When + response = staff_client.put(path=url, data=payload, format="json") + + # Then + assert response.status_code == status.HTTP_200_OK + mock_call_gitlab.assert_called_once() + + +def test_call_gitlab_task__v2_versioning_feature_state_updated__calls_task( + admin_client_new: APIClient, + environment_v2_versioning: Environment, + feature: Feature, + project: Project, + gitlab_configuration: GitLabConfiguration, + environment: Environment, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/issues/1", + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened"}', + ) + mock_call_gitlab = mocker.patch( + "integrations.gitlab.gitlab.call_gitlab_task" + ) + + environment_feature_version = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + segment = Segment.objects.create(name="segment", project=project) + + url = reverse( + "api-v1:versioning:environment-feature-version-featurestates-list", + args=[ + environment_v2_versioning.id, + feature.id, + environment_feature_version.uuid, + ], + ) + data = { + "feature_segment": {"segment": segment.id}, + "enabled": True, + "feature_state_value": { + "string_value": "segment value!", + }, + } + + # When + response = admin_client_new.post( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + mock_call_gitlab.assert_called_once() + + +# --------------------------------------------------------------- +# FeatureExternalResourceViewSet.get_queryset — "pk" branch (line 44) +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_get_feature_external_resource__with_pk__returns_single_resource( + admin_client_new: APIClient, + feature: Feature, + project: Project, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + resource = FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/issues/5", + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened"}', + ) + url = reverse( + "api-v1:projects:feature-external-resources-detail", + kwargs={ + "project_pk": project.id, + "feature_pk": feature.id, + "pk": resource.id, + }, + ) + + # When + response = admin_client_new.get(url) + + # Then — get_queryset filters by pk (line 47) + assert response.status_code == status.HTTP_200_OK + assert response.json()["id"] == resource.id + assert response.json()["url"] == resource.url + + +# --------------------------------------------------------------- +# FeatureExternalResourceViewSet.list — GITLAB_MR metadata branch (lines 89-90) +# --------------------------------------------------------------- + + +@pytest.mark.django_db +@responses.activate +def test_list_feature_external_resources__gitlab_mr_type__fetches_metadata( + admin_client_new: APIClient, + feature: Feature, + project: Project, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + mr_url = "https://gitlab.example.com/testgroup/testrepo/-/merge_requests/7" + FeatureExternalResource.objects.create( + url=mr_url, + type="GITLAB_MR", + feature=feature, + metadata='{"state": "opened"}', + ) + responses.add( + responses.GET, + f"{gitlab_configuration.gitlab_instance_url}/api/v4/projects/{gitlab_configuration.gitlab_project_id}/merge_requests/7", + json={"title": "My MR", "state": "opened"}, + status=200, + ) + + url = reverse( + "api-v1:projects:feature-external-resources-list", + kwargs={"project_pk": project.id, "feature_pk": feature.id}, + ) + + # When + response = admin_client_new.get(url) + response_json = response.json() + + # Then — GITLAB_MR branch (lines 89-90) is exercised, metadata populated + assert response.status_code == status.HTTP_200_OK + assert len(response_json["results"]) == 1 + result = response_json["results"][0] + assert result["type"] == "GITLAB_MR" + assert result["metadata"]["title"] == "My MR" + + +# --------------------------------------------------------------- +# FeatureExternalResourceViewSet.list — exception swallowed (lines 104-105) +# --------------------------------------------------------------- + + +@pytest.mark.django_db +@responses.activate +def test_list_feature_external_resources__gitlab_api_fails__swallows_exception( + admin_client_new: APIClient, + feature: Feature, + project: Project, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + issue_url = "https://gitlab.example.com/testgroup/testrepo/-/issues/3" + FeatureExternalResource.objects.create( + url=issue_url, + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened"}', + ) + # Simulate the GitLab API returning an error so get_gitlab_issue_mr_title_and_state raises + responses.add( + responses.GET, + f"{gitlab_configuration.gitlab_instance_url}/api/v4/projects/{gitlab_configuration.gitlab_project_id}/issues/3", + status=500, + ) + + url = reverse( + "api-v1:projects:feature-external-resources-list", + kwargs={"project_pk": project.id, "feature_pk": feature.id}, + ) + + # When + response = admin_client_new.get(url) + response_json = response.json() + + # Then — exception is swallowed (lines 104-105); response is still successful. + # The stored metadata is returned because only the live API fetch fails; DB data is unaffected. + assert response.status_code == status.HTTP_200_OK + assert len(response_json["results"]) == 1 + result = response_json["results"][0] + assert result.get("metadata") == {"state": "opened"} diff --git a/api/tests/unit/integrations/gitlab/__init__.py b/api/tests/unit/integrations/gitlab/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_client.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_client.py new file mode 100644 index 000000000000..9aaab5d3511b --- /dev/null +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_client.py @@ -0,0 +1,459 @@ +import json + +import pytest +import responses + +from integrations.gitlab.client import ( + build_paginated_response, + build_request_headers, + create_flagsmith_flag_label, + create_gitlab_issue, + fetch_gitlab_project_members, + fetch_gitlab_projects, + fetch_search_gitlab_resource, + get_gitlab_issue_mr_title_and_state, + label_gitlab_issue_mr, + post_comment_to_gitlab, +) +from integrations.gitlab.dataclasses import ( + IssueQueryParams, + PaginatedQueryParams, + ProjectQueryParams, +) + +INSTANCE_URL = "https://gitlab.example.com" +ACCESS_TOKEN = "test-access-token" + + +def test_build_request_headers__valid_token__returns_correct_headers() -> None: + # Given / When + headers = build_request_headers(ACCESS_TOKEN) + # Then + assert headers == {"PRIVATE-TOKEN": ACCESS_TOKEN} + + +@responses.activate +def test_fetch_gitlab_projects__valid_token__returns_projects() -> None: + # Given + responses.add( + responses.GET, + f"{INSTANCE_URL}/api/v4/projects", + json=[ + {"id": 1, "name": "my-project", "path_with_namespace": "group/my-project"}, + {"id": 2, "name": "other", "path_with_namespace": "group/other"}, + ], + status=200, + ) + params = PaginatedQueryParams(page=1, page_size=20) + + # When + result = fetch_gitlab_projects( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + params=params, + ) + + # Then + assert len(result["results"]) == 2 + assert result["results"][0]["id"] == 1 + assert result["results"][0]["path_with_namespace"] == "group/my-project" + + +@responses.activate +def test_fetch_search_gitlab_resource__issues__returns_results() -> None: + # Given + responses.add( + responses.GET, + f"{INSTANCE_URL}/api/v4/projects/1/issues", + json=[ + { + "id": 10, + "iid": 5, + "title": "Bug fix", + "state": "opened", + "web_url": f"{INSTANCE_URL}/group/project/-/issues/5", + }, + ], + status=200, + headers={"x-total": "1"}, + ) + params = IssueQueryParams(gitlab_project_id=1, project_name="group/project") + + # When + result = fetch_search_gitlab_resource( + resource_type="issue", + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + params=params, + ) + + # Then + assert len(result["results"]) == 1 + assert result["results"][0]["title"] == "Bug fix" + + +@responses.activate +def test_post_comment_to_gitlab__issue__posts_note() -> None: + # Given + responses.add( + responses.POST, + f"{INSTANCE_URL}/api/v4/projects/1/issues/5/notes", + json={"id": 100, "body": "test comment"}, + status=201, + ) + + # When + result = post_comment_to_gitlab( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + gitlab_project_id=1, + resource_type="issues", + resource_iid=5, + body="test comment", + ) + + # Then + assert result["body"] == "test comment" + + +@responses.activate +def test_create_gitlab_issue__valid_data__creates_issue() -> None: + # Given + responses.add( + responses.POST, + f"{INSTANCE_URL}/api/v4/projects/1/issues", + json={ + "iid": 42, + "title": "Cleanup flag", + "state": "opened", + "web_url": f"{INSTANCE_URL}/group/project/-/issues/42", + }, + status=201, + ) + + # When + result = create_gitlab_issue( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + gitlab_project_id=1, + title="Cleanup flag", + body="Remove stale flag", + ) + + # Then + assert result["title"] == "Cleanup flag" + + +@responses.activate +def test_get_gitlab_issue_mr_title_and_state__valid_resource__returns_metadata() -> None: + # Given + responses.add( + responses.GET, + f"{INSTANCE_URL}/api/v4/projects/1/issues/5", + json={"title": "Bug fix", "state": "opened"}, + status=200, + ) + + # When + result = get_gitlab_issue_mr_title_and_state( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + gitlab_project_id=1, + resource_type="issues", + resource_iid=5, + ) + + # Then + assert result == {"title": "Bug fix", "state": "opened"} + + +# --------------------------------------------------------------- +# fetch_gitlab_project_members tests +# --------------------------------------------------------------- + + +@responses.activate +def test_fetch_gitlab_project_members__happy_path__returns_members() -> None: + # Given + responses.add( + responses.GET, + f"{INSTANCE_URL}/api/v4/projects/1/members", + json=[ + { + "username": "jdoe", + "avatar_url": "https://gitlab.example.com/avatar/jdoe", + "name": "John Doe", + } + ], + status=200, + headers={"x-page": "1", "x-total-pages": "1"}, + ) + params = ProjectQueryParams(gitlab_project_id=1, project_name="group/project") + + # When + result = fetch_gitlab_project_members( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + params=params, + ) + + # Then + assert len(result["results"]) == 1 + assert result["results"][0]["username"] == "jdoe" + assert result["results"][0]["name"] == "John Doe" + + +# --------------------------------------------------------------- +# create_flagsmith_flag_label tests +# --------------------------------------------------------------- + + +@responses.activate +def test_create_flagsmith_flag_label__happy_path__creates_label() -> None: + # Given + responses.add( + responses.POST, + f"{INSTANCE_URL}/api/v4/projects/1/labels", + json={"id": 10, "name": "Flagsmith Flag"}, + status=201, + ) + + # When + result = create_flagsmith_flag_label( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + gitlab_project_id=1, + ) + + # Then + assert result is not None + assert result["name"] == "Flagsmith Flag" + + +@responses.activate +def test_create_flagsmith_flag_label__already_exists__returns_none() -> None: + # Given + responses.add( + responses.POST, + f"{INSTANCE_URL}/api/v4/projects/1/labels", + body=json.dumps({"message": "Label already exists"}), + status=409, + content_type="application/json", + ) + + # When + result = create_flagsmith_flag_label( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + gitlab_project_id=1, + ) + + # Then + assert result is None + + +@responses.activate +def test_create_flagsmith_flag_label__other_error__raises() -> None: + # Given + responses.add( + responses.POST, + f"{INSTANCE_URL}/api/v4/projects/1/labels", + body=json.dumps({"message": "Forbidden"}), + status=403, + content_type="application/json", + ) + + # When / Then + with pytest.raises(Exception): + create_flagsmith_flag_label( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + gitlab_project_id=1, + ) + + +# --------------------------------------------------------------- +# label_gitlab_issue_mr tests +# --------------------------------------------------------------- + + +@responses.activate +def test_label_gitlab_issue_mr__happy_path__adds_label() -> None: + # Given + responses.add( + responses.PUT, + f"{INSTANCE_URL}/api/v4/projects/1/issues/5", + json={"id": 5, "labels": ["Flagsmith Flag"]}, + status=200, + ) + + # When + result = label_gitlab_issue_mr( + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + gitlab_project_id=1, + resource_type="issues", + resource_iid=5, + ) + + # Then + assert result["labels"] == ["Flagsmith Flag"] + + +# --------------------------------------------------------------- +# build_paginated_response tests +# --------------------------------------------------------------- + + +def test_build_paginated_response__pagination_headers__returns_previous_and_next() -> None: + # Given + import requests + + resp = requests.models.Response() + resp.headers["x-page"] = "2" + resp.headers["x-total-pages"] = "3" + + # When + result = build_paginated_response( + results=[{"id": 1}], + response=resp, + total_count=10, + ) + + # Then + assert result["previous"] == 1 + assert result["next"] == 3 + assert result["total_count"] == 10 + + +def test_build_paginated_response__first_page__no_previous() -> None: + # Given + import requests + + resp = requests.models.Response() + resp.headers["x-page"] = "1" + resp.headers["x-total-pages"] = "3" + + # When + result = build_paginated_response( + results=[{"id": 1}], + response=resp, + ) + + # Then + assert "previous" not in result + assert result["next"] == 2 + + +def test_build_paginated_response__last_page__no_next() -> None: + # Given + import requests + + resp = requests.models.Response() + resp.headers["x-page"] = "3" + resp.headers["x-total-pages"] = "3" + + # When + result = build_paginated_response( + results=[{"id": 1}], + response=resp, + ) + + # Then + assert result["previous"] == 2 + assert "next" not in result + + +# --------------------------------------------------------------- +# fetch_search_gitlab_resource — optional filter params +# --------------------------------------------------------------- + + +@responses.activate +def test_fetch_search_gitlab_resource__with_search_text__appends_search_param() -> None: + # Given + responses.add( + responses.GET, + f"{INSTANCE_URL}/api/v4/projects/1/issues", + json=[], + status=200, + headers={"x-total": "0"}, + ) + params = IssueQueryParams( + gitlab_project_id=1, + project_name="group/project", + search_text="my search", + ) + + # When + fetch_search_gitlab_resource( + resource_type="issue", + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + params=params, + ) + + # Then + assert len(responses.calls) == 1 + request_url: str = responses.calls[0].request.url # type: ignore[union-attr] + assert "search=my+search" in request_url or "search=my%20search" in request_url + + +@responses.activate +def test_fetch_search_gitlab_resource__with_author__appends_author_param() -> None: + # Given + responses.add( + responses.GET, + f"{INSTANCE_URL}/api/v4/projects/1/issues", + json=[], + status=200, + headers={"x-total": "0"}, + ) + params = IssueQueryParams( + gitlab_project_id=1, + project_name="group/project", + author="jdoe", + ) + + # When + fetch_search_gitlab_resource( + resource_type="issue", + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + params=params, + ) + + # Then + assert len(responses.calls) == 1 + request_url: str = responses.calls[0].request.url # type: ignore[union-attr] + assert "author_username=jdoe" in request_url + + +@responses.activate +def test_fetch_search_gitlab_resource__with_assignee__appends_assignee_param() -> None: + # Given + responses.add( + responses.GET, + f"{INSTANCE_URL}/api/v4/projects/1/issues", + json=[], + status=200, + headers={"x-total": "0"}, + ) + params = IssueQueryParams( + gitlab_project_id=1, + project_name="group/project", + assignee="jsmith", + ) + + # When + fetch_search_gitlab_resource( + resource_type="issue", + instance_url=INSTANCE_URL, + access_token=ACCESS_TOKEN, + params=params, + ) + + # Then + assert len(responses.calls) == 1 + request_url: str = responses.calls[0].request.url # type: ignore[union-attr] + assert "assignee_username=jsmith" in request_url diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py new file mode 100644 index 000000000000..3ad7588c63de --- /dev/null +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py @@ -0,0 +1,1669 @@ +from dataclasses import asdict +from typing import Any + +import pytest +from pytest_mock import MockerFixture + +from features.feature_external_resources.models import ( + FeatureExternalResource, +) +from features.models import Feature +from integrations.gitlab.constants import ( + DELETED_FEATURE_TEXT, + DELETED_SEGMENT_OVERRIDE_TEXT, + LINK_FEATURE_TITLE, + UNLINKED_FEATURE_TEXT, + UPDATED_FEATURE_TEXT, + GitLabEventType, + GitLabTag, +) +from integrations.gitlab.dataclasses import ( + CallGitLabData, + GitLabData, + PaginatedQueryParams, +) +from integrations.gitlab.gitlab import ( + _get_tag_value_for_event, + call_gitlab_task, + generate_body_comment, + generate_data, + handle_gitlab_webhook_event, + tag_feature_per_gitlab_event, +) +from integrations.gitlab.models import GitLabConfiguration +from integrations.gitlab.tasks import ( + _post_to_resource, + _resolve_resource_urls_for_event, + call_gitlab_app_webhook_for_feature_state, + send_post_request, +) +from projects.tags.models import TagType + +# --------------------------------------------------------------- +# _get_tag_value_for_event tests +# --------------------------------------------------------------- + + +def test_get_tag_value_for_event__mr_close__returns_mr_closed() -> None: + # Given + event_type = "merge_request" + action = "close" + metadata: dict[str, object] = {} + + # When + result = _get_tag_value_for_event(event_type, action, metadata) + + # Then + assert result == GitLabTag.MR_CLOSED.value + + +def test_get_tag_value_for_event__mr_merge__returns_mr_merged() -> None: + # Given + event_type = "merge_request" + action = "merge" + metadata: dict[str, object] = {} + + # When + result = _get_tag_value_for_event(event_type, action, metadata) + + # Then + assert result == GitLabTag.MR_MERGED.value + + +def test_get_tag_value_for_event__mr_open__returns_mr_open() -> None: + # Given + event_type = "merge_request" + action = "open" + metadata: dict[str, object] = {} + + # When + result = _get_tag_value_for_event(event_type, action, metadata) + + # Then + assert result == GitLabTag.MR_OPEN.value + + +def test_get_tag_value_for_event__mr_update_with_draft__returns_mr_draft() -> None: + # Given + event_type = "merge_request" + action = "update" + metadata = {"draft": True} + + # When + result = _get_tag_value_for_event(event_type, action, metadata) + + # Then + assert result == GitLabTag.MR_DRAFT.value + + +def test_get_tag_value_for_event__mr_update_without_draft__returns_none() -> None: + # Given + event_type = "merge_request" + action = "update" + metadata = {"draft": False} + + # When + result = _get_tag_value_for_event(event_type, action, metadata) + + # Then + assert result is None + + +def test_get_tag_value_for_event__issue_close__returns_issue_closed() -> None: + # Given + event_type = "issue" + action = "close" + metadata: dict[str, object] = {} + + # When + result = _get_tag_value_for_event(event_type, action, metadata) + + # Then + assert result == GitLabTag.ISSUE_CLOSED.value + + +def test_get_tag_value_for_event__issue_open__returns_issue_open() -> None: + # Given + event_type = "issue" + action = "open" + metadata: dict[str, object] = {} + + # When + result = _get_tag_value_for_event(event_type, action, metadata) + + # Then + assert result == GitLabTag.ISSUE_OPEN.value + + +def test_get_tag_value_for_event__issue_reopen__returns_issue_open() -> None: + # Given + event_type = "issue" + action = "reopen" + metadata: dict[str, object] = {} + + # When + result = _get_tag_value_for_event(event_type, action, metadata) + + # Then + assert result == GitLabTag.ISSUE_OPEN.value + + +def test_get_tag_value_for_event__unknown_action__returns_none() -> None: + # Given + event_type = "issue" + action = "unknown_action" + metadata: dict[str, object] = {} + + # When + result = _get_tag_value_for_event(event_type, action, metadata) + + # Then + assert result is None + + +def test_get_tag_value_for_event__unknown_event_type__returns_none() -> None: + # Given + event_type = "push" + action = "open" + metadata: dict[str, object] = {} + + # When + result = _get_tag_value_for_event(event_type, action, metadata) + + # Then + assert result is None + + +# --------------------------------------------------------------- +# tag_feature_per_gitlab_event tests +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_tag_feature_per_gitlab_event__empty_feature__returns_none() -> None: + # Given + # No feature is linked to the given URL in the database + + # When / Then - should not raise + tag_feature_per_gitlab_event( + event_type="merge_request", + action="merge", + metadata={"web_url": "https://gitlab.com/group/project/-/merge_requests/1"}, + project_path="group/project", + ) + + +@pytest.mark.django_db +def test_tag_feature_per_gitlab_event__matching_feature_tagging_enabled__adds_tag( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + web_url = "https://gitlab.example.com/testgroup/testrepo/-/merge_requests/1" + FeatureExternalResource.objects.create( + url=web_url, + type="GITLAB_MR", + feature=feature, + metadata='{"state": "opened"}', + ) + + # When + tag_feature_per_gitlab_event( + event_type="merge_request", + action="merge", + metadata={"web_url": web_url}, + project_path="testgroup/testrepo", + ) + + # Then + feature.refresh_from_db() + tag_labels = list(feature.tags.values_list("label", flat=True)) + assert GitLabTag.MR_MERGED.value in tag_labels + + +@pytest.mark.django_db +def test_tag_feature_per_gitlab_event__tagging_disabled__does_not_tag( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + gitlab_configuration.tagging_enabled = False + gitlab_configuration.save() + + web_url = "https://gitlab.example.com/testgroup/testrepo/-/merge_requests/1" + FeatureExternalResource.objects.create( + url=web_url, + type="GITLAB_MR", + feature=feature, + metadata='{"state": "opened"}', + ) + + # When + tag_feature_per_gitlab_event( + event_type="merge_request", + action="merge", + metadata={"web_url": web_url}, + project_path="testgroup/testrepo", + ) + + # Then + feature.refresh_from_db() + gitlab_tags = feature.tags.filter(type=TagType.GITLAB.value) + assert not gitlab_tags.filter(label=GitLabTag.MR_MERGED.value).exists() + + +@pytest.mark.django_db +def test_tag_feature_per_gitlab_event__work_items_url_variant__finds_feature( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given - feature linked with work_items URL, but webhook sends issues URL + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + work_items_url = ( + "https://gitlab.example.com/testgroup/testrepo/-/work_items/5" + ) + FeatureExternalResource.objects.create( + url=work_items_url, + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened"}', + ) + + issues_url = "https://gitlab.example.com/testgroup/testrepo/-/issues/5" + + # When + tag_feature_per_gitlab_event( + event_type="issue", + action="close", + metadata={"web_url": issues_url}, + project_path="testgroup/testrepo", + ) + + # Then + feature.refresh_from_db() + tag_labels = list(feature.tags.values_list("label", flat=True)) + assert GitLabTag.ISSUE_CLOSED.value in tag_labels + + +@pytest.mark.django_db +def test_tag_feature_per_gitlab_event__no_gitlab_config__returns_none( + feature: Feature, + mocker: MockerFixture, +) -> None: + # Given - feature has external resource but no GitLab config + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + web_url = "https://gitlab.example.com/testgroup/testrepo/-/merge_requests/1" + FeatureExternalResource.objects.create( + url=web_url, + type="GITLAB_MR", + feature=feature, + metadata='{"state": "opened"}', + ) + + # When + tag_feature_per_gitlab_event( + event_type="merge_request", + action="merge", + metadata={"web_url": web_url}, + project_path="testgroup/testrepo", + ) + + # Then + assert feature.tags.count() == 0 + + +@pytest.mark.django_db +def test_tag_feature_per_gitlab_event__null_tag_value__returns_none( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given - MR update without draft = no tag change + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + web_url = "https://gitlab.example.com/testgroup/testrepo/-/merge_requests/1" + FeatureExternalResource.objects.create( + url=web_url, + type="GITLAB_MR", + feature=feature, + metadata='{"state": "opened"}', + ) + + # When + tag_feature_per_gitlab_event( + event_type="merge_request", + action="update", + metadata={"web_url": web_url, "draft": False}, + project_path="testgroup/testrepo", + ) + + # Then + assert feature.tags.count() == 0 + + +# --------------------------------------------------------------- +# handle_gitlab_webhook_event tests +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_handle_gitlab_webhook_event__merge_request__calls_tag_feature( + mocker: MockerFixture, +) -> None: + # Given + mock_tag = mocker.patch( + "integrations.gitlab.gitlab.tag_feature_per_gitlab_event" + ) + payload = { + "object_attributes": { + "action": "merge", + "url": "https://gitlab.example.com/group/project/-/merge_requests/1", + "state": "merged", + "work_in_progress": False, + }, + "project": { + "path_with_namespace": "group/project", + }, + } + + # When + handle_gitlab_webhook_event(event_type="merge_request", payload=payload) + + # Then + mock_tag.assert_called_once_with( + "merge_request", + "merge", + { + "web_url": "https://gitlab.example.com/group/project/-/merge_requests/1", + "draft": False, + "merged": True, + }, + "group/project", + ) + + +@pytest.mark.django_db +def test_handle_gitlab_webhook_event__issue__calls_tag_feature( + mocker: MockerFixture, +) -> None: + # Given + mock_tag = mocker.patch( + "integrations.gitlab.gitlab.tag_feature_per_gitlab_event" + ) + payload = { + "object_attributes": { + "action": "close", + "url": "https://gitlab.example.com/group/project/-/issues/5", + }, + "project": { + "path_with_namespace": "group/project", + }, + } + + # When + handle_gitlab_webhook_event(event_type="issue", payload=payload) + + # Then + mock_tag.assert_called_once_with( + "issue", + "close", + {"web_url": "https://gitlab.example.com/group/project/-/issues/5"}, + "group/project", + ) + + +def test_handle_gitlab_webhook_event__unknown_event__does_nothing( + mocker: MockerFixture, +) -> None: + # Given + mock_tag = mocker.patch( + "integrations.gitlab.gitlab.tag_feature_per_gitlab_event" + ) + + # When + handle_gitlab_webhook_event(event_type="push", payload={}) + + # Then + mock_tag.assert_not_called() + + +# --------------------------------------------------------------- +# generate_body_comment tests +# --------------------------------------------------------------- + + +def test_generate_body_comment__flag_deleted__returns_deleted_text() -> None: + # Given + event_type = GitLabEventType.FLAG_DELETED.value + + # When + result = generate_body_comment( + name="my_flag", + event_type=event_type, + feature_id=1, + feature_states=[], + ) + + # Then + assert result == DELETED_FEATURE_TEXT % "my_flag" + + +def test_generate_body_comment__resource_removed__returns_unlinked_text() -> None: + # Given + event_type = GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value + + # When + result = generate_body_comment( + name="my_flag", + event_type=event_type, + feature_id=1, + feature_states=[], + ) + + # Then + assert result == UNLINKED_FEATURE_TEXT % "my_flag" + + +def test_generate_body_comment__segment_override_deleted__returns_segment_deleted_text() -> None: + # Given + event_type = GitLabEventType.SEGMENT_OVERRIDE_DELETED.value + + # When + result = generate_body_comment( + name="my_flag", + event_type=event_type, + feature_id=1, + feature_states=[], + segment_name="my_segment", + ) + + # Then + assert result == DELETED_SEGMENT_OVERRIDE_TEXT % ("my_segment", "my_flag") + + +def test_generate_body_comment__flag_updated__returns_updated_text_with_table( + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "integrations.gitlab.gitlab.get_current_site_url", + return_value="https://example.com", + ) + event_type = GitLabEventType.FLAG_UPDATED.value + feature_states = [ + { + "environment_name": "Production", + "enabled": True, + "feature_state_value": "on", + "last_updated": "2024-01-01 00:00:00", + "environment_api_key": "api-key-123", + } + ] + + # When + result = generate_body_comment( + name="my_flag", + event_type=event_type, + feature_id=42, + feature_states=feature_states, + project_id=10, + ) + + # Then + assert UPDATED_FEATURE_TEXT % "my_flag" in result + assert "Production" in result + assert "Enabled" in result + + +def test_generate_body_comment__resource_added_with_feature_states__returns_linked_text( + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "integrations.gitlab.gitlab.get_current_site_url", + return_value="https://example.com", + ) + event_type = GitLabEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value + feature_states = [ + { + "environment_name": "Dev", + "enabled": False, + "feature_state_value": None, + "last_updated": "2024-01-01 00:00:00", + "environment_api_key": "api-key-dev", + } + ] + + # When + result = generate_body_comment( + name="my_flag", + event_type=event_type, + feature_id=42, + feature_states=feature_states, + project_id=10, + ) + + # Then + assert LINK_FEATURE_TITLE % "my_flag" in result + assert "Dev" in result + assert "Disabled" in result + + +def test_generate_body_comment__with_segment_feature_states__includes_segment_header( + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "integrations.gitlab.gitlab.get_current_site_url", + return_value="https://example.com", + ) + event_type = GitLabEventType.FLAG_UPDATED.value + feature_states = [ + { + "environment_name": "Production", + "enabled": True, + "feature_state_value": "v1", + "last_updated": "2024-01-01 00:00:00", + "environment_api_key": "api-key-prod", + "segment_name": "beta_users", + } + ] + + # When + result = generate_body_comment( + name="my_flag", + event_type=event_type, + feature_id=42, + feature_states=feature_states, + project_id=10, + ) + + # Then + assert "beta_users" in result + assert "segment-overrides" in result + + +# --------------------------------------------------------------- +# generate_data tests +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_generate_data__with_feature_states__returns_gitlab_data( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + environment: Any, + mocker: MockerFixture, +) -> None: + # Given + from features.models import FeatureState + + feature_state = FeatureState.objects.filter( + feature=feature, identity__isnull=True + ).first() + assert feature_state is not None + + # When + result = generate_data( + gitlab_configuration=gitlab_configuration, + feature=feature, + type=GitLabEventType.FLAG_UPDATED.value, + feature_states=[feature_state], + ) + + # Then + assert result.feature_id == feature.id + assert result.feature_name == feature.name + assert result.type == GitLabEventType.FLAG_UPDATED.value + assert len(result.feature_states) == 1 + assert "environment_name" in result.feature_states[0] + assert result.url is None + + +@pytest.mark.django_db +def test_generate_data__feature_state_with_value__includes_feature_state_value( + gitlab_configuration: GitLabConfiguration, + environment: Any, +) -> None: + # Given + from features.models import Feature, FeatureState + + feature_with_val = Feature.objects.create( + name="flag_with_value", + initial_value="some_value", + project=gitlab_configuration.project, + ) + feature_state = FeatureState.objects.filter( + feature=feature_with_val, identity__isnull=True, environment=environment, + ).first() + assert feature_state is not None + + # When + result = generate_data( + gitlab_configuration=gitlab_configuration, + feature=feature_with_val, + type=GitLabEventType.FLAG_UPDATED.value, + feature_states=[feature_state], + ) + + # Then — line 224: feature_state_value is set + assert len(result.feature_states) == 1 + assert result.feature_states[0]["feature_state_value"] == "some_value" + + +@pytest.mark.django_db +def test_generate_data__without_feature_states__returns_empty_list( + feature: Feature, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given / When + result = generate_data( + gitlab_configuration=gitlab_configuration, + feature=feature, + type=GitLabEventType.FLAG_DELETED.value, + ) + + # Then + assert result.feature_states == [] + assert result.url is None + + +@pytest.mark.django_db +def test_generate_data__resource_removed__sets_url( + feature: Feature, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + removed_url = "https://gitlab.example.com/group/project/-/issues/5" + + # When + result = generate_data( + gitlab_configuration=gitlab_configuration, + feature=feature, + type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value, + url=removed_url, + ) + + # Then + assert result.url == removed_url + + +@pytest.mark.django_db +def test_generate_data__resource_removed_with_feature_states__skips_env_data( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + environment: Any, +) -> None: + # Given + from features.models import FeatureState + + feature_state = FeatureState.objects.filter( + feature=feature, identity__isnull=True + ).first() + assert feature_state is not None + + # When + result = generate_data( + gitlab_configuration=gitlab_configuration, + feature=feature, + type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value, + feature_states=[feature_state], + url="https://gitlab.example.com/group/project/-/issues/1", + ) + + # Then — line 221 False branch: env data is NOT included for REMOVED events + assert len(result.feature_states) == 1 + assert "environment_name" not in result.feature_states[0] + assert "enabled" not in result.feature_states[0] + + +@pytest.mark.django_db +def test_generate_data__with_segment_feature_state__includes_segment_name( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + environment: Any, + mocker: MockerFixture, +) -> None: + # Given + from features.models import FeatureState + + feature_state = FeatureState.objects.filter( + feature=feature, identity__isnull=True + ).first() + assert feature_state is not None + + # When + result = generate_data( + gitlab_configuration=gitlab_configuration, + feature=feature, + type=GitLabEventType.FLAG_UPDATED.value, + feature_states=[feature_state], + segment_name="beta_segment", + ) + + # Then + assert result.segment_name == "beta_segment" + + +# --------------------------------------------------------------- +# call_gitlab_task tests +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_call_gitlab_task__happy_path__calls_webhook_task( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mock_task = mocker.patch( + "integrations.gitlab.gitlab.call_gitlab_app_webhook_for_feature_state" + ) + + # When + call_gitlab_task( + project_id=feature.project_id, + type=GitLabEventType.FLAG_DELETED.value, + feature=feature, + segment_name=None, + url=None, + feature_states=None, + ) + + # Then + mock_task.delay.assert_called_once() + call_args = mock_task.delay.call_args + assert call_args.kwargs["args"] is not None + + +# --------------------------------------------------------------- +# PaginatedQueryParams validation tests +# --------------------------------------------------------------- + + +def test_paginated_query_params__page_less_than_1__raises_value_error() -> None: + # Given / When + # Then + with pytest.raises(ValueError, match="Page must be greater or equal than 1"): + PaginatedQueryParams(page=0, page_size=10) + + +def test_paginated_query_params__page_size_too_large__raises_value_error() -> None: + # Given / When + # Then + with pytest.raises(ValueError, match="Page size must be an integer between 1 and 100"): + PaginatedQueryParams(page=1, page_size=101) + + +def test_paginated_query_params__page_size_less_than_1__raises_value_error() -> None: + # Given / When + # Then + with pytest.raises(ValueError, match="Page size must be an integer between 1 and 100"): + PaginatedQueryParams(page=1, page_size=0) + + +# --------------------------------------------------------------- +# _resolve_resource_urls_for_event tests +# --------------------------------------------------------------- + + +def test_resolve_resource_urls_for_event__flag_updated__returns_all_resource_urls() -> None: + # Given + gitlab_data = GitLabData( + gitlab_instance_url="https://gitlab.example.com", + access_token="token", + feature_id=1, + feature_name="test_flag", + type=GitLabEventType.FLAG_UPDATED.value, + ) + data = CallGitLabData( + event_type=GitLabEventType.FLAG_UPDATED.value, + gitlab_data=gitlab_data, + feature_external_resources=[ + {"type": "GITLAB_ISSUE", "url": "https://gitlab.example.com/group/project/-/issues/1"}, + {"type": "GITLAB_MR", "url": "https://gitlab.example.com/group/project/-/merge_requests/2"}, + ], + ) + + # When + result = _resolve_resource_urls_for_event(data) + + # Then + assert len(result) == 2 + assert "issues/1" in result[0] + assert "merge_requests/2" in result[1] + + +def test_resolve_resource_urls_for_event__flag_deleted__returns_all_resource_urls() -> None: + # Given + gitlab_data = GitLabData( + gitlab_instance_url="https://gitlab.example.com", + access_token="token", + feature_id=1, + feature_name="test_flag", + type=GitLabEventType.FLAG_DELETED.value, + ) + data = CallGitLabData( + event_type=GitLabEventType.FLAG_DELETED.value, + gitlab_data=gitlab_data, + feature_external_resources=[ + {"type": "GITLAB_ISSUE", "url": "https://gitlab.example.com/group/project/-/issues/1"}, + ], + ) + + # When + result = _resolve_resource_urls_for_event(data) + + # Then + assert len(result) == 1 + + +def test_resolve_resource_urls_for_event__resource_removed_with_url__returns_url() -> None: + # Given + gitlab_data = GitLabData( + gitlab_instance_url="https://gitlab.example.com", + access_token="token", + feature_id=1, + feature_name="test_flag", + type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value, + url="https://gitlab.example.com/group/project/-/issues/5", + ) + data = CallGitLabData( + event_type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value, + gitlab_data=gitlab_data, + feature_external_resources=[], + ) + + # When + result = _resolve_resource_urls_for_event(data) + + # Then + assert result == ["https://gitlab.example.com/group/project/-/issues/5"] + + +def test_resolve_resource_urls_for_event__resource_removed_no_url__returns_empty() -> None: + # Given + gitlab_data = GitLabData( + gitlab_instance_url="https://gitlab.example.com", + access_token="token", + feature_id=1, + feature_name="test_flag", + type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value, + url=None, + ) + data = CallGitLabData( + event_type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value, + gitlab_data=gitlab_data, + feature_external_resources=[], + ) + + # When + result = _resolve_resource_urls_for_event(data) + + # Then + assert result == [] + + +def test_resolve_resource_urls_for_event__default_case__returns_last_resource() -> None: + # Given + gitlab_data = GitLabData( + gitlab_instance_url="https://gitlab.example.com", + access_token="token", + feature_id=1, + feature_name="test_flag", + type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value, + ) + data = CallGitLabData( + event_type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value, + gitlab_data=gitlab_data, + feature_external_resources=[ + {"type": "GITLAB_ISSUE", "url": "https://gitlab.example.com/group/project/-/issues/1"}, + {"type": "GITLAB_MR", "url": "https://gitlab.example.com/group/project/-/merge_requests/2"}, + ], + ) + + # When + result = _resolve_resource_urls_for_event(data) + + # Then + assert len(result) == 1 + assert "merge_requests/2" in result[0] + + +def test_resolve_resource_urls_for_event__default_empty_resources__returns_empty() -> None: + # Given + gitlab_data = GitLabData( + gitlab_instance_url="https://gitlab.example.com", + access_token="token", + feature_id=1, + feature_name="test_flag", + type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value, + ) + data = CallGitLabData( + event_type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value, + gitlab_data=gitlab_data, + feature_external_resources=[], + ) + + # When + result = _resolve_resource_urls_for_event(data) + + # Then + assert result == [] + + +# --------------------------------------------------------------- +# _post_to_resource tests +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_post_to_resource__mr_url__posts_to_merge_requests( + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mock_post_comment = mocker.patch( + "integrations.gitlab.tasks.post_comment_to_gitlab" + ) + resource_url = "https://gitlab.example.com/testgroup/testrepo/-/merge_requests/3" + + # When + _post_to_resource( + resource_url=resource_url, + instance_url="https://gitlab.example.com", + access_token="test-token", + body="Test comment", + ) + + # Then + mock_post_comment.assert_called_once_with( + instance_url="https://gitlab.example.com", + access_token="test-token", + gitlab_project_id=gitlab_configuration.gitlab_project_id, + resource_type="merge_requests", + resource_iid=3, + body="Test comment", + ) + + +@pytest.mark.django_db +def test_post_to_resource__issue_url__posts_to_issues( + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mock_post_comment = mocker.patch( + "integrations.gitlab.tasks.post_comment_to_gitlab" + ) + resource_url = "https://gitlab.example.com/testgroup/testrepo/-/issues/7" + + # When + _post_to_resource( + resource_url=resource_url, + instance_url="https://gitlab.example.com", + access_token="test-token", + body="Test comment", + ) + + # Then + mock_post_comment.assert_called_once_with( + instance_url="https://gitlab.example.com", + access_token="test-token", + gitlab_project_id=gitlab_configuration.gitlab_project_id, + resource_type="issues", + resource_iid=7, + body="Test comment", + ) + + +@pytest.mark.django_db +def test_post_to_resource__work_items_url__posts_to_issues( + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mock_post_comment = mocker.patch( + "integrations.gitlab.tasks.post_comment_to_gitlab" + ) + resource_url = "https://gitlab.example.com/testgroup/testrepo/-/work_items/7" + + # When + _post_to_resource( + resource_url=resource_url, + instance_url="https://gitlab.example.com", + access_token="test-token", + body="Test comment", + ) + + # Then + mock_post_comment.assert_called_once_with( + instance_url="https://gitlab.example.com", + access_token="test-token", + gitlab_project_id=gitlab_configuration.gitlab_project_id, + resource_type="issues", + resource_iid=7, + body="Test comment", + ) + + +@pytest.mark.django_db +def test_post_to_resource__unknown_url_format__does_not_post( + mocker: MockerFixture, +) -> None: + # Given + mock_post_comment = mocker.patch( + "integrations.gitlab.tasks.post_comment_to_gitlab" + ) + resource_url = "https://gitlab.example.com/testgroup/testrepo/-/pipelines/1" + + # When + _post_to_resource( + resource_url=resource_url, + instance_url="https://gitlab.example.com", + access_token="test-token", + body="Test comment", + ) + + # Then + mock_post_comment.assert_not_called() + + +@pytest.mark.django_db +def test_post_to_resource__missing_config__does_not_post( + mocker: MockerFixture, +) -> None: + # Given + mock_post_comment = mocker.patch( + "integrations.gitlab.tasks.post_comment_to_gitlab" + ) + resource_url = "https://gitlab.example.com/nonexistent/project/-/issues/1" + + # When + _post_to_resource( + resource_url=resource_url, + instance_url="https://gitlab.example.com", + access_token="test-token", + body="Test comment", + ) + + # Then + mock_post_comment.assert_not_called() + + +def test_post_to_resource__no_iid_in_url__returns_early( + mocker: MockerFixture, +) -> None: + # Given — URL has /-/issues/ but no digits after it + mock_post_comment = mocker.patch( + "integrations.gitlab.tasks.post_comment_to_gitlab" + ) + resource_url = "https://gitlab.example.com/group/project/-/issues/notanumber" + + # When + _post_to_resource( + resource_url=resource_url, + instance_url="https://gitlab.example.com", + access_token="test-token", + body="Test", + ) + + # Then — line 61: early return because iid_match is None + mock_post_comment.assert_not_called() + + +def test_post_to_resource__no_project_path_match__returns_early( + mocker: MockerFixture, +) -> None: + # Given — URL has hyphens in the group name which breaks the [^/-] regex + mock_post_comment = mocker.patch( + "integrations.gitlab.tasks.post_comment_to_gitlab" + ) + resource_url = "https://gitlab.example.com/my-group/my-project/-/issues/1" + + # When + _post_to_resource( + resource_url=resource_url, + instance_url="https://gitlab.example.com", + access_token="test-token", + body="Test", + ) + + # Then — line 68: early return because project_path_match is None + mock_post_comment.assert_not_called() + + +# --------------------------------------------------------------- +# send_post_request tests +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_send_post_request__with_resources__calls_post_to_resource( + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mock_post_to_resource = mocker.patch( + "integrations.gitlab.tasks._post_to_resource" + ) + gitlab_data = GitLabData( + gitlab_instance_url="https://gitlab.example.com", + access_token="test-token", + feature_id=1, + feature_name="test_flag", + type=GitLabEventType.FLAG_UPDATED.value, + feature_states=[], + project_id=1, + ) + data = CallGitLabData( + event_type=GitLabEventType.FLAG_UPDATED.value, + gitlab_data=gitlab_data, + feature_external_resources=[ + {"type": "GITLAB_ISSUE", "url": "https://gitlab.example.com/testgroup/testrepo/-/issues/1"}, + ], + ) + + # When + send_post_request(data) + + # Then + mock_post_to_resource.assert_called_once() + + +# --------------------------------------------------------------- +# call_gitlab_app_webhook_for_feature_state tests +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_call_gitlab_app_webhook_for_feature_state__flag_deleted__posts_to_resources( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/issues/1", + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened"}', + ) + mock_send = mocker.patch("integrations.gitlab.tasks.send_post_request") + + event_data = asdict( + GitLabData( + gitlab_instance_url="https://gitlab.example.com", + access_token="test-token", + feature_id=feature.id, + feature_name=feature.name, + type=GitLabEventType.FLAG_DELETED.value, + project_id=feature.project_id, + ) + ) + + # When + call_gitlab_app_webhook_for_feature_state(event_data=event_data) + + # Then + mock_send.assert_called_once() + call_args = mock_send.call_args[0][0] + assert call_args.event_type == GitLabEventType.FLAG_DELETED.value + + +@pytest.mark.django_db +def test_call_gitlab_app_webhook_for_feature_state__resource_removed__posts_with_url( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mock_send = mocker.patch("integrations.gitlab.tasks.send_post_request") + removed_url = "https://gitlab.example.com/testgroup/testrepo/-/issues/1" + + event_data = asdict( + GitLabData( + gitlab_instance_url="https://gitlab.example.com", + access_token="test-token", + feature_id=feature.id, + feature_name=feature.name, + type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value, + url=removed_url, + project_id=feature.project_id, + ) + ) + + # When + call_gitlab_app_webhook_for_feature_state(event_data=event_data) + + # Then + mock_send.assert_called_once() + call_args = mock_send.call_args[0][0] + assert call_args.event_type == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value + assert call_args.feature_external_resources == [] + + +@pytest.mark.django_db +def test_call_gitlab_app_webhook_for_feature_state__normal_update__posts_to_resources( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/issues/1", + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened"}', + ) + mock_send = mocker.patch("integrations.gitlab.tasks.send_post_request") + + event_data = asdict( + GitLabData( + gitlab_instance_url="https://gitlab.example.com", + access_token="test-token", + feature_id=feature.id, + feature_name=feature.name, + type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value, + project_id=feature.project_id, + ) + ) + + # When + call_gitlab_app_webhook_for_feature_state(event_data=event_data) + + # Then + mock_send.assert_called_once() + call_args = mock_send.call_args[0][0] + assert call_args.event_type == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value + assert len(call_args.feature_external_resources) == 1 + + +@pytest.mark.django_db +def test_call_gitlab_app_webhook_for_feature_state__no_resources__does_not_send( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given - feature has no GitLab external resources + mock_send = mocker.patch("integrations.gitlab.tasks.send_post_request") + + event_data = asdict( + GitLabData( + gitlab_instance_url="https://gitlab.example.com", + access_token="test-token", + feature_id=feature.id, + feature_name=feature.name, + type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value, + project_id=feature.project_id, + ) + ) + + # When + call_gitlab_app_webhook_for_feature_state(event_data=event_data) + + # Then + mock_send.assert_not_called() + + +@pytest.mark.django_db +def test_call_gitlab_app_webhook_for_feature_state__segment_override_deleted__posts_to_resources( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/issues/1", + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened"}', + ) + mock_send = mocker.patch("integrations.gitlab.tasks.send_post_request") + + event_data = asdict( + GitLabData( + gitlab_instance_url="https://gitlab.example.com", + access_token="test-token", + feature_id=feature.id, + feature_name=feature.name, + type=GitLabEventType.SEGMENT_OVERRIDE_DELETED.value, + segment_name="my_segment", + project_id=feature.project_id, + ) + ) + + # When + call_gitlab_app_webhook_for_feature_state(event_data=event_data) + + # Then + mock_send.assert_called_once() + call_args = mock_send.call_args[0][0] + assert call_args.event_type == GitLabEventType.SEGMENT_OVERRIDE_DELETED.value + + +# --------------------------------------------------------------- +# FeatureExternalResource model - GitLab hooks tests +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_handle_gitlab_after_save__with_config_and_tagging__adds_tag_and_calls_task( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mock_call_gitlab_task = mocker.patch( + "integrations.gitlab.gitlab.call_gitlab_app_webhook_for_feature_state", + ) + + # When + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/issues/10", + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened"}', + ) + + # Then + mock_call_gitlab_task.delay.assert_called_once() + # Check tag was added + feature.refresh_from_db() + gitlab_tags = feature.tags.filter(type=TagType.GITLAB.value) + assert gitlab_tags.exists() + + +@pytest.mark.django_db +def test_handle_gitlab_after_save__without_config__returns_early( + feature: Feature, + mocker: MockerFixture, +) -> None: + # Given - no gitlab config + mock_call_gitlab_task = mocker.patch( + "integrations.gitlab.gitlab.call_gitlab_app_webhook_for_feature_state", + ) + + # When + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/issues/10", + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened"}', + ) + + # Then + mock_call_gitlab_task.delay.assert_not_called() + + +@pytest.mark.django_db +def test_execute_before_save_actions__gitlab_delete__calls_task( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mock_call_task = mocker.patch( + "integrations.gitlab.gitlab.call_gitlab_app_webhook_for_feature_state", + ) + resource = FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/issues/10", + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened"}', + ) + # Reset mock to ignore the after_save call + mock_call_task.delay.reset_mock() + + # When + resource.delete() + + # Then + mock_call_task.delay.assert_called_once() + + +@pytest.mark.django_db +def test_execute_before_save_actions__gitlab_delete_no_config__returns_early( + feature: Feature, + mocker: MockerFixture, +) -> None: + # Given - no gitlab config + mock_call_task = mocker.patch( + "integrations.gitlab.gitlab.call_gitlab_app_webhook_for_feature_state", + ) + resource = FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/issues/10", + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened"}', + ) + mock_call_task.delay.reset_mock() + + # When + resource.delete() + + # Then + mock_call_task.delay.assert_not_called() + + +# --------------------------------------------------------------- +# Feature.create_github_comment - GitLab branch tests +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_feature_create_github_comment__with_gitlab_config__calls_gitlab_task( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/issues/1", + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened"}', + ) + mock_call_task = mocker.patch( + "integrations.gitlab.gitlab.call_gitlab_app_webhook_for_feature_state", + ) + + # When - soft delete the feature + feature.delete() + + # Then + mock_call_task.delay.assert_called_once() + + +# --------------------------------------------------------------- +# FeatureSegment.create_github_comment - GitLab branch tests +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_feature_segment_create_github_comment__with_gitlab_config__calls_gitlab_task( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + environment: Any, + mocker: MockerFixture, +) -> None: + # Given + from features.models import FeatureSegment + from segments.models import Segment + + mock_call_task = mocker.patch( + "integrations.gitlab.gitlab.call_gitlab_app_webhook_for_feature_state", + ) + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/issues/1", + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened"}', + ) + + segment = Segment.objects.create( + name="test_segment", project=feature.project + ) + feature_segment = FeatureSegment.objects.create( + feature=feature, + segment=segment, + environment=environment, + ) + mock_call_task.delay.reset_mock() + + # When + feature_segment.delete() + + # Then + mock_call_task.delay.assert_called_once() + + +# --------------------------------------------------------------- +# tag_feature_per_gitlab_event — work_items URL → issues variant (line 74) +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_tag_feature_per_gitlab_event__reverse_work_items_variant__finds_feature( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given — feature is stored under an /-/issues/ URL, but the webhook sends /-/work_items/ + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + issues_url = "https://gitlab.example.com/testgroup/testrepo/-/issues/9" + FeatureExternalResource.objects.create( + url=issues_url, + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened"}', + ) + work_items_url = "https://gitlab.example.com/testgroup/testrepo/-/work_items/9" + + # When + tag_feature_per_gitlab_event( + event_type="issue", + action="close", + metadata={"web_url": work_items_url}, + project_path="testgroup/testrepo", + ) + + # Then + feature.refresh_from_db() + from integrations.gitlab.constants import GitLabTag + + tag_labels = list(feature.tags.values_list("label", flat=True)) + assert GitLabTag.ISSUE_CLOSED.value in tag_labels + + +# --------------------------------------------------------------- +# generate_data — non-removed event populates env fields (lines 221, 236) +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_generate_data__non_removed_event_with_segment_feature_state__includes_env_and_segment_name( + feature: Feature, + gitlab_configuration: GitLabConfiguration, + environment: Any, + segment: Any, + mocker: MockerFixture, +) -> None: + # Given + from features.models import FeatureSegment, FeatureState + + feature_segment = FeatureSegment.objects.create( + feature=feature, + segment=segment, + environment=environment, + ) + feature_state = FeatureState.objects.create( + feature=feature, + environment=environment, + feature_segment=feature_segment, + enabled=True, + ) + + # When + result = generate_data( + gitlab_configuration=gitlab_configuration, + feature=feature, + type=GitLabEventType.FLAG_UPDATED.value, + feature_states=[feature_state], + ) + + # Then + assert len(result.feature_states) == 1 + fs_data = result.feature_states[0] + # Lines 224-231: environment fields are populated for non-removed event + assert "environment_name" in fs_data + assert "enabled" in fs_data + assert "last_updated" in fs_data + assert "environment_api_key" in fs_data + # Line 236: segment_name is extracted from feature_segment + assert fs_data.get("segment_name") == segment.name + + +# --------------------------------------------------------------- +# _post_to_resource — successful IID and project_path extraction (lines 61, 68) +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_post_to_resource__issue_url__extracts_iid_and_project_path( + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mock_post_comment = mocker.patch( + "integrations.gitlab.tasks.post_comment_to_gitlab" + ) + # testgroup/testrepo matches the gitlab_configuration fixture's project_name + resource_url = "https://gitlab.example.com/testgroup/testrepo/-/issues/42" + + # When + _post_to_resource( + resource_url=resource_url, + instance_url="https://gitlab.example.com", + access_token="test-token", + body="Comment body", + ) + + # Then — lines 61 and 68: resource_iid and project_path were successfully extracted + mock_post_comment.assert_called_once_with( + instance_url="https://gitlab.example.com", + access_token="test-token", + gitlab_project_id=gitlab_configuration.gitlab_project_id, + resource_type="issues", + resource_iid=42, + body="Comment body", + ) diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_views.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_views.py new file mode 100644 index 000000000000..6dbaab03376c --- /dev/null +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_views.py @@ -0,0 +1,1138 @@ +import json +from unittest.mock import MagicMock + +import pytest +import responses +from django.urls import reverse +from pytest_mock import MockerFixture +from rest_framework import status +from rest_framework.test import APIClient + +from features.feature_external_resources.models import FeatureExternalResource +from features.models import Feature +from integrations.gitlab.models import GitLabConfiguration +from projects.models import Project + +GITLAB_INSTANCE_URL = "https://gitlab.example.com" + +WEBHOOK_MR_PAYLOAD = json.dumps( + { + "object_kind": "merge_request", + "event_type": "merge_request", + "project": { + "path_with_namespace": "testgroup/testrepo", + }, + "object_attributes": { + "action": "open", + "url": "https://gitlab.example.com/testgroup/testrepo/-/merge_requests/1", + "state": "opened", + "work_in_progress": False, + }, + } +) + + +# --------------------------------------------------------------- +# Configuration tests +# --------------------------------------------------------------- + + +def test_get_gitlab_configuration__no_configuration_exists__returns_200( + admin_client_new: APIClient, + project: Project, +) -> None: + # Given + url = reverse( + "api-v1:projects:integrations-gitlab-list", + kwargs={"project_pk": project.id}, + ) + # When + response = admin_client_new.get(url) + # Then + assert response.status_code == status.HTTP_200_OK + + +def test_create_gitlab_configuration__valid_data__returns_201( + admin_client_new: APIClient, + project: Project, +) -> None: + # Given + data = { + "gitlab_instance_url": GITLAB_INSTANCE_URL, + "access_token": "new-token", + "webhook_secret": "new-secret", + } + url = reverse( + "api-v1:projects:integrations-gitlab-list", + kwargs={"project_pk": project.id}, + ) + # When + response = admin_client_new.post(url, data) + # Then + assert response.status_code == status.HTTP_201_CREATED + assert GitLabConfiguration.objects.filter( + project=project + ).exists() + + +def test_create_gitlab_configuration__duplicate__returns_400( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + data = { + "gitlab_instance_url": "https://gitlab.other.com", + "access_token": "another-token", + "webhook_secret": "another-secret", + } + url = reverse( + "api-v1:projects:integrations-gitlab-list", + kwargs={"project_pk": project.id}, + ) + # When + response = admin_client_new.post(url, data) + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert ( + "Duplication error. The GitLab integration already created" + in response.json()["detail"] + ) + + +def test_delete_gitlab_configuration__valid_configuration__returns_204( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + url = reverse( + "api-v1:projects:integrations-gitlab-detail", + args=[project.id, gitlab_configuration.id], + ) + # When + response = admin_client_new.delete(url) + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not GitLabConfiguration.objects.filter( + id=gitlab_configuration.id + ).exists() + + +@responses.activate +def test_create_gitlab_configuration__tagging_enabled__creates_label( + admin_client_new: APIClient, + project: Project, + mocker: MockerFixture, +) -> None: + # Given + mock_create_label = mocker.patch( + "integrations.gitlab.views.create_flagsmith_flag_label" + ) + data = { + "gitlab_instance_url": GITLAB_INSTANCE_URL, + "access_token": "new-token", + "webhook_secret": "new-secret", + "tagging_enabled": True, + "gitlab_project_id": 42, + } + url = reverse( + "api-v1:projects:integrations-gitlab-list", + kwargs={"project_pk": project.id}, + ) + + # When + response = admin_client_new.post(url, data, format="json") + + # Then + assert response.status_code == status.HTTP_201_CREATED + mock_create_label.assert_called_once_with( + instance_url=GITLAB_INSTANCE_URL, + access_token="new-token", + gitlab_project_id=42, + ) + + +@responses.activate +def test_update_gitlab_configuration__tagging_enabled__creates_label( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mock_create_label = mocker.patch( + "integrations.gitlab.views.create_flagsmith_flag_label" + ) + data = { + "gitlab_instance_url": gitlab_configuration.gitlab_instance_url, + "access_token": gitlab_configuration.access_token, + "webhook_secret": gitlab_configuration.webhook_secret, + "tagging_enabled": True, + "gitlab_project_id": gitlab_configuration.gitlab_project_id, + } + url = reverse( + "api-v1:projects:integrations-gitlab-detail", + args=[project.id, gitlab_configuration.id], + ) + + # When + response = admin_client_new.put(url, data, format="json") + + # Then + assert response.status_code == status.HTTP_200_OK + mock_create_label.assert_called_once_with( + instance_url=gitlab_configuration.gitlab_instance_url, + access_token=gitlab_configuration.access_token, + gitlab_project_id=gitlab_configuration.gitlab_project_id, + ) + + +def test_delete_gitlab_configuration__has_external_resources__removes_them( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, + feature: Feature, + post_request_mock: MagicMock, + mock_github_client_generate_token: MagicMock, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/issues/1", + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened"}', + ) + + url = reverse( + "api-v1:projects:integrations-gitlab-detail", + args=[ + project.id, + gitlab_configuration.id, + ], + ) + assert FeatureExternalResource.objects.filter(feature=feature).exists() + + # When + response = admin_client_new.delete(url) + + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not FeatureExternalResource.objects.filter( + feature=feature, type="GITLAB_ISSUE" + ).exists() + + +# --------------------------------------------------------------- +# Webhook tests +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_gitlab_webhook__valid_merge_request_event__returns_200( + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + client = APIClient() + url = reverse("api-v1:gitlab-webhook", args=[project.id]) + + # When + response = client.post( + url, + data=WEBHOOK_MR_PAYLOAD, + content_type="application/json", + **{ # type: ignore[arg-type] + "HTTP_X_GITLAB_TOKEN": gitlab_configuration.webhook_secret, + "HTTP_X_GITLAB_EVENT": "Merge Request Hook", + }, + ) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["detail"] == "Event processed" + + +@pytest.mark.django_db +def test_gitlab_webhook__invalid_token__returns_400( + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + client = APIClient() + url = reverse("api-v1:gitlab-webhook", args=[project.id]) + + # When + response = client.post( + url, + data=WEBHOOK_MR_PAYLOAD, + content_type="application/json", + **{ # type: ignore[arg-type] + "HTTP_X_GITLAB_TOKEN": "wrong-secret", + "HTTP_X_GITLAB_EVENT": "Merge Request Hook", + }, + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["error"] == "Invalid token" + + +@pytest.mark.django_db +def test_gitlab_webhook__missing_token_header__returns_400( + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + client = APIClient() + url = reverse("api-v1:gitlab-webhook", args=[project.id]) + + # When + response = client.post( + url, + data=WEBHOOK_MR_PAYLOAD, + content_type="application/json", + **{ # type: ignore[arg-type] + "HTTP_X_GITLAB_EVENT": "Merge Request Hook", + }, + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["error"] == "Invalid token" + + +# --------------------------------------------------------------- +# Resource browsing tests +# --------------------------------------------------------------- + + +@responses.activate +def test_fetch_projects__valid_request__returns_projects( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + responses.add( + method="GET", + url=f"{GITLAB_INSTANCE_URL}/api/v4/projects", + status=200, + json=[ + { + "id": 1, + "name": "My Project", + "path_with_namespace": "testgroup/myproject", + } + ], + headers={"x-page": "1", "x-total-pages": "1", "x-total": "1"}, + ) + + url = reverse( + "api-v1:projects:get-gitlab-projects", + args=[project.id], + ) + + # When + response = admin_client_new.get(url) + response_json = response.json() + + # Then + assert response.status_code == status.HTTP_200_OK + assert "results" in response_json + assert len(response_json["results"]) == 1 + assert response_json["results"][0]["name"] == "My Project" + + +@responses.activate +def test_fetch_issues__valid_request__returns_results( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + responses.add( + method="GET", + url=f"{GITLAB_INSTANCE_URL}/api/v4/projects/1/issues", + status=200, + json=[ + { + "web_url": "https://gitlab.example.com/testgroup/testrepo/-/issues/1", + "id": 101, + "title": "Test Issue", + "iid": 1, + "state": "opened", + } + ], + headers={"x-page": "1", "x-total-pages": "1", "x-total": "1"}, + ) + + url = reverse( + "api-v1:projects:get-gitlab-issues", + args=[project.id], + ) + data = {"gitlab_project_id": 1} + + # When + response = admin_client_new.get(url, data=data) + response_json = response.json() + + # Then + assert response.status_code == status.HTTP_200_OK + assert "results" in response_json + assert len(response_json["results"]) == 1 + assert response_json["results"][0]["title"] == "Test Issue" + + +@responses.activate +def test_fetch_merge_requests__valid_request__returns_results( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + responses.add( + method="GET", + url=f"{GITLAB_INSTANCE_URL}/api/v4/projects/1/merge_requests", + status=200, + json=[ + { + "web_url": "https://gitlab.example.com/testgroup/testrepo/-/merge_requests/1", + "id": 201, + "title": "Test MR", + "iid": 1, + "state": "opened", + "draft": False, + "merged_at": None, + } + ], + headers={"x-page": "1", "x-total-pages": "1", "x-total": "1"}, + ) + + url = reverse( + "api-v1:projects:get-gitlab-merge-requests", + args=[project.id], + ) + data = {"gitlab_project_id": 1} + + # When + response = admin_client_new.get(url, data=data) + response_json = response.json() + + # Then + assert response.status_code == status.HTTP_200_OK + assert "results" in response_json + assert len(response_json["results"]) == 1 + assert response_json["results"][0]["title"] == "Test MR" + assert response_json["results"][0]["merged"] is False + + +# --------------------------------------------------------------- +# fetch_issues / fetch_merge_requests - missing config +# --------------------------------------------------------------- + + +def test_fetch_issues__missing_config__returns_400( + admin_client_new: APIClient, + project: Project, +) -> None: + # Given - no gitlab_configuration + url = reverse( + "api-v1:projects:get-gitlab-issues", + args=[project.id], + ) + + # When + response = admin_client_new.get(url, data={"gitlab_project_id": 1}) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "doesn't have a valid GitLab Configuration" in response.json()["detail"] + + +def test_fetch_merge_requests__missing_config__returns_400( + admin_client_new: APIClient, + project: Project, +) -> None: + # Given - no gitlab_configuration + url = reverse( + "api-v1:projects:get-gitlab-merge-requests", + args=[project.id], + ) + + # When + response = admin_client_new.get(url, data={"gitlab_project_id": 1}) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "doesn't have a valid GitLab Configuration" in response.json()["detail"] + + +# --------------------------------------------------------------- +# fetch_project_members tests +# --------------------------------------------------------------- + + +@responses.activate +def test_fetch_project_members__valid_request__returns_members( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + responses.add( + method="GET", + url=f"{GITLAB_INSTANCE_URL}/api/v4/projects/1/members", + status=200, + json=[ + { + "username": "jdoe", + "avatar_url": "https://gitlab.example.com/avatar/jdoe", + "name": "John Doe", + } + ], + headers={"x-page": "1", "x-total-pages": "1"}, + ) + + url = reverse( + "api-v1:projects:get-gitlab-project-members", + args=[project.id], + ) + + # When + response = admin_client_new.get(url, data={"gitlab_project_id": 1}) + response_json = response.json() + + # Then + assert response.status_code == status.HTTP_200_OK + assert "results" in response_json + assert len(response_json["results"]) == 1 + assert response_json["results"][0]["username"] == "jdoe" + + +# --------------------------------------------------------------- +# create_cleanup_issue tests +# --------------------------------------------------------------- + + +@pytest.mark.django_db +@responses.activate +def test_create_cleanup_issue__valid_feature_with_code_refs__returns_204( + admin_client_new: APIClient, + project: Project, + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + from datetime import datetime + + from projects.code_references.types import ( + CodeReference, + FeatureFlagCodeReferencesRepositorySummary, + VCSProvider, + ) + + summary = FeatureFlagCodeReferencesRepositorySummary( + repository_url="https://gitlab.example.com/testgroup/testrepo", + vcs_provider=VCSProvider.GITLAB, + revision="abc123", + last_successful_repository_scanned_at=datetime.now(), + last_feature_found_at=datetime.now(), + code_references=[ + CodeReference( + feature_name=feature.name, + file_path="src/main.py", + line_number=10, + permalink="https://gitlab.example.com/testgroup/testrepo/-/blob/abc123/src/main.py#L10", + ) + ], + ) + mocker.patch( + "integrations.gitlab.views.get_code_references_for_feature_flag", + return_value=[summary], + ) + responses.add( + responses.POST, + f"{GITLAB_INSTANCE_URL}/api/v4/projects/1/issues", + json={ + "iid": 42, + "title": f"Remove stale feature flag: {feature.name}", + "state": "opened", + "web_url": f"{GITLAB_INSTANCE_URL}/testgroup/testrepo/-/issues/42", + }, + status=201, + ) + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + + url = reverse( + "api-v1:projects:create-gitlab-cleanup-issue", + args=[project.id], + ) + + # When + response = admin_client_new.post(url, data={"feature_id": feature.id}) + + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + + +@pytest.mark.django_db +def test_create_cleanup_issue__missing_feature__returns_404( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + url = reverse( + "api-v1:projects:create-gitlab-cleanup-issue", + args=[project.id], + ) + + # When + response = admin_client_new.post(url, data={"feature_id": 999999}) + + # Then + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "Feature not found" in response.json()["detail"] + + +@pytest.mark.django_db +def test_create_cleanup_issue__no_code_references__returns_400( + admin_client_new: APIClient, + project: Project, + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "integrations.gitlab.views.get_code_references_for_feature_flag", + return_value=[], + ) + + url = reverse( + "api-v1:projects:create-gitlab-cleanup-issue", + args=[project.id], + ) + + # When + response = admin_client_new.post(url, data={"feature_id": feature.id}) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "No code references found" in response.json()["detail"] + + +# --------------------------------------------------------------- +# gitlab_webhook additional tests +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_gitlab_webhook__missing_config__returns_404( + project: Project, +) -> None: + # Given - no gitlab config exists for this project + client = APIClient() + url = reverse("api-v1:gitlab-webhook", args=[project.id]) + + # When + response = client.post( + url, + data=WEBHOOK_MR_PAYLOAD, + content_type="application/json", + **{ # type: ignore[arg-type] + "HTTP_X_GITLAB_TOKEN": "some-secret", + "HTTP_X_GITLAB_EVENT": "Merge Request Hook", + }, + ) + + # Then + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "No GitLab configuration found" in response.json()["error"] + + +@pytest.mark.django_db +def test_gitlab_webhook__unhandled_event_type__returns_200_bypassed( + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + client = APIClient() + url = reverse("api-v1:gitlab-webhook", args=[project.id]) + payload = json.dumps({"object_kind": "push"}) + + # When + response = client.post( + url, + data=payload, + content_type="application/json", + **{ # type: ignore[arg-type] + "HTTP_X_GITLAB_TOKEN": gitlab_configuration.webhook_secret, + "HTTP_X_GITLAB_EVENT": "Push Hook", + }, + ) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["detail"] == "Event bypassed" + + +@pytest.mark.django_db +def test_gitlab_webhook__issue_event__returns_200( + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + client = APIClient() + url = reverse("api-v1:gitlab-webhook", args=[project.id]) + payload = json.dumps( + { + "object_kind": "issue", + "event_type": "issue", + "project": {"path_with_namespace": "testgroup/testrepo"}, + "object_attributes": { + "action": "open", + "url": "https://gitlab.example.com/testgroup/testrepo/-/issues/1", + }, + } + ) + + # When + response = client.post( + url, + data=payload, + content_type="application/json", + **{ # type: ignore[arg-type] + "HTTP_X_GITLAB_TOKEN": gitlab_configuration.webhook_secret, + "HTTP_X_GITLAB_EVENT": "Issue Hook", + }, + ) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["detail"] == "Event processed" + + +# --------------------------------------------------------------- +# Error handler decorator tests +# --------------------------------------------------------------- + + +def test_gitlab_api_call_error_handler__value_error__returns_400( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "integrations.gitlab.views.fetch_search_gitlab_resource", + side_effect=ValueError("bad value"), + ) + url = reverse( + "api-v1:projects:get-gitlab-issues", + args=[project.id], + ) + + # When + response = admin_client_new.get(url, data={"gitlab_project_id": 1}) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Failed to retrieve GitLab issues" in response.json()["detail"] + + +def test_gitlab_api_call_error_handler__request_exception__returns_502( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + import requests + + mocker.patch( + "integrations.gitlab.views.fetch_search_gitlab_resource", + side_effect=requests.RequestException("connection failed"), + ) + url = reverse( + "api-v1:projects:get-gitlab-issues", + args=[project.id], + ) + + # When + response = admin_client_new.get(url, data={"gitlab_project_id": 1}) + + # Then + assert response.status_code == status.HTTP_502_BAD_GATEWAY + assert "Failed to retrieve GitLab issues" in response.json()["detail"] + + +# --------------------------------------------------------------- +# Permission tests +# --------------------------------------------------------------- + + +def test_gitlab_configuration__non_existent_project__returns_403( + admin_client_new: APIClient, +) -> None: + # Given + url = reverse( + "api-v1:projects:integrations-gitlab-list", + kwargs={"project_pk": 999999}, + ) + + # When + response = admin_client_new.get(url) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# --------------------------------------------------------------- +# GitLabConfigurationViewSet.get_queryset — real (non-swagger) path (line 122) +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_get_gitlab_configuration__existing_config__returns_list( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + url = reverse( + "api-v1:projects:integrations-gitlab-list", + kwargs={"project_pk": project.id}, + ) + + # When + response = admin_client_new.get(url) + response_json = response.json() + + # Then + assert response.status_code == status.HTTP_200_OK + assert len(response_json["results"]) == 1 + assert response_json["results"][0]["id"] == gitlab_configuration.id + + +# --------------------------------------------------------------- +# fetch_merge_requests / fetch_issues — invalid query params (lines 157, 178) +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_fetch_merge_requests__invalid_query_params__returns_400( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given — non-integer page triggers serializer validation failure + url = reverse( + "api-v1:projects:get-gitlab-merge-requests", + args=[project.id], + ) + + # When + response = admin_client_new.get(url, data={"gitlab_project_id": "1", "page": "abc"}) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "error" in response.json() + + +@pytest.mark.django_db +def test_fetch_issues__invalid_query_params__returns_400( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given — non-integer page triggers serializer validation failure + url = reverse( + "api-v1:projects:get-gitlab-issues", + args=[project.id], + ) + + # When + response = admin_client_new.get(url, data={"gitlab_project_id": "1", "page": "abc"}) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "error" in response.json() + + +# --------------------------------------------------------------- +# fetch_projects — invalid query params (line 198) +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_fetch_projects__invalid_query_params__returns_400( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given — non-integer page triggers serializer validation failure + url = reverse( + "api-v1:projects:get-gitlab-projects", + args=[project.id], + ) + + # When + response = admin_client_new.get(url, data={"page": "abc"}) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "error" in response.json() + + +# --------------------------------------------------------------- +# fetch_project_members — invalid query params (line 218) +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_fetch_project_members__invalid_query_params__returns_400( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given — non-integer page triggers serializer validation failure + url = reverse( + "api-v1:projects:get-gitlab-project-members", + args=[project.id], + ) + + # When + response = admin_client_new.get(url, data={"gitlab_project_id": "1", "page": "abc"}) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "error" in response.json() + + +# --------------------------------------------------------------- +# create_cleanup_issue — invalid serializer data (line 237) +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_create_cleanup_issue__invalid_serializer_data__returns_400( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given — feature_id is missing entirely so the serializer is invalid + url = reverse( + "api-v1:projects:create-gitlab-cleanup-issue", + args=[project.id], + ) + + # When + response = admin_client_new.post(url, data={}) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "error" in response.json() + + +# --------------------------------------------------------------- +# create_cleanup_issue — project_name mismatch (line 283) and +# gitlab_project_id is None (line 286) +# --------------------------------------------------------------- + + +@pytest.mark.django_db +def test_create_cleanup_issue__project_name_does_not_match_repo__returns_204_with_no_issue( + admin_client_new: APIClient, + project: Project, + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given — the summary repository URL does not match the gitlab config project_name + from datetime import datetime + + from projects.code_references.types import ( + CodeReference, + FeatureFlagCodeReferencesRepositorySummary, + VCSProvider, + ) + + summary = FeatureFlagCodeReferencesRepositorySummary( + repository_url="https://gitlab.example.com/other/different-repo", + vcs_provider=VCSProvider.GITLAB, + revision="abc123", + last_successful_repository_scanned_at=datetime.now(), + last_feature_found_at=datetime.now(), + code_references=[ + CodeReference( + feature_name=feature.name, + file_path="src/main.py", + line_number=5, + permalink="https://gitlab.example.com/other/different-repo/-/blob/abc123/src/main.py#L5", + ) + ], + ) + mocker.patch( + "integrations.gitlab.views.get_code_references_for_feature_flag", + return_value=[summary], + ) + + url = reverse( + "api-v1:projects:create-gitlab-cleanup-issue", + args=[project.id], + ) + + # When + response = admin_client_new.post(url, data={"feature_id": feature.id}) + + # Then — skipped (project_name mismatch), still returns 204 + assert response.status_code == status.HTTP_204_NO_CONTENT + + +@pytest.mark.django_db +def test_create_cleanup_issue__gitlab_project_id_is_none__skips_issue_creation( + admin_client_new: APIClient, + project: Project, + feature: Feature, + mocker: MockerFixture, +) -> None: + # Given — GitLabConfiguration has no gitlab_project_id set + from datetime import datetime + + from projects.code_references.types import ( + CodeReference, + FeatureFlagCodeReferencesRepositorySummary, + VCSProvider, + ) + + gitlab_config_no_project = GitLabConfiguration.objects.create( + project=project, + gitlab_instance_url=GITLAB_INSTANCE_URL, + access_token="no-project-token", + webhook_secret="some-secret", + project_name="testgroup/testrepo", + gitlab_project_id=None, + ) + + summary = FeatureFlagCodeReferencesRepositorySummary( + repository_url="https://gitlab.example.com/testgroup/testrepo", + vcs_provider=VCSProvider.GITLAB, + revision="abc123", + last_successful_repository_scanned_at=datetime.now(), + last_feature_found_at=datetime.now(), + code_references=[ + CodeReference( + feature_name=feature.name, + file_path="src/main.py", + line_number=5, + permalink="https://gitlab.example.com/testgroup/testrepo/-/blob/abc123/src/main.py#L5", + ) + ], + ) + mocker.patch( + "integrations.gitlab.views.get_code_references_for_feature_flag", + return_value=[summary], + ) + + url = reverse( + "api-v1:projects:create-gitlab-cleanup-issue", + args=[project.id], + ) + + # When + response = admin_client_new.post(url, data={"feature_id": feature.id}) + + # Then — skipped due to missing gitlab_project_id, returns 204 + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Cleanup + gitlab_config_no_project.delete() + + +# --------------------------------------------------------------- +# create_cleanup_issue — IntegrityError on duplicate resource (lines 310-311) +# --------------------------------------------------------------- + + +@pytest.mark.django_db +@responses.activate +def test_create_cleanup_issue__duplicate_external_resource__swallows_integrity_error( + admin_client_new: APIClient, + project: Project, + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given — pre-existing FeatureExternalResource with the same URL that would cause IntegrityError + from datetime import datetime + + from projects.code_references.types import ( + CodeReference, + FeatureFlagCodeReferencesRepositorySummary, + VCSProvider, + ) + + issue_url = f"{GITLAB_INSTANCE_URL}/testgroup/testrepo/-/issues/99" + + mocker.patch( + "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" + ) + FeatureExternalResource.objects.create( + url=issue_url, + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened", "title": "Pre-existing"}', + ) + + summary = FeatureFlagCodeReferencesRepositorySummary( + repository_url="https://gitlab.example.com/testgroup/testrepo", + vcs_provider=VCSProvider.GITLAB, + revision="abc123", + last_successful_repository_scanned_at=datetime.now(), + last_feature_found_at=datetime.now(), + code_references=[ + CodeReference( + feature_name=feature.name, + file_path="src/main.py", + line_number=10, + permalink="https://gitlab.example.com/testgroup/testrepo/-/blob/abc123/src/main.py#L10", + ) + ], + ) + mocker.patch( + "integrations.gitlab.views.get_code_references_for_feature_flag", + return_value=[summary], + ) + responses.add( + responses.POST, + f"{GITLAB_INSTANCE_URL}/api/v4/projects/1/issues", + json={ + "iid": 99, + "title": f"Remove stale feature flag: {feature.name}", + "state": "opened", + "web_url": issue_url, + }, + status=201, + ) + + url = reverse( + "api-v1:projects:create-gitlab-cleanup-issue", + args=[project.id], + ) + + # When — the create call will hit IntegrityError for the duplicate URL, which must be swallowed + response = admin_client_new.post(url, data={"feature_id": feature.id}) + + # Then — integrity error is caught (line 310-311) and we still return 204 + assert response.status_code == status.HTTP_204_NO_CONTENT diff --git a/frontend/common/constants.ts b/frontend/common/constants.ts index df68667a4f6b..acca3d6df8ee 100644 --- a/frontend/common/constants.ts +++ b/frontend/common/constants.ts @@ -492,6 +492,10 @@ const Constants = { githubIssue: 'GitHub Issue', githubPR: 'Github PR', }, + gitlabType: { + gitlabIssue: 'GitLab Issue', + gitlabMR: 'GitLab MR', + }, integrationCategoryDescriptions: { 'All': 'Send data on what flags served to each identity.', 'Analytics': 'Send data on what flags served to each identity.', @@ -685,6 +689,18 @@ const Constants = { resourceType: 'pulls', type: 'GITHUB', }, + GITLAB_ISSUE: { + id: 3, + label: 'Issue', + resourceType: 'issues', + type: 'GITLAB', + }, + GITLAB_MR: { + id: 4, + label: 'Merge Request', + resourceType: 'merge_requests', + type: 'GITLAB', + }, }, roles: { 'ADMIN': 'Organisation Administrator', diff --git a/frontend/common/services/useGitlab.ts b/frontend/common/services/useGitlab.ts new file mode 100644 index 000000000000..219618674001 --- /dev/null +++ b/frontend/common/services/useGitlab.ts @@ -0,0 +1,73 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' +import Utils from 'common/utils/utils' + +export const gitlabService = service + .enhanceEndpoints({ addTagTypes: ['Gitlab'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + getGitlabProjects: builder.query< + Res['gitlabProjects'], + Req['getGitlabProjects'] + >({ + providesTags: [{ id: 'LIST', type: 'Gitlab' }], + query: (query: Req['getGitlabProjects']) => ({ + url: `projects/${query.project_id}/gitlab/projects/`, + }), + }), + getGitlabResources: builder.query< + Res['gitlabResources'], + Req['getGitlabResources'] + >({ + providesTags: [{ id: 'LIST', type: 'Gitlab' }], + query: (query: Req['getGitlabResources']) => ({ + url: + `projects/${query.project_id}/gitlab/${query.gitlab_resource}/` + + `?${Utils.toParam({ + gitlab_project_id: query.gitlab_project_id, + page: query.page, + page_size: query.page_size, + project_name: query.project_name, + })}`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function getGitlabResources( + store: any, + data: Req['getGitlabResources'], + options?: Parameters< + typeof gitlabService.endpoints.getGitlabResources.initiate + >[1], +) { + return store.dispatch( + gitlabService.endpoints.getGitlabResources.initiate(data, options), + ) +} +export async function getGitlabProjects( + store: any, + data: Req['getGitlabProjects'], + options?: Parameters< + typeof gitlabService.endpoints.getGitlabProjects.initiate + >[1], +) { + return store.dispatch( + gitlabService.endpoints.getGitlabProjects.initiate(data, options), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useGetGitlabProjectsQuery, + useGetGitlabResourcesQuery, + // END OF EXPORTS +} = gitlabService + +/* Usage examples: +const { data, isLoading } = useGetGitlabResourcesQuery({ project_id: 2, gitlab_resource: 'issues', gitlab_project_id: 1, project_name: 'my-project' }, {}) //get hook +const { data, isLoading } = useGetGitlabProjectsQuery({ project_id: 2 }, {}) //get hook +gitlabService.endpoints.getGitlabProjects.select({ project_id: 2 })(store.getState()) //access data from any function +*/ diff --git a/frontend/common/services/useGitlabIntegration.ts b/frontend/common/services/useGitlabIntegration.ts new file mode 100644 index 000000000000..5f7a1c30be49 --- /dev/null +++ b/frontend/common/services/useGitlabIntegration.ts @@ -0,0 +1,124 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const gitlabIntegrationService = service + .enhanceEndpoints({ addTagTypes: ['GitlabIntegration'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createGitlabIntegration: builder.mutation< + Res['gitlabIntegrations'], + Req['createGitlabIntegration'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'GitlabIntegration' }], + query: (query: Req['createGitlabIntegration']) => ({ + body: query.body, + method: 'POST', + url: `projects/${query.project_id}/integrations/gitlab/`, + }), + }), + deleteGitlabIntegration: builder.mutation< + Res['gitlabIntegrations'], + Req['deleteGitlabIntegration'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'GitlabIntegration' }], + query: (query: Req['deleteGitlabIntegration']) => ({ + method: 'DELETE', + url: `projects/${query.project_id}/integrations/gitlab/${query.gitlab_integration_id}/`, + }), + }), + getGitlabIntegration: builder.query< + Res['gitlabIntegrations'], + Req['getGitlabIntegration'] + >({ + providesTags: [{ id: 'LIST', type: 'GitlabIntegration' }], + query: (query: Req['getGitlabIntegration']) => ({ + url: `projects/${query.project_id}/integrations/gitlab/`, + }), + }), + updateGitlabIntegration: builder.mutation< + Res['gitlabIntegrations'], + Req['updateGitlabIntegration'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'GitlabIntegration' }], + query: (query: Req['updateGitlabIntegration']) => ({ + body: query.body, + method: 'PATCH', + url: `projects/${query.project_id}/integrations/gitlab/${query.gitlab_integration_id}/`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function createGitlabIntegration( + store: any, + data: Req['createGitlabIntegration'], + options?: Parameters< + typeof gitlabIntegrationService.endpoints.createGitlabIntegration.initiate + >[1], +) { + return store.dispatch( + gitlabIntegrationService.endpoints.createGitlabIntegration.initiate( + data, + options, + ), + ) +} +export async function deleteGitlabIntegration( + store: any, + data: Req['deleteGitlabIntegration'], + options?: Parameters< + typeof gitlabIntegrationService.endpoints.deleteGitlabIntegration.initiate + >[1], +) { + return store.dispatch( + gitlabIntegrationService.endpoints.deleteGitlabIntegration.initiate( + data, + options, + ), + ) +} +export async function getGitlabIntegration( + store: any, + data: Req['getGitlabIntegration'], + options?: Parameters< + typeof gitlabIntegrationService.endpoints.getGitlabIntegration.initiate + >[1], +) { + return store.dispatch( + gitlabIntegrationService.endpoints.getGitlabIntegration.initiate( + data, + options, + ), + ) +} +export async function updateGitlabIntegration( + store: any, + data: Req['updateGitlabIntegration'], + options?: Parameters< + typeof gitlabIntegrationService.endpoints.updateGitlabIntegration.initiate + >[1], +) { + return store.dispatch( + gitlabIntegrationService.endpoints.updateGitlabIntegration.initiate( + data, + options, + ), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useCreateGitlabIntegrationMutation, + useDeleteGitlabIntegrationMutation, + useGetGitlabIntegrationQuery, + useUpdateGitlabIntegrationMutation, + // END OF EXPORTS +} = gitlabIntegrationService + +/* Usage examples: +const { data, isLoading } = useGetGitlabIntegrationQuery({ project_id: 2 }, {}) //get hook +const [createGitlabIntegration, { isLoading, data, isSuccess }] = useCreateGitlabIntegrationMutation() //create hook +gitlabIntegrationService.endpoints.getGitlabIntegration.select({ project_id: 2 })(store.getState()) //access data from any function +*/ diff --git a/frontend/common/stores/default-flags.ts b/frontend/common/stores/default-flags.ts index 7bd8d4415353..8db4e8e48100 100644 --- a/frontend/common/stores/default-flags.ts +++ b/frontend/common/stores/default-flags.ts @@ -87,6 +87,32 @@ const defaultFlags = { 'tags': ['logging'], 'title': 'Dynatrace', }, + 'gitlab': { + 'description': + 'View your Flagsmith flags inside GitLab issues and merge requests.', + 'docs': + 'https://docs.flagsmith.com/integrations/project-management/gitlab', + 'fields': [ + { + 'default': 'https://gitlab.com', + 'key': 'gitlab_instance_url', + 'label': 'GitLab Instance URL', + }, + { + 'hidden': true, + 'key': 'access_token', + 'label': 'Access Token', + }, + { + 'key': 'webhook_secret', + 'label': 'Webhook Secret', + }, + ], + 'image': '/static/images/integrations/gitlab.svg', + 'isGitlabIntegration': true, + 'perEnvironment': false, + 'title': 'GitLab', + }, 'grafana': { 'description': 'Receive Flagsmith annotations to your Grafana instance on feature flag and segment changes.', diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index acdae2039e27..2c9a1ed8f4e2 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -619,6 +619,36 @@ export type Req = { github_resource: string }> getGithubRepos: { installation_id: string; organisation_id: number } + // GitLab + getGitlabIntegration: { project_id: number; id?: number } + createGitlabIntegration: { + project_id: number + body: { + gitlab_instance_url: string + access_token: string + webhook_secret: string + } + } + updateGitlabIntegration: { + project_id: number + gitlab_integration_id: number + body: { + gitlab_project_id?: number + project_name?: string + tagging_enabled?: boolean + } + } + deleteGitlabIntegration: { + project_id: number + gitlab_integration_id: number + } + getGitlabResources: PagedRequest<{ + project_id: number + gitlab_project_id: number + project_name: string + gitlab_resource: string + }> + getGitlabProjects: { project_id: number } getServersideEnvironmentKeys: { environmentId: string } deleteServersideEnvironmentKeys: { environmentId: string; id: string } createServersideEnvironmentKeys: { diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index d73a95353bda..eddd924ee09d 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -321,6 +321,7 @@ export type IntegrationData = { image: string fields: IntegrationField[] | undefined isExternalInstallation: boolean + isGitlabIntegration?: boolean perEnvironment: boolean title?: string organisation?: string @@ -1150,6 +1151,26 @@ export type Res = { githubRepository: PagedResponse githubResources: GitHubPagedResponse githubRepos: GithubPaginatedRepos + // GitLab + gitlabIntegration: { + id: number + gitlab_instance_url: string + webhook_secret: string + project: number + } + gitlabIntegrations: PagedResponse + GitlabResource: { + web_url: string + id: number + iid: number + title: string + state: string + merged: boolean + draft: boolean + } + gitlabResources: PagedResponse + GitlabProject: { id: number; name: string; path_with_namespace: string } + gitlabProjects: PagedResponse segmentPriorities: {} featureSegment: FeatureState['feature_segment'] featureVersions: PagedResponse diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 88389946f1a4..65098a70af0d 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -377,10 +377,12 @@ const Utils = Object.assign({}, require('./base/_utils'), { return 'identities' }, getIntegrationData() { - return Utils.getFlagsmithJSONValue( + const data = Utils.getFlagsmithJSONValue( 'integration_data', defaultFlags.integration_data, ) + // Merge default integration entries that may not be in the remote flag yet + return { ...defaultFlags.integration_data, ...data } }, getIsEdge() { const model = ProjectStore.model as null | ProjectType diff --git a/frontend/web/components/ExternalResourcesLinkTab.tsx b/frontend/web/components/ExternalResourcesLinkTab.tsx index f3c34443e4d3..9c0dc1078674 100644 --- a/frontend/web/components/ExternalResourcesLinkTab.tsx +++ b/frontend/web/components/ExternalResourcesLinkTab.tsx @@ -1,44 +1,55 @@ import React, { FC, useState } from 'react' -import ExternalResourcesTable, { - ExternalResourcesTableBase, -} from './ExternalResourcesTable' -import { ExternalResource, GithubResource } from 'common/types/responses' +import ExternalResourcesTable from './ExternalResourcesTable' +import { ExternalResource, GithubResource, Res } from 'common/types/responses' import { useCreateExternalResourceMutation } from 'common/services/useExternalResource' import Constants from 'common/constants' import GitHubResourcesSelect from './GitHubResourcesSelect' +import GitLabResourcesSelect from './GitLabResourcesSelect' import AppActions from 'common/dispatcher/app-actions' +type VcsProvider = 'github' | 'gitlab' + type ExternalResourcesLinkTabType = { githubId: string + hasIntegrationWithGitlab: boolean organisationId: number featureId: string projectId: number environmentId: string } -type AddExternalResourceRowType = ExternalResourcesTableBase & { - selectedResources?: ExternalResource[] - environmentId: string - githubId: string -} - const ExternalResourcesLinkTab: FC = ({ environmentId, featureId, githubId, + hasIntegrationWithGitlab, organisationId, projectId, }) => { const githubTypes = Object.values(Constants.resourceTypes).filter( (v) => v.type === 'GITHUB', ) + const gitlabTypes = Object.values(Constants.resourceTypes).filter( + (v) => v.type === 'GITLAB', + ) + + const hasGithub = !!githubId + const hasGitlab = hasIntegrationWithGitlab + + const defaultProvider: VcsProvider = + hasGitlab && !hasGithub ? 'gitlab' : 'github' + const defaultResourceType = + defaultProvider === 'gitlab' + ? gitlabTypes[0]?.resourceType + : githubTypes[0]?.resourceType + const [vcsProvider, setVcsProvider] = useState(defaultProvider) const [createExternalResource] = useCreateExternalResourceMutation() - const [resourceType, setResourceType] = useState(githubTypes[0].resourceType) + const [resourceType, setResourceType] = useState(defaultResourceType) const [selectedResources, setSelectedResources] = useState() - const addResource = (featureExternalResource: GithubResource) => { + const addGithubResource = (featureExternalResource: GithubResource) => { const type = Object.keys(Constants.resourceTypes).find( (key: string) => Constants.resourceTypes[key as keyof typeof Constants.resourceTypes] @@ -63,16 +74,90 @@ const ExternalResourcesLinkTab: FC = ({ AppActions.refreshFeatures(projectId, environmentId) }) } + + const addGitlabResource = ( + featureExternalResource: Res['GitlabResource'], + ) => { + const type = Object.keys(Constants.resourceTypes).find((key: string) => { + const rt = + Constants.resourceTypes[key as keyof typeof Constants.resourceTypes] + return rt.resourceType === resourceType && rt.type === 'GITLAB' + }) + createExternalResource({ + body: { + feature: parseInt(featureId), + metadata: { + 'draft': featureExternalResource.draft, + 'merged': featureExternalResource.merged, + 'state': featureExternalResource.state, + 'title': featureExternalResource.title, + }, + type: type, + url: featureExternalResource.web_url, + }, + feature_id: featureId, + project_id: projectId, + }).then((res) => { + if ('error' in res) { + toast(`Error adding resource: ${JSON.stringify(res.error)}`, 'danger') + } else { + toast('External Resource Added') + } + AppActions.refreshFeatures(projectId, environmentId) + }) + } + + const handleProviderChange = (provider: VcsProvider) => { + setVcsProvider(provider) + if (provider === 'gitlab') { + setResourceType(gitlabTypes[0]?.resourceType) + } else { + setResourceType(githubTypes[0]?.resourceType) + } + } + return ( <> - v.url!)} - orgId={organisationId} - /> + {hasGithub && hasGitlab && ( +
+ + +
+ )} + {vcsProvider === 'gitlab' && hasGitlab ? ( + v.url ?? '')} + projectId={`${projectId}`} + linkedExternalResources={selectedResources} + /> + ) : ( + v.url ?? '')} + orgId={organisationId as unknown as string} + linkedExternalResources={selectedResources} + /> + )} { + const isGitlab = type?.startsWith('GITLAB') + const match = url.match(/\/(\d+)\/?$/) + const num = match ? match[1] : url.replace(/\D/g, '') + return isGitlab && type === 'GITLAB_MR' ? `!${num}` : `#${num}` +} + export type ExternalResourcesTableBase = { featureId: string projectId: string @@ -61,9 +68,10 @@ const ExternalResourceRow: FC = ({ - {`${ - externalResource?.metadata?.title - } (#${externalResource?.url.replace(/\D/g, '')})`}{' '} + {`${externalResource?.metadata?.title} (${getResourceNumber( + externalResource?.url, + externalResource?.type, + )})`}{' '}
diff --git a/frontend/web/components/GitLabResourcesSelect.tsx b/frontend/web/components/GitLabResourcesSelect.tsx new file mode 100644 index 000000000000..6cd3571c7532 --- /dev/null +++ b/frontend/web/components/GitLabResourcesSelect.tsx @@ -0,0 +1,152 @@ +import React, { FC, useState } from 'react' +import { ExternalResource, Res } from 'common/types/responses' +import Utils from 'common/utils/utils' +import useInfiniteScroll from 'common/useInfiniteScroll' +import { Req } from 'common/types/requests' +import { + useGetGitlabProjectsQuery, + useGetGitlabResourcesQuery, +} from 'common/services/useGitlab' +import Constants from 'common/constants' + +export type GitLabResourcesSelectType = { + onChange: (value: string) => void + linkedExternalResources: ExternalResource[] | undefined + projectId: string + resourceType: string + value: string[] | undefined // an array of resource URLs + setResourceType: (value: string) => void +} + +type GitLabResourcesValueType = { + value: string +} + +const GitLabResourcesSelect: FC = ({ + onChange, + projectId, + resourceType, + setResourceType, + value, +}) => { + const gitlabTypes = Object.values(Constants.resourceTypes).filter( + (v) => v.type === 'GITLAB', + ) + const [selectedProject, setSelectedProject] = useState('') + const gitlabProjectId = selectedProject + ? parseInt(selectedProject.split('::')[0]) + : undefined + const projectName = selectedProject + ? selectedProject.split('::')[1] + : undefined + + const { data: gitlabProjects } = useGetGitlabProjectsQuery({ + project_id: parseInt(projectId), + }) + + const { data, isFetching, isLoading, searchItems } = useInfiniteScroll< + Req['getGitlabResources'], + Res['gitlabResources'] + >( + useGetGitlabResourcesQuery, + { + gitlab_project_id: gitlabProjectId || 0, + gitlab_resource: resourceType, + page_size: 100, + project_id: parseInt(projectId), + project_name: projectName || '', + }, + 100, + { skip: !resourceType || !projectId || !gitlabProjectId || !projectName }, + ) + + const [searchText, setSearchText] = React.useState('') + + return ( + <> + +
+
+ v.resourceType === resourceType)} + onChange={(v: { resourceType: string }) => + setResourceType(v.resourceType) + } + options={gitlabTypes.map((e) => { + return { + label: e.label, + resourceType: e.resourceType, + value: e.id, + } + })} + /> +
+
+ {!!gitlabProjectId && !!projectName && ( +
+ { + const found = gitlabProjects?.results?.find( + (p) => p.id === v.value, + ) + setSelectedGitlabProject(found || null) + }} + options={gitlabProjects?.results?.map((p) => ({ + label: p.path_with_namespace, + value: p.id, + }))} + /> + {selectedGitlabProject && ( + + )} +
+ )} + + + {/* Tagging toggle */} + {gitlabIntegration.project_name && ( +
+ + { + updateGitlabIntegration({ + body: { + tagging_enabled: !gitlabIntegration.tagging_enabled, + }, + gitlab_integration_id: gitlabIntegration.id, + project_id: parseInt(projectId), + }) + }} + /> + + Enable automatic tagging of features based on issue/MR state + + +
+ )} + + {/* Webhook config */} +
+ +

+ Add this webhook URL to your GitLab project or group settings. + Enable triggers for: Issues events, Merge request events. +

+ + + {webhookUrl} + + + + + + {showSecret + ? gitlabIntegration.webhook_secret + : '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'} + + + + +
+ +
+ +
+ + ) + } + + // No integration — show setup form + return ( +
+ + setInstanceUrl(Utils.safeParseEventValue(e)) + } + type='text' + title='GitLab Instance URL' + tooltip='The base URL of your GitLab instance, e.g. https://gitlab.com' + /> + + + setAccessToken(Utils.safeParseEventValue(e)) + } + type='password' + title='Access Token' + tooltip='A group or project access token with api scope (Developer role minimum)' + /> + + + setWebhookSecret(Utils.safeParseEventValue(e)) + } + type='text' + title='Webhook Secret (optional)' + tooltip='Custom secret for webhook validation. Leave as-is to use the auto-generated value.' + /> + +
+ +
+
+ ) +} + +export default GitLabSetupPage From 7ba86830dadfec0677b3e1db6363b9527870197e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:54:54 +0000 Subject: [PATCH 2/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../feature_external_resources/models.py | 4 +- .../feature_external_resources/views.py | 37 +++-- api/integrations/gitlab/gitlab.py | 9 +- api/integrations/gitlab/helpers.py | 3 +- api/integrations/gitlab/views.py | 32 +++-- api/projects/urls.py | 8 +- ...t_unit_feature_external_resources_views.py | 8 +- .../gitlab/test_unit_gitlab_client.py | 8 +- .../gitlab/test_unit_gitlab_gitlab.py | 126 ++++++++++-------- .../gitlab/test_unit_gitlab_views.py | 8 +- 10 files changed, 140 insertions(+), 103 deletions(-) diff --git a/api/features/feature_external_resources/models.py b/api/features/feature_external_resources/models.py index b9fe24eb9a8a..a267bbb00b38 100644 --- a/api/features/feature_external_resources/models.py +++ b/api/features/feature_external_resources/models.py @@ -170,9 +170,7 @@ def _handle_gitlab_after_save(self, state: str) -> None: self.feature.save() feature_states: list[FeatureState] = [] - environments = Environment.objects.filter( - project_id=self.feature.project_id - ) + environments = Environment.objects.filter(project_id=self.feature.project_id) for environment in environments: q = Q( feature_id=self.feature_id, diff --git a/api/features/feature_external_resources/views.py b/api/features/feature_external_resources/views.py index 06207420b36d..fbc0a5fb7827 100644 --- a/api/features/feature_external_resources/views.py +++ b/api/features/feature_external_resources/views.py @@ -69,13 +69,14 @@ def list(self, request, *args, **kwargs) -> Response: # type: ignore[no-untyped ) elif resource_type.startswith("GITLAB_"): try: + import re as _re + from integrations.gitlab.client import ( get_gitlab_issue_mr_title_and_state as get_gitlab_metadata, ) from integrations.gitlab.models import ( GitLabConfiguration, ) - import re as _re feature_obj = get_object_or_404( Feature.objects.filter(id=self.kwargs["feature_pk"]), @@ -86,10 +87,16 @@ def list(self, request, *args, **kwargs) -> Response: # type: ignore[no-untyped if gitlab_config and gitlab_config.gitlab_project_id: # Parse resource IID from URL if resource_type == "GITLAB_MR": - match = _re.search(r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/merge_requests/(\d+)$", resource_url) + match = _re.search( + r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/merge_requests/(\d+)$", + resource_url, + ) api_type = "merge_requests" else: - match = _re.search(r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/(?:issues|work_items)/(\d+)$", resource_url) + match = _re.search( + r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/(?:issues|work_items)/(\d+)$", + resource_url, + ) api_type = "issues" if match: @@ -106,7 +113,9 @@ def list(self, request, *args, **kwargs) -> Response: # type: ignore[no-untyped return Response(data={"results": data}) - def _create_gitlab_resource(self, request: Any, feature: Any, resource_type: str, *args: Any, **kwargs: Any) -> Response: + def _create_gitlab_resource( + self, request: Any, feature: Any, resource_type: str, *args: Any, **kwargs: Any + ) -> Response: from integrations.gitlab.models import GitLabConfiguration try: @@ -127,12 +136,16 @@ def _create_gitlab_resource(self, request: Any, feature: Any, resource_type: str if resource_type == "GITLAB_MR": pattern = r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/merge_requests/(\d+)$" else: - pattern = r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/(?:issues|work_items)/(\d+)$" + pattern = ( + r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/(?:issues|work_items)/(\d+)$" + ) url_match = re.search(pattern, url) if url_match: _project_path, resource_iid = url_match.groups() - api_resource_type = "merge_requests" if resource_type == "GITLAB_MR" else "issues" + api_resource_type = ( + "merge_requests" if resource_type == "GITLAB_MR" else "issues" + ) if gitlab_config.tagging_enabled and gitlab_config.gitlab_project_id: label_gitlab_issue_mr( instance_url=gitlab_config.gitlab_instance_url, @@ -149,7 +162,9 @@ def _create_gitlab_resource(self, request: Any, feature: Any, resource_type: str status=status.HTTP_400_BAD_REQUEST, ) - def _create_github_resource(self, request: Any, feature: Any, resource_type: str, *args: Any, **kwargs: Any) -> Response: + def _create_github_resource( + self, request: Any, feature: Any, resource_type: str, *args: Any, **kwargs: Any + ) -> Response: github_configuration = ( Organisation.objects.prefetch_related("github_config") .get(id=feature.project.organisation_id) @@ -212,10 +227,14 @@ def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def] # Handle GitLab resources if resource_type in ("GITLAB_MR", "GITLAB_ISSUE"): - return self._create_gitlab_resource(request, feature, resource_type, *args, **kwargs) + return self._create_gitlab_resource( + request, feature, resource_type, *args, **kwargs + ) # Handle GitHub resources - return self._create_github_resource(request, feature, resource_type, *args, **kwargs) + return self._create_github_resource( + request, feature, resource_type, *args, **kwargs + ) def perform_update(self, serializer): # type: ignore[no-untyped-def] external_resource_id = int(self.kwargs["pk"]) diff --git a/api/integrations/gitlab/gitlab.py b/api/integrations/gitlab/gitlab.py index 596e0bfa993a..be2ad0fa3f74 100644 --- a/api/integrations/gitlab/gitlab.py +++ b/api/integrations/gitlab/gitlab.py @@ -61,7 +61,10 @@ def _get_tag_value_for_event( def tag_feature_per_gitlab_event( - event_type: str, action: str, metadata: dict[str, Any], project_path: str, + event_type: str, + action: str, + metadata: dict[str, Any], + project_path: str, ) -> None: web_url = metadata.get("web_url", "") @@ -263,9 +266,7 @@ def call_gitlab_task( url: str | None, feature_states: typing.Union[list[typing.Any], list[typing.Any]] | None, ) -> None: - gitlab_configuration = GitLabConfiguration.objects.get( - project_id=project_id - ) + gitlab_configuration = GitLabConfiguration.objects.get(project_id=project_id) feature_data: GitLabData = generate_data( gitlab_configuration=gitlab_configuration, diff --git a/api/integrations/gitlab/helpers.py b/api/integrations/gitlab/helpers.py index 37de6c0d379d..d6582a3b337d 100644 --- a/api/integrations/gitlab/helpers.py +++ b/api/integrations/gitlab/helpers.py @@ -1,5 +1,6 @@ def gitlab_webhook_payload_is_valid( - secret_token: str, gitlab_token_header: str | None, + secret_token: str, + gitlab_token_header: str | None, ) -> bool: """Verify that the webhook was sent from GitLab by comparing the secret token.""" if not gitlab_token_header: diff --git a/api/integrations/gitlab/views.py b/api/integrations/gitlab/views.py index b72a5bbaf4b5..c26edfa51d83 100644 --- a/api/integrations/gitlab/views.py +++ b/api/integrations/gitlab/views.py @@ -51,9 +51,7 @@ def gitlab_auth_required(func): # type: ignore[no-untyped-def] @wraps(func) def wrapper(request, project_pk): # type: ignore[no-untyped-def] - if not GitLabConfiguration.has_gitlab_configuration( - project_id=project_pk - ): + if not GitLabConfiguration.has_gitlab_configuration(project_id=project_pk): return Response( data={ "detail": "This Project doesn't have a valid GitLab Configuration" @@ -110,7 +108,9 @@ def get_serializer_class(self): # type: ignore[no-untyped-def] def perform_create(self, serializer): # type: ignore[no-untyped-def] project_id = self.kwargs["project_pk"] serializer.save(project_id=project_id) - if serializer.validated_data.get("tagging_enabled", False) and serializer.validated_data.get("gitlab_project_id"): + if serializer.validated_data.get( + "tagging_enabled", False + ) and serializer.validated_data.get("gitlab_project_id"): create_flagsmith_flag_label( instance_url=serializer.validated_data["gitlab_instance_url"], access_token=serializer.validated_data["access_token"], @@ -121,9 +121,7 @@ def get_queryset(self): # type: ignore[no-untyped-def] if getattr(self, "swagger_fake_view", False): return GitLabConfiguration.objects.none() - return GitLabConfiguration.objects.filter( - project_id=self.kwargs["project_pk"] - ) + return GitLabConfiguration.objects.filter(project_id=self.kwargs["project_pk"]) def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def] try: @@ -165,7 +163,9 @@ def fetch_merge_requests(request, project_pk) -> Response: # type: ignore[no-un access_token=gitlab_config.access_token, params=query_serializer.validated_data, ) - return Response(data=data, content_type="application/json", status=status.HTTP_200_OK) + return Response( + data=data, content_type="application/json", status=status.HTTP_200_OK + ) @api_view(["GET"]) @@ -186,7 +186,9 @@ def fetch_issues(request, project_pk) -> Response: # type: ignore[no-untyped-de access_token=gitlab_config.access_token, params=query_serializer.validated_data, ) - return Response(data=data, content_type="application/json", status=status.HTTP_200_OK) + return Response( + data=data, content_type="application/json", status=status.HTTP_200_OK + ) @api_view(["GET"]) @@ -205,7 +207,9 @@ def fetch_projects(request, project_pk: int) -> Response | None: # type: ignore access_token=gitlab_config.access_token, params=query_serializer.validated_data, ) - return Response(data=data, content_type="application/json", status=status.HTTP_200_OK) + return Response( + data=data, content_type="application/json", status=status.HTTP_200_OK + ) @api_view(["GET"]) @@ -225,7 +229,9 @@ def fetch_project_members(request, project_pk) -> Response: # type: ignore[no-u access_token=gitlab_config.access_token, params=query_serializer.validated_data, ) - return Response(data=response, content_type="application/json", status=status.HTTP_200_OK) + return Response( + data=response, content_type="application/json", status=status.HTTP_200_OK + ) @api_view(["POST"]) @@ -279,7 +285,9 @@ def create_cleanup_issue(request, project_pk: int) -> Response: # type: ignore[ url_parts = summary.repository_url.rstrip("/").split("/") repo_path = "/".join(url_parts[-2:]) # e.g. "group/project" - if not gitlab_config.project_name or not gitlab_config.project_name.endswith(repo_path): + if not gitlab_config.project_name or not gitlab_config.project_name.endswith( + repo_path + ): continue if not gitlab_config.gitlab_project_id: diff --git a/api/projects/urls.py b/api/projects/urls.py index e342f23ce20f..fbcc8ad7aab6 100644 --- a/api/projects/urls.py +++ b/api/projects/urls.py @@ -21,12 +21,16 @@ from integrations.datadog.views import DataDogConfigurationViewSet from integrations.gitlab.views import ( GitLabConfigurationViewSet, - create_cleanup_issue as gitlab_create_cleanup_issue, - fetch_issues as gitlab_fetch_issues, fetch_merge_requests, fetch_project_members, fetch_projects, ) +from integrations.gitlab.views import ( + create_cleanup_issue as gitlab_create_cleanup_issue, +) +from integrations.gitlab.views import ( + fetch_issues as gitlab_fetch_issues, +) from integrations.grafana.views import GrafanaProjectConfigurationViewSet from integrations.launch_darkly.views import LaunchDarklyImportRequestViewSet from integrations.new_relic.views import NewRelicConfigurationViewSet diff --git a/api/tests/unit/features/test_unit_feature_external_resources_views.py b/api/tests/unit/features/test_unit_feature_external_resources_views.py index ed575a8445b9..fdc01f13a846 100644 --- a/api/tests/unit/features/test_unit_feature_external_resources_views.py +++ b/api/tests/unit/features/test_unit_feature_external_resources_views.py @@ -1151,9 +1151,7 @@ def test_call_gitlab_task__feature_state_updated__calls_task( feature=feature, metadata='{"state": "opened"}', ) - mock_call_gitlab = mocker.patch( - "integrations.gitlab.gitlab.call_gitlab_task" - ) + mock_call_gitlab = mocker.patch("integrations.gitlab.gitlab.call_gitlab_task") with_environment_permissions([UPDATE_FEATURE_STATE], environment.id, False) feature_state = FeatureState.objects.get( feature=feature, environment=environment.id @@ -1192,9 +1190,7 @@ def test_call_gitlab_task__v2_versioning_feature_state_updated__calls_task( feature=feature, metadata='{"state": "opened"}', ) - mock_call_gitlab = mocker.patch( - "integrations.gitlab.gitlab.call_gitlab_task" - ) + mock_call_gitlab = mocker.patch("integrations.gitlab.gitlab.call_gitlab_task") environment_feature_version = EnvironmentFeatureVersion.objects.create( environment=environment_v2_versioning, feature=feature diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_client.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_client.py index 9aaab5d3511b..551fd7937cf9 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_client.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_client.py @@ -145,7 +145,9 @@ def test_create_gitlab_issue__valid_data__creates_issue() -> None: @responses.activate -def test_get_gitlab_issue_mr_title_and_state__valid_resource__returns_metadata() -> None: +def test_get_gitlab_issue_mr_title_and_state__valid_resource__returns_metadata() -> ( + None +): # Given responses.add( responses.GET, @@ -305,7 +307,9 @@ def test_label_gitlab_issue_mr__happy_path__adds_label() -> None: # --------------------------------------------------------------- -def test_build_paginated_response__pagination_headers__returns_previous_and_next() -> None: +def test_build_paginated_response__pagination_headers__returns_previous_and_next() -> ( + None +): # Given import requests diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py index 3ad7588c63de..2149ff05d89d 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py @@ -270,9 +270,7 @@ def test_tag_feature_per_gitlab_event__work_items_url_variant__finds_feature( mocker.patch( "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" ) - work_items_url = ( - "https://gitlab.example.com/testgroup/testrepo/-/work_items/5" - ) + work_items_url = "https://gitlab.example.com/testgroup/testrepo/-/work_items/5" FeatureExternalResource.objects.create( url=work_items_url, type="GITLAB_ISSUE", @@ -365,9 +363,7 @@ def test_handle_gitlab_webhook_event__merge_request__calls_tag_feature( mocker: MockerFixture, ) -> None: # Given - mock_tag = mocker.patch( - "integrations.gitlab.gitlab.tag_feature_per_gitlab_event" - ) + mock_tag = mocker.patch("integrations.gitlab.gitlab.tag_feature_per_gitlab_event") payload = { "object_attributes": { "action": "merge", @@ -401,9 +397,7 @@ def test_handle_gitlab_webhook_event__issue__calls_tag_feature( mocker: MockerFixture, ) -> None: # Given - mock_tag = mocker.patch( - "integrations.gitlab.gitlab.tag_feature_per_gitlab_event" - ) + mock_tag = mocker.patch("integrations.gitlab.gitlab.tag_feature_per_gitlab_event") payload = { "object_attributes": { "action": "close", @@ -430,9 +424,7 @@ def test_handle_gitlab_webhook_event__unknown_event__does_nothing( mocker: MockerFixture, ) -> None: # Given - mock_tag = mocker.patch( - "integrations.gitlab.gitlab.tag_feature_per_gitlab_event" - ) + mock_tag = mocker.patch("integrations.gitlab.gitlab.tag_feature_per_gitlab_event") # When handle_gitlab_webhook_event(event_type="push", payload={}) @@ -478,7 +470,9 @@ def test_generate_body_comment__resource_removed__returns_unlinked_text() -> Non assert result == UNLINKED_FEATURE_TEXT % "my_flag" -def test_generate_body_comment__segment_override_deleted__returns_segment_deleted_text() -> None: +def test_generate_body_comment__segment_override_deleted__returns_segment_deleted_text() -> ( + None +): # Given event_type = GitLabEventType.SEGMENT_OVERRIDE_DELETED.value @@ -648,7 +642,9 @@ def test_generate_data__feature_state_with_value__includes_feature_state_value( project=gitlab_configuration.project, ) feature_state = FeatureState.objects.filter( - feature=feature_with_val, identity__isnull=True, environment=environment, + feature=feature_with_val, + identity__isnull=True, + environment=environment, ).first() assert feature_state is not None @@ -806,14 +802,18 @@ def test_paginated_query_params__page_less_than_1__raises_value_error() -> None: def test_paginated_query_params__page_size_too_large__raises_value_error() -> None: # Given / When # Then - with pytest.raises(ValueError, match="Page size must be an integer between 1 and 100"): + with pytest.raises( + ValueError, match="Page size must be an integer between 1 and 100" + ): PaginatedQueryParams(page=1, page_size=101) def test_paginated_query_params__page_size_less_than_1__raises_value_error() -> None: # Given / When # Then - with pytest.raises(ValueError, match="Page size must be an integer between 1 and 100"): + with pytest.raises( + ValueError, match="Page size must be an integer between 1 and 100" + ): PaginatedQueryParams(page=1, page_size=0) @@ -822,7 +822,9 @@ def test_paginated_query_params__page_size_less_than_1__raises_value_error() -> # --------------------------------------------------------------- -def test_resolve_resource_urls_for_event__flag_updated__returns_all_resource_urls() -> None: +def test_resolve_resource_urls_for_event__flag_updated__returns_all_resource_urls() -> ( + None +): # Given gitlab_data = GitLabData( gitlab_instance_url="https://gitlab.example.com", @@ -835,8 +837,14 @@ def test_resolve_resource_urls_for_event__flag_updated__returns_all_resource_url event_type=GitLabEventType.FLAG_UPDATED.value, gitlab_data=gitlab_data, feature_external_resources=[ - {"type": "GITLAB_ISSUE", "url": "https://gitlab.example.com/group/project/-/issues/1"}, - {"type": "GITLAB_MR", "url": "https://gitlab.example.com/group/project/-/merge_requests/2"}, + { + "type": "GITLAB_ISSUE", + "url": "https://gitlab.example.com/group/project/-/issues/1", + }, + { + "type": "GITLAB_MR", + "url": "https://gitlab.example.com/group/project/-/merge_requests/2", + }, ], ) @@ -849,7 +857,9 @@ def test_resolve_resource_urls_for_event__flag_updated__returns_all_resource_url assert "merge_requests/2" in result[1] -def test_resolve_resource_urls_for_event__flag_deleted__returns_all_resource_urls() -> None: +def test_resolve_resource_urls_for_event__flag_deleted__returns_all_resource_urls() -> ( + None +): # Given gitlab_data = GitLabData( gitlab_instance_url="https://gitlab.example.com", @@ -862,7 +872,10 @@ def test_resolve_resource_urls_for_event__flag_deleted__returns_all_resource_url event_type=GitLabEventType.FLAG_DELETED.value, gitlab_data=gitlab_data, feature_external_resources=[ - {"type": "GITLAB_ISSUE", "url": "https://gitlab.example.com/group/project/-/issues/1"}, + { + "type": "GITLAB_ISSUE", + "url": "https://gitlab.example.com/group/project/-/issues/1", + }, ], ) @@ -873,7 +886,9 @@ def test_resolve_resource_urls_for_event__flag_deleted__returns_all_resource_url assert len(result) == 1 -def test_resolve_resource_urls_for_event__resource_removed_with_url__returns_url() -> None: +def test_resolve_resource_urls_for_event__resource_removed_with_url__returns_url() -> ( + None +): # Given gitlab_data = GitLabData( gitlab_instance_url="https://gitlab.example.com", @@ -896,7 +911,9 @@ def test_resolve_resource_urls_for_event__resource_removed_with_url__returns_url assert result == ["https://gitlab.example.com/group/project/-/issues/5"] -def test_resolve_resource_urls_for_event__resource_removed_no_url__returns_empty() -> None: +def test_resolve_resource_urls_for_event__resource_removed_no_url__returns_empty() -> ( + None +): # Given gitlab_data = GitLabData( gitlab_instance_url="https://gitlab.example.com", @@ -932,8 +949,14 @@ def test_resolve_resource_urls_for_event__default_case__returns_last_resource() event_type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value, gitlab_data=gitlab_data, feature_external_resources=[ - {"type": "GITLAB_ISSUE", "url": "https://gitlab.example.com/group/project/-/issues/1"}, - {"type": "GITLAB_MR", "url": "https://gitlab.example.com/group/project/-/merge_requests/2"}, + { + "type": "GITLAB_ISSUE", + "url": "https://gitlab.example.com/group/project/-/issues/1", + }, + { + "type": "GITLAB_MR", + "url": "https://gitlab.example.com/group/project/-/merge_requests/2", + }, ], ) @@ -945,7 +968,9 @@ def test_resolve_resource_urls_for_event__default_case__returns_last_resource() assert "merge_requests/2" in result[0] -def test_resolve_resource_urls_for_event__default_empty_resources__returns_empty() -> None: +def test_resolve_resource_urls_for_event__default_empty_resources__returns_empty() -> ( + None +): # Given gitlab_data = GitLabData( gitlab_instance_url="https://gitlab.example.com", @@ -978,9 +1003,7 @@ def test_post_to_resource__mr_url__posts_to_merge_requests( mocker: MockerFixture, ) -> None: # Given - mock_post_comment = mocker.patch( - "integrations.gitlab.tasks.post_comment_to_gitlab" - ) + mock_post_comment = mocker.patch("integrations.gitlab.tasks.post_comment_to_gitlab") resource_url = "https://gitlab.example.com/testgroup/testrepo/-/merge_requests/3" # When @@ -1008,9 +1031,7 @@ def test_post_to_resource__issue_url__posts_to_issues( mocker: MockerFixture, ) -> None: # Given - mock_post_comment = mocker.patch( - "integrations.gitlab.tasks.post_comment_to_gitlab" - ) + mock_post_comment = mocker.patch("integrations.gitlab.tasks.post_comment_to_gitlab") resource_url = "https://gitlab.example.com/testgroup/testrepo/-/issues/7" # When @@ -1038,9 +1059,7 @@ def test_post_to_resource__work_items_url__posts_to_issues( mocker: MockerFixture, ) -> None: # Given - mock_post_comment = mocker.patch( - "integrations.gitlab.tasks.post_comment_to_gitlab" - ) + mock_post_comment = mocker.patch("integrations.gitlab.tasks.post_comment_to_gitlab") resource_url = "https://gitlab.example.com/testgroup/testrepo/-/work_items/7" # When @@ -1067,9 +1086,7 @@ def test_post_to_resource__unknown_url_format__does_not_post( mocker: MockerFixture, ) -> None: # Given - mock_post_comment = mocker.patch( - "integrations.gitlab.tasks.post_comment_to_gitlab" - ) + mock_post_comment = mocker.patch("integrations.gitlab.tasks.post_comment_to_gitlab") resource_url = "https://gitlab.example.com/testgroup/testrepo/-/pipelines/1" # When @@ -1089,9 +1106,7 @@ def test_post_to_resource__missing_config__does_not_post( mocker: MockerFixture, ) -> None: # Given - mock_post_comment = mocker.patch( - "integrations.gitlab.tasks.post_comment_to_gitlab" - ) + mock_post_comment = mocker.patch("integrations.gitlab.tasks.post_comment_to_gitlab") resource_url = "https://gitlab.example.com/nonexistent/project/-/issues/1" # When @@ -1110,9 +1125,7 @@ def test_post_to_resource__no_iid_in_url__returns_early( mocker: MockerFixture, ) -> None: # Given — URL has /-/issues/ but no digits after it - mock_post_comment = mocker.patch( - "integrations.gitlab.tasks.post_comment_to_gitlab" - ) + mock_post_comment = mocker.patch("integrations.gitlab.tasks.post_comment_to_gitlab") resource_url = "https://gitlab.example.com/group/project/-/issues/notanumber" # When @@ -1131,9 +1144,7 @@ def test_post_to_resource__no_project_path_match__returns_early( mocker: MockerFixture, ) -> None: # Given — URL has hyphens in the group name which breaks the [^/-] regex - mock_post_comment = mocker.patch( - "integrations.gitlab.tasks.post_comment_to_gitlab" - ) + mock_post_comment = mocker.patch("integrations.gitlab.tasks.post_comment_to_gitlab") resource_url = "https://gitlab.example.com/my-group/my-project/-/issues/1" # When @@ -1159,9 +1170,7 @@ def test_send_post_request__with_resources__calls_post_to_resource( mocker: MockerFixture, ) -> None: # Given - mock_post_to_resource = mocker.patch( - "integrations.gitlab.tasks._post_to_resource" - ) + mock_post_to_resource = mocker.patch("integrations.gitlab.tasks._post_to_resource") gitlab_data = GitLabData( gitlab_instance_url="https://gitlab.example.com", access_token="test-token", @@ -1175,7 +1184,10 @@ def test_send_post_request__with_resources__calls_post_to_resource( event_type=GitLabEventType.FLAG_UPDATED.value, gitlab_data=gitlab_data, feature_external_resources=[ - {"type": "GITLAB_ISSUE", "url": "https://gitlab.example.com/testgroup/testrepo/-/issues/1"}, + { + "type": "GITLAB_ISSUE", + "url": "https://gitlab.example.com/testgroup/testrepo/-/issues/1", + }, ], ) @@ -1257,7 +1269,9 @@ def test_call_gitlab_app_webhook_for_feature_state__resource_removed__posts_with # Then mock_send.assert_called_once() call_args = mock_send.call_args[0][0] - assert call_args.event_type == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value + assert ( + call_args.event_type == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value + ) assert call_args.feature_external_resources == [] @@ -1528,9 +1542,7 @@ def test_feature_segment_create_github_comment__with_gitlab_config__calls_gitlab metadata='{"state": "opened"}', ) - segment = Segment.objects.create( - name="test_segment", project=feature.project - ) + segment = Segment.objects.create(name="test_segment", project=feature.project) feature_segment = FeatureSegment.objects.create( feature=feature, segment=segment, @@ -1644,9 +1656,7 @@ def test_post_to_resource__issue_url__extracts_iid_and_project_path( mocker: MockerFixture, ) -> None: # Given - mock_post_comment = mocker.patch( - "integrations.gitlab.tasks.post_comment_to_gitlab" - ) + mock_post_comment = mocker.patch("integrations.gitlab.tasks.post_comment_to_gitlab") # testgroup/testrepo matches the gitlab_configuration fixture's project_name resource_url = "https://gitlab.example.com/testgroup/testrepo/-/issues/42" diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_views.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_views.py index 6dbaab03376c..c97e53759a92 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_views.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_views.py @@ -70,9 +70,7 @@ def test_create_gitlab_configuration__valid_data__returns_201( response = admin_client_new.post(url, data) # Then assert response.status_code == status.HTTP_201_CREATED - assert GitLabConfiguration.objects.filter( - project=project - ).exists() + assert GitLabConfiguration.objects.filter(project=project).exists() def test_create_gitlab_configuration__duplicate__returns_400( @@ -114,9 +112,7 @@ def test_delete_gitlab_configuration__valid_configuration__returns_204( response = admin_client_new.delete(url) # Then assert response.status_code == status.HTTP_204_NO_CONTENT - assert not GitLabConfiguration.objects.filter( - id=gitlab_configuration.id - ).exists() + assert not GitLabConfiguration.objects.filter(id=gitlab_configuration.id).exists() @responses.activate From 86d8c2c83f15f8e40cbe75fa30f8d44ee86586dc Mon Sep 17 00:00:00 2001 From: "Asaph M. Kotzin" Date: Tue, 24 Mar 2026 19:09:54 +0000 Subject: [PATCH 3/7] fix(gitlab): allow hyphens in GitLab project paths The regex [^/-] excluded hyphens from project path segments, breaking URLs like my-group/my-project/-/issues/1. Replace with [^/] which matches any character except forward slash, correctly handling hyphens in GitLab namespace and project names. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/features/feature_external_resources/views.py | 8 ++++---- api/integrations/gitlab/tasks.py | 2 +- .../unit/integrations/gitlab/test_unit_gitlab_gitlab.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/features/feature_external_resources/views.py b/api/features/feature_external_resources/views.py index 06207420b36d..a1b49ffcfad2 100644 --- a/api/features/feature_external_resources/views.py +++ b/api/features/feature_external_resources/views.py @@ -86,10 +86,10 @@ def list(self, request, *args, **kwargs) -> Response: # type: ignore[no-untyped if gitlab_config and gitlab_config.gitlab_project_id: # Parse resource IID from URL if resource_type == "GITLAB_MR": - match = _re.search(r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/merge_requests/(\d+)$", resource_url) + match = _re.search(r"https?://[^/]+/([^/]+(?:/[^/]+)*)/-/merge_requests/(\d+)$", resource_url) api_type = "merge_requests" else: - match = _re.search(r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/(?:issues|work_items)/(\d+)$", resource_url) + match = _re.search(r"https?://[^/]+/([^/]+(?:/[^/]+)*)/-/(?:issues|work_items)/(\d+)$", resource_url) api_type = "issues" if match: @@ -125,9 +125,9 @@ def _create_gitlab_resource(self, request: Any, feature: Any, resource_type: str url = request.data.get("url") if resource_type == "GITLAB_MR": - pattern = r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/merge_requests/(\d+)$" + pattern = r"https?://[^/]+/([^/]+(?:/[^/]+)*)/-/merge_requests/(\d+)$" else: - pattern = r"https?://[^/]+/([^/-]+(?:/[^/-]+)*)/-/(?:issues|work_items)/(\d+)$" + pattern = r"https?://[^/]+/([^/]+(?:/[^/]+)*)/-/(?:issues|work_items)/(\d+)$" url_match = re.search(pattern, url) if url_match: diff --git a/api/integrations/gitlab/tasks.py b/api/integrations/gitlab/tasks.py index 2a63ac77ef9e..92dd35d0d62f 100644 --- a/api/integrations/gitlab/tasks.py +++ b/api/integrations/gitlab/tasks.py @@ -63,7 +63,7 @@ def _post_to_resource( resource_iid = int(iid_match.group(1)) # Extract project path from URL (everything between host and /-/) - project_path_match = re.search(r"^/([^/-]+(?:/[^/-]+)*)/-/", path) + project_path_match = re.search(r"^/([^/]+(?:/[^/]+)*)/-/", path) if not project_path_match: return project_path = project_path_match.group(1) diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py index 3ad7588c63de..0be7fdafad00 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py @@ -1130,11 +1130,11 @@ def test_post_to_resource__no_iid_in_url__returns_early( def test_post_to_resource__no_project_path_match__returns_early( mocker: MockerFixture, ) -> None: - # Given — URL has hyphens in the group name which breaks the [^/-] regex + # Given — URL path starts directly with /-/ with no project path before it mock_post_comment = mocker.patch( "integrations.gitlab.tasks.post_comment_to_gitlab" ) - resource_url = "https://gitlab.example.com/my-group/my-project/-/issues/1" + resource_url = "https://gitlab.example.com/-/issues/1" # When _post_to_resource( From cdd6acc4f160d01369e4166f4237bb8179ca27ff Mon Sep 17 00:00:00 2001 From: "Asaph M. Kotzin" Date: Tue, 24 Mar 2026 19:16:39 +0000 Subject: [PATCH 4/7] refactor(vcs): extract shared comment generation into integrations/vcs/ Move generate_body_comment logic into a shared module at integrations/vcs/comments.py. Both GitHub and GitLab now delegate to this shared function, parameterised by unlinked_feature_text ("issue/PR" vs "issue/MR"). Removes ~60 lines of duplication. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/integrations/github/github.py | 69 +++-------------- api/integrations/gitlab/gitlab.py | 69 +++-------------- api/integrations/vcs/__init__.py | 0 api/integrations/vcs/comments.py | 76 +++++++++++++++++++ api/integrations/vcs/constants.py | 12 +++ .../gitlab/test_unit_gitlab_gitlab.py | 6 +- 6 files changed, 113 insertions(+), 119 deletions(-) create mode 100644 api/integrations/vcs/__init__.py create mode 100644 api/integrations/vcs/comments.py create mode 100644 api/integrations/vcs/constants.py diff --git a/api/integrations/github/github.py b/api/integrations/github/github.py index bc8172e3b6d4..f6cbcda0e272 100644 --- a/api/integrations/github/github.py +++ b/api/integrations/github/github.py @@ -6,19 +6,10 @@ from django.db.models import Q from django.utils.formats import get_format -from core.helpers import get_current_site_url from features.models import Feature, FeatureState, FeatureStateValue from integrations.github.constants import ( - DELETED_FEATURE_TEXT, - DELETED_SEGMENT_OVERRIDE_TEXT, - FEATURE_ENVIRONMENT_URL, - FEATURE_TABLE_HEADER, - FEATURE_TABLE_ROW, GITHUB_TAG_COLOR, - LINK_FEATURE_TITLE, - LINK_SEGMENT_TITLE, UNLINKED_FEATURE_TEXT, - UPDATED_FEATURE_TEXT, GitHubEventType, GitHubTag, github_tag_description, @@ -130,57 +121,19 @@ def generate_body_comment( project_id: int | None = None, segment_name: str | None = None, ) -> str: - is_removed = event_type == GitHubEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value - is_segment_override_deleted = ( - event_type == GitHubEventType.SEGMENT_OVERRIDE_DELETED.value + from integrations.vcs.comments import ( + generate_body_comment as _generate_body_comment, ) - if event_type == GitHubEventType.FLAG_DELETED.value: - return DELETED_FEATURE_TEXT % (name) - - if is_removed: - return UNLINKED_FEATURE_TEXT % (name) - - if is_segment_override_deleted and segment_name is not None: - return DELETED_SEGMENT_OVERRIDE_TEXT % (segment_name, name) - - result = "" - if event_type == GitHubEventType.FLAG_UPDATED.value: - result = UPDATED_FEATURE_TEXT % (name) - else: - result = LINK_FEATURE_TITLE % (name) - - last_segment_name = "" - if len(feature_states) > 0 and not feature_states[0].get("segment_name"): - result += FEATURE_TABLE_HEADER - - for fs in feature_states: - feature_value = fs.get("feature_state_value") - tab = "segment-overrides" if fs.get("segment_name") is not None else "value" - environment_link_url = FEATURE_ENVIRONMENT_URL % ( - get_current_site_url(), - project_id, - fs.get("environment_api_key"), - feature_id, - tab, - ) - if ( - fs.get("segment_name") is not None - and fs["segment_name"] != last_segment_name - ): - result += "\n" + LINK_SEGMENT_TITLE % (fs["segment_name"]) - last_segment_name = fs["segment_name"] - result += FEATURE_TABLE_HEADER - table_row = FEATURE_TABLE_ROW % ( - fs["environment_name"], - environment_link_url, - "✅ Enabled" if fs["enabled"] else "❌ Disabled", - f"`{feature_value}`" if feature_value else "", - fs["last_updated"], - ) - result += table_row - - return result + return _generate_body_comment( + name=name, + event_type=event_type, + feature_id=feature_id, + feature_states=feature_states, + unlinked_feature_text=UNLINKED_FEATURE_TEXT, + project_id=project_id, + segment_name=segment_name, + ) def check_not_none(value: Any) -> bool: diff --git a/api/integrations/gitlab/gitlab.py b/api/integrations/gitlab/gitlab.py index 596e0bfa993a..bd9dafdf7d7d 100644 --- a/api/integrations/gitlab/gitlab.py +++ b/api/integrations/gitlab/gitlab.py @@ -6,19 +6,10 @@ from django.db.models import Q from django.utils.formats import get_format -from core.helpers import get_current_site_url from features.models import Feature, FeatureState, FeatureStateValue from integrations.gitlab.constants import ( - DELETED_FEATURE_TEXT, - DELETED_SEGMENT_OVERRIDE_TEXT, - FEATURE_ENVIRONMENT_URL, - FEATURE_TABLE_HEADER, - FEATURE_TABLE_ROW, GITLAB_TAG_COLOR, - LINK_FEATURE_TITLE, - LINK_SEGMENT_TITLE, UNLINKED_FEATURE_TEXT, - UPDATED_FEATURE_TEXT, GitLabEventType, GitLabTag, gitlab_tag_description, @@ -147,57 +138,19 @@ def generate_body_comment( project_id: int | None = None, segment_name: str | None = None, ) -> str: - is_removed = event_type == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value - is_segment_override_deleted = ( - event_type == GitLabEventType.SEGMENT_OVERRIDE_DELETED.value + from integrations.vcs.comments import ( + generate_body_comment as _generate_body_comment, ) - if event_type == GitLabEventType.FLAG_DELETED.value: - return DELETED_FEATURE_TEXT % (name) - - if is_removed: - return UNLINKED_FEATURE_TEXT % (name) - - if is_segment_override_deleted and segment_name is not None: - return DELETED_SEGMENT_OVERRIDE_TEXT % (segment_name, name) - - result = "" - if event_type == GitLabEventType.FLAG_UPDATED.value: - result = UPDATED_FEATURE_TEXT % (name) - else: - result = LINK_FEATURE_TITLE % (name) - - last_segment_name = "" - if len(feature_states) > 0 and not feature_states[0].get("segment_name"): - result += FEATURE_TABLE_HEADER - - for fs in feature_states: - feature_value = fs.get("feature_state_value") - tab = "segment-overrides" if fs.get("segment_name") is not None else "value" - environment_link_url = FEATURE_ENVIRONMENT_URL % ( - get_current_site_url(), - project_id, - fs.get("environment_api_key"), - feature_id, - tab, - ) - if ( - fs.get("segment_name") is not None - and fs["segment_name"] != last_segment_name - ): - result += "\n" + LINK_SEGMENT_TITLE % (fs["segment_name"]) - last_segment_name = fs["segment_name"] - result += FEATURE_TABLE_HEADER - table_row = FEATURE_TABLE_ROW % ( - fs["environment_name"], - environment_link_url, - "✅ Enabled" if fs["enabled"] else "❌ Disabled", - f"`{feature_value}`" if feature_value else "", - fs["last_updated"], - ) - result += table_row - - return result + return _generate_body_comment( + name=name, + event_type=event_type, + feature_id=feature_id, + feature_states=feature_states, + unlinked_feature_text=UNLINKED_FEATURE_TEXT, + project_id=project_id, + segment_name=segment_name, + ) def generate_data( diff --git a/api/integrations/vcs/__init__.py b/api/integrations/vcs/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/vcs/comments.py b/api/integrations/vcs/comments.py new file mode 100644 index 000000000000..6ba9733aa3e2 --- /dev/null +++ b/api/integrations/vcs/comments.py @@ -0,0 +1,76 @@ +import typing + +from core.helpers import get_current_site_url +from integrations.vcs.constants import ( + DELETED_FEATURE_TEXT, + DELETED_SEGMENT_OVERRIDE_TEXT, + FEATURE_ENVIRONMENT_URL, + FEATURE_TABLE_HEADER, + FEATURE_TABLE_ROW, + LINK_FEATURE_TITLE, + LINK_SEGMENT_TITLE, + UPDATED_FEATURE_TEXT, +) + + +def generate_body_comment( + name: str, + event_type: str, + feature_id: int, + feature_states: list[dict[str, typing.Any]], + unlinked_feature_text: str, + project_id: int | None = None, + segment_name: str | None = None, +) -> str: + """Generate a markdown comment for a VCS issue/PR/MR. + + This is shared between GitHub and GitLab integrations. The only + difference is the unlinked_feature_text which uses "PR" for GitHub + and "MR" for GitLab. + """ + if event_type == "FLAG_DELETED": + return DELETED_FEATURE_TEXT % (name) + + if event_type == "FEATURE_EXTERNAL_RESOURCE_REMOVED": + return unlinked_feature_text % (name) + + if event_type == "SEGMENT_OVERRIDE_DELETED" and segment_name is not None: + return DELETED_SEGMENT_OVERRIDE_TEXT % (segment_name, name) + + result = "" + if event_type == "FLAG_UPDATED": + result = UPDATED_FEATURE_TEXT % (name) + else: + result = LINK_FEATURE_TITLE % (name) + + last_segment_name = "" + if len(feature_states) > 0 and not feature_states[0].get("segment_name"): + result += FEATURE_TABLE_HEADER + + for fs in feature_states: + feature_value = fs.get("feature_state_value") + tab = "segment-overrides" if fs.get("segment_name") is not None else "value" + environment_link_url = FEATURE_ENVIRONMENT_URL % ( + get_current_site_url(), + project_id, + fs.get("environment_api_key"), + feature_id, + tab, + ) + if ( + fs.get("segment_name") is not None + and fs["segment_name"] != last_segment_name + ): + result += "\n" + LINK_SEGMENT_TITLE % (fs["segment_name"]) + last_segment_name = fs["segment_name"] + result += FEATURE_TABLE_HEADER + table_row = FEATURE_TABLE_ROW % ( + fs["environment_name"], + environment_link_url, + "✅ Enabled" if fs["enabled"] else "❌ Disabled", + f"`{feature_value}`" if feature_value else "", + fs["last_updated"], + ) + result += table_row + + return result diff --git a/api/integrations/vcs/constants.py b/api/integrations/vcs/constants.py new file mode 100644 index 000000000000..ff63f7f997e6 --- /dev/null +++ b/api/integrations/vcs/constants.py @@ -0,0 +1,12 @@ +LINK_FEATURE_TITLE = """**Flagsmith feature linked:** `%s` +Default Values:\n""" +FEATURE_TABLE_HEADER = """| Environment | Enabled | Value | Last Updated (UTC) | +| :--- | :----- | :------ | :------ |\n""" +FEATURE_TABLE_ROW = "| [%s](%s) | %s | %s | %s |\n" +LINK_SEGMENT_TITLE = "Segment `%s` values:\n" +UPDATED_FEATURE_TEXT = "**Flagsmith Feature `%s` has been updated:**\n" +DELETED_FEATURE_TEXT = "**The Feature Flag `%s` was deleted**" +DELETED_SEGMENT_OVERRIDE_TEXT = ( + "**The Segment Override `%s` for Feature Flag `%s` was deleted**" +) +FEATURE_ENVIRONMENT_URL = "%s/project/%s/environment/%s/features?feature=%s&tab=%s" diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py index 0be7fdafad00..004e0afb2862 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py @@ -500,7 +500,7 @@ def test_generate_body_comment__flag_updated__returns_updated_text_with_table( ) -> None: # Given mocker.patch( - "integrations.gitlab.gitlab.get_current_site_url", + "integrations.vcs.comments.get_current_site_url", return_value="https://example.com", ) event_type = GitLabEventType.FLAG_UPDATED.value @@ -534,7 +534,7 @@ def test_generate_body_comment__resource_added_with_feature_states__returns_link ) -> None: # Given mocker.patch( - "integrations.gitlab.gitlab.get_current_site_url", + "integrations.vcs.comments.get_current_site_url", return_value="https://example.com", ) event_type = GitLabEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value @@ -568,7 +568,7 @@ def test_generate_body_comment__with_segment_feature_states__includes_segment_he ) -> None: # Given mocker.patch( - "integrations.gitlab.gitlab.get_current_site_url", + "integrations.vcs.comments.get_current_site_url", return_value="https://example.com", ) event_type = GitLabEventType.FLAG_UPDATED.value From d0f489cc0004e88001b631b27d8aa09ac7a2339b Mon Sep 17 00:00:00 2001 From: "Asaph M. Kotzin" Date: Tue, 24 Mar 2026 19:19:00 +0000 Subject: [PATCH 5/7] refactor(vcs): extract shared feature state collection helper Move the duplicated feature state collection logic from both GitHub and GitLab AFTER_SAVE hooks into integrations/vcs/helpers.py. Reduces the FeatureExternalResource model complexity and removes unused imports. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../feature_external_resources/models.py | 40 +++++-------------- api/integrations/vcs/helpers.py | 26 ++++++++++++ 2 files changed, 36 insertions(+), 30 deletions(-) create mode 100644 api/integrations/vcs/helpers.py diff --git a/api/features/feature_external_resources/models.py b/api/features/feature_external_resources/models.py index b9fe24eb9a8a..85fc511c5541 100644 --- a/api/features/feature_external_resources/models.py +++ b/api/features/feature_external_resources/models.py @@ -3,7 +3,6 @@ import re from django.db import models -from django.db.models import Q from django_lifecycle import ( # type: ignore[import-untyped] AFTER_SAVE, BEFORE_DELETE, @@ -11,8 +10,7 @@ hook, ) -from environments.models import Environment -from features.models import Feature, FeatureState +from features.models import Feature from integrations.github.constants import GitHubEventType, GitHubTag from integrations.gitlab.constants import GitLabEventType, GitLabTag from organisations.models import Organisation @@ -121,23 +119,13 @@ def _handle_github_after_save(self, state: str) -> None: self.feature.tags.add(github_tag) self.feature.save() - feature_states: list[FeatureState] = [] + from integrations.vcs.helpers import collect_feature_states_for_resource - environments = Environment.objects.filter( - project_id=self.feature.project_id + feature_states = collect_feature_states_for_resource( + feature_id=self.feature_id, + project_id=self.feature.project_id, ) - for environment in environments: - q = Q( - feature_id=self.feature_id, - identity__isnull=True, - ) - feature_states.extend( - FeatureState.objects.get_live_feature_states( - environment=environment, additional_filters=q - ) - ) - call_github_task( organisation_id=self.feature.project.organisation_id, # type: ignore[arg-type] type=GitHubEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value, @@ -169,20 +157,12 @@ def _handle_gitlab_after_save(self, state: str) -> None: self.feature.tags.add(gitlab_tag) self.feature.save() - feature_states: list[FeatureState] = [] - environments = Environment.objects.filter( - project_id=self.feature.project_id + from integrations.vcs.helpers import collect_feature_states_for_resource + + feature_states = collect_feature_states_for_resource( + feature_id=self.feature_id, + project_id=self.feature.project_id, ) - for environment in environments: - q = Q( - feature_id=self.feature_id, - identity__isnull=True, - ) - feature_states.extend( - FeatureState.objects.get_live_feature_states( - environment=environment, additional_filters=q - ) - ) call_gitlab_task( project_id=self.feature.project_id, diff --git a/api/integrations/vcs/helpers.py b/api/integrations/vcs/helpers.py new file mode 100644 index 000000000000..202937f7c656 --- /dev/null +++ b/api/integrations/vcs/helpers.py @@ -0,0 +1,26 @@ +from django.db.models import Q + +from environments.models import Environment +from features.models import FeatureState + + +def collect_feature_states_for_resource( + feature_id: int, project_id: int +) -> list[FeatureState]: + """Collect live feature states across all environments for a feature. + + Used by both GitHub and GitLab integrations when a feature is linked + to an external resource. + """ + feature_states: list[FeatureState] = [] + environments = Environment.objects.filter(project_id=project_id) + + for environment in environments: + q = Q(feature_id=feature_id, identity__isnull=True) + feature_states.extend( + FeatureState.objects.get_live_feature_states( + environment=environment, additional_filters=q + ) + ) + + return feature_states From 9cb935a9a8455293b4a492bb6dd2fb3927420755 Mon Sep 17 00:00:00 2001 From: "Asaph M. Kotzin" Date: Tue, 24 Mar 2026 19:19:50 +0000 Subject: [PATCH 6/7] refactor(frontend): use integration flags instead of hardcoded key names Replace 'key !== "github" && key !== "gitlab"' with flag-based checks (isExternalInstallation, isGitlabIntegration) so new integrations with custom flows don't need to be added to the skip list. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/web/components/IntegrationList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/web/components/IntegrationList.tsx b/frontend/web/components/IntegrationList.tsx index f348b53766df..fc5af93bb65d 100644 --- a/frontend/web/components/IntegrationList.tsx +++ b/frontend/web/components/IntegrationList.tsx @@ -355,7 +355,7 @@ const IntegrationList: FC = (props) => { return allItems }) } - if (key !== 'github' && key !== 'gitlab') { + if (!integration.isExternalInstallation && !integration.isGitlabIntegration) { return _data .get( `${Project.api}projects/${props.projectId}/integrations/${key}/`, From 2cd0a1be9ee2358821b160b49abfc3c8dab210c1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:29:38 +0000 Subject: [PATCH 7/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../feature_external_resources/views.py | 37 +++-- .../gitlab/test_unit_gitlab_gitlab.py | 126 ++++++++++-------- 2 files changed, 96 insertions(+), 67 deletions(-) diff --git a/api/features/feature_external_resources/views.py b/api/features/feature_external_resources/views.py index a1b49ffcfad2..fa1b4224e524 100644 --- a/api/features/feature_external_resources/views.py +++ b/api/features/feature_external_resources/views.py @@ -69,13 +69,14 @@ def list(self, request, *args, **kwargs) -> Response: # type: ignore[no-untyped ) elif resource_type.startswith("GITLAB_"): try: + import re as _re + from integrations.gitlab.client import ( get_gitlab_issue_mr_title_and_state as get_gitlab_metadata, ) from integrations.gitlab.models import ( GitLabConfiguration, ) - import re as _re feature_obj = get_object_or_404( Feature.objects.filter(id=self.kwargs["feature_pk"]), @@ -86,10 +87,16 @@ def list(self, request, *args, **kwargs) -> Response: # type: ignore[no-untyped if gitlab_config and gitlab_config.gitlab_project_id: # Parse resource IID from URL if resource_type == "GITLAB_MR": - match = _re.search(r"https?://[^/]+/([^/]+(?:/[^/]+)*)/-/merge_requests/(\d+)$", resource_url) + match = _re.search( + r"https?://[^/]+/([^/]+(?:/[^/]+)*)/-/merge_requests/(\d+)$", + resource_url, + ) api_type = "merge_requests" else: - match = _re.search(r"https?://[^/]+/([^/]+(?:/[^/]+)*)/-/(?:issues|work_items)/(\d+)$", resource_url) + match = _re.search( + r"https?://[^/]+/([^/]+(?:/[^/]+)*)/-/(?:issues|work_items)/(\d+)$", + resource_url, + ) api_type = "issues" if match: @@ -106,7 +113,9 @@ def list(self, request, *args, **kwargs) -> Response: # type: ignore[no-untyped return Response(data={"results": data}) - def _create_gitlab_resource(self, request: Any, feature: Any, resource_type: str, *args: Any, **kwargs: Any) -> Response: + def _create_gitlab_resource( + self, request: Any, feature: Any, resource_type: str, *args: Any, **kwargs: Any + ) -> Response: from integrations.gitlab.models import GitLabConfiguration try: @@ -127,12 +136,16 @@ def _create_gitlab_resource(self, request: Any, feature: Any, resource_type: str if resource_type == "GITLAB_MR": pattern = r"https?://[^/]+/([^/]+(?:/[^/]+)*)/-/merge_requests/(\d+)$" else: - pattern = r"https?://[^/]+/([^/]+(?:/[^/]+)*)/-/(?:issues|work_items)/(\d+)$" + pattern = ( + r"https?://[^/]+/([^/]+(?:/[^/]+)*)/-/(?:issues|work_items)/(\d+)$" + ) url_match = re.search(pattern, url) if url_match: _project_path, resource_iid = url_match.groups() - api_resource_type = "merge_requests" if resource_type == "GITLAB_MR" else "issues" + api_resource_type = ( + "merge_requests" if resource_type == "GITLAB_MR" else "issues" + ) if gitlab_config.tagging_enabled and gitlab_config.gitlab_project_id: label_gitlab_issue_mr( instance_url=gitlab_config.gitlab_instance_url, @@ -149,7 +162,9 @@ def _create_gitlab_resource(self, request: Any, feature: Any, resource_type: str status=status.HTTP_400_BAD_REQUEST, ) - def _create_github_resource(self, request: Any, feature: Any, resource_type: str, *args: Any, **kwargs: Any) -> Response: + def _create_github_resource( + self, request: Any, feature: Any, resource_type: str, *args: Any, **kwargs: Any + ) -> Response: github_configuration = ( Organisation.objects.prefetch_related("github_config") .get(id=feature.project.organisation_id) @@ -212,10 +227,14 @@ def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def] # Handle GitLab resources if resource_type in ("GITLAB_MR", "GITLAB_ISSUE"): - return self._create_gitlab_resource(request, feature, resource_type, *args, **kwargs) + return self._create_gitlab_resource( + request, feature, resource_type, *args, **kwargs + ) # Handle GitHub resources - return self._create_github_resource(request, feature, resource_type, *args, **kwargs) + return self._create_github_resource( + request, feature, resource_type, *args, **kwargs + ) def perform_update(self, serializer): # type: ignore[no-untyped-def] external_resource_id = int(self.kwargs["pk"]) diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py index 004e0afb2862..de58a12a3d75 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_gitlab.py @@ -270,9 +270,7 @@ def test_tag_feature_per_gitlab_event__work_items_url_variant__finds_feature( mocker.patch( "features.feature_external_resources.models.FeatureExternalResource._handle_gitlab_after_save" ) - work_items_url = ( - "https://gitlab.example.com/testgroup/testrepo/-/work_items/5" - ) + work_items_url = "https://gitlab.example.com/testgroup/testrepo/-/work_items/5" FeatureExternalResource.objects.create( url=work_items_url, type="GITLAB_ISSUE", @@ -365,9 +363,7 @@ def test_handle_gitlab_webhook_event__merge_request__calls_tag_feature( mocker: MockerFixture, ) -> None: # Given - mock_tag = mocker.patch( - "integrations.gitlab.gitlab.tag_feature_per_gitlab_event" - ) + mock_tag = mocker.patch("integrations.gitlab.gitlab.tag_feature_per_gitlab_event") payload = { "object_attributes": { "action": "merge", @@ -401,9 +397,7 @@ def test_handle_gitlab_webhook_event__issue__calls_tag_feature( mocker: MockerFixture, ) -> None: # Given - mock_tag = mocker.patch( - "integrations.gitlab.gitlab.tag_feature_per_gitlab_event" - ) + mock_tag = mocker.patch("integrations.gitlab.gitlab.tag_feature_per_gitlab_event") payload = { "object_attributes": { "action": "close", @@ -430,9 +424,7 @@ def test_handle_gitlab_webhook_event__unknown_event__does_nothing( mocker: MockerFixture, ) -> None: # Given - mock_tag = mocker.patch( - "integrations.gitlab.gitlab.tag_feature_per_gitlab_event" - ) + mock_tag = mocker.patch("integrations.gitlab.gitlab.tag_feature_per_gitlab_event") # When handle_gitlab_webhook_event(event_type="push", payload={}) @@ -478,7 +470,9 @@ def test_generate_body_comment__resource_removed__returns_unlinked_text() -> Non assert result == UNLINKED_FEATURE_TEXT % "my_flag" -def test_generate_body_comment__segment_override_deleted__returns_segment_deleted_text() -> None: +def test_generate_body_comment__segment_override_deleted__returns_segment_deleted_text() -> ( + None +): # Given event_type = GitLabEventType.SEGMENT_OVERRIDE_DELETED.value @@ -648,7 +642,9 @@ def test_generate_data__feature_state_with_value__includes_feature_state_value( project=gitlab_configuration.project, ) feature_state = FeatureState.objects.filter( - feature=feature_with_val, identity__isnull=True, environment=environment, + feature=feature_with_val, + identity__isnull=True, + environment=environment, ).first() assert feature_state is not None @@ -806,14 +802,18 @@ def test_paginated_query_params__page_less_than_1__raises_value_error() -> None: def test_paginated_query_params__page_size_too_large__raises_value_error() -> None: # Given / When # Then - with pytest.raises(ValueError, match="Page size must be an integer between 1 and 100"): + with pytest.raises( + ValueError, match="Page size must be an integer between 1 and 100" + ): PaginatedQueryParams(page=1, page_size=101) def test_paginated_query_params__page_size_less_than_1__raises_value_error() -> None: # Given / When # Then - with pytest.raises(ValueError, match="Page size must be an integer between 1 and 100"): + with pytest.raises( + ValueError, match="Page size must be an integer between 1 and 100" + ): PaginatedQueryParams(page=1, page_size=0) @@ -822,7 +822,9 @@ def test_paginated_query_params__page_size_less_than_1__raises_value_error() -> # --------------------------------------------------------------- -def test_resolve_resource_urls_for_event__flag_updated__returns_all_resource_urls() -> None: +def test_resolve_resource_urls_for_event__flag_updated__returns_all_resource_urls() -> ( + None +): # Given gitlab_data = GitLabData( gitlab_instance_url="https://gitlab.example.com", @@ -835,8 +837,14 @@ def test_resolve_resource_urls_for_event__flag_updated__returns_all_resource_url event_type=GitLabEventType.FLAG_UPDATED.value, gitlab_data=gitlab_data, feature_external_resources=[ - {"type": "GITLAB_ISSUE", "url": "https://gitlab.example.com/group/project/-/issues/1"}, - {"type": "GITLAB_MR", "url": "https://gitlab.example.com/group/project/-/merge_requests/2"}, + { + "type": "GITLAB_ISSUE", + "url": "https://gitlab.example.com/group/project/-/issues/1", + }, + { + "type": "GITLAB_MR", + "url": "https://gitlab.example.com/group/project/-/merge_requests/2", + }, ], ) @@ -849,7 +857,9 @@ def test_resolve_resource_urls_for_event__flag_updated__returns_all_resource_url assert "merge_requests/2" in result[1] -def test_resolve_resource_urls_for_event__flag_deleted__returns_all_resource_urls() -> None: +def test_resolve_resource_urls_for_event__flag_deleted__returns_all_resource_urls() -> ( + None +): # Given gitlab_data = GitLabData( gitlab_instance_url="https://gitlab.example.com", @@ -862,7 +872,10 @@ def test_resolve_resource_urls_for_event__flag_deleted__returns_all_resource_url event_type=GitLabEventType.FLAG_DELETED.value, gitlab_data=gitlab_data, feature_external_resources=[ - {"type": "GITLAB_ISSUE", "url": "https://gitlab.example.com/group/project/-/issues/1"}, + { + "type": "GITLAB_ISSUE", + "url": "https://gitlab.example.com/group/project/-/issues/1", + }, ], ) @@ -873,7 +886,9 @@ def test_resolve_resource_urls_for_event__flag_deleted__returns_all_resource_url assert len(result) == 1 -def test_resolve_resource_urls_for_event__resource_removed_with_url__returns_url() -> None: +def test_resolve_resource_urls_for_event__resource_removed_with_url__returns_url() -> ( + None +): # Given gitlab_data = GitLabData( gitlab_instance_url="https://gitlab.example.com", @@ -896,7 +911,9 @@ def test_resolve_resource_urls_for_event__resource_removed_with_url__returns_url assert result == ["https://gitlab.example.com/group/project/-/issues/5"] -def test_resolve_resource_urls_for_event__resource_removed_no_url__returns_empty() -> None: +def test_resolve_resource_urls_for_event__resource_removed_no_url__returns_empty() -> ( + None +): # Given gitlab_data = GitLabData( gitlab_instance_url="https://gitlab.example.com", @@ -932,8 +949,14 @@ def test_resolve_resource_urls_for_event__default_case__returns_last_resource() event_type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value, gitlab_data=gitlab_data, feature_external_resources=[ - {"type": "GITLAB_ISSUE", "url": "https://gitlab.example.com/group/project/-/issues/1"}, - {"type": "GITLAB_MR", "url": "https://gitlab.example.com/group/project/-/merge_requests/2"}, + { + "type": "GITLAB_ISSUE", + "url": "https://gitlab.example.com/group/project/-/issues/1", + }, + { + "type": "GITLAB_MR", + "url": "https://gitlab.example.com/group/project/-/merge_requests/2", + }, ], ) @@ -945,7 +968,9 @@ def test_resolve_resource_urls_for_event__default_case__returns_last_resource() assert "merge_requests/2" in result[0] -def test_resolve_resource_urls_for_event__default_empty_resources__returns_empty() -> None: +def test_resolve_resource_urls_for_event__default_empty_resources__returns_empty() -> ( + None +): # Given gitlab_data = GitLabData( gitlab_instance_url="https://gitlab.example.com", @@ -978,9 +1003,7 @@ def test_post_to_resource__mr_url__posts_to_merge_requests( mocker: MockerFixture, ) -> None: # Given - mock_post_comment = mocker.patch( - "integrations.gitlab.tasks.post_comment_to_gitlab" - ) + mock_post_comment = mocker.patch("integrations.gitlab.tasks.post_comment_to_gitlab") resource_url = "https://gitlab.example.com/testgroup/testrepo/-/merge_requests/3" # When @@ -1008,9 +1031,7 @@ def test_post_to_resource__issue_url__posts_to_issues( mocker: MockerFixture, ) -> None: # Given - mock_post_comment = mocker.patch( - "integrations.gitlab.tasks.post_comment_to_gitlab" - ) + mock_post_comment = mocker.patch("integrations.gitlab.tasks.post_comment_to_gitlab") resource_url = "https://gitlab.example.com/testgroup/testrepo/-/issues/7" # When @@ -1038,9 +1059,7 @@ def test_post_to_resource__work_items_url__posts_to_issues( mocker: MockerFixture, ) -> None: # Given - mock_post_comment = mocker.patch( - "integrations.gitlab.tasks.post_comment_to_gitlab" - ) + mock_post_comment = mocker.patch("integrations.gitlab.tasks.post_comment_to_gitlab") resource_url = "https://gitlab.example.com/testgroup/testrepo/-/work_items/7" # When @@ -1067,9 +1086,7 @@ def test_post_to_resource__unknown_url_format__does_not_post( mocker: MockerFixture, ) -> None: # Given - mock_post_comment = mocker.patch( - "integrations.gitlab.tasks.post_comment_to_gitlab" - ) + mock_post_comment = mocker.patch("integrations.gitlab.tasks.post_comment_to_gitlab") resource_url = "https://gitlab.example.com/testgroup/testrepo/-/pipelines/1" # When @@ -1089,9 +1106,7 @@ def test_post_to_resource__missing_config__does_not_post( mocker: MockerFixture, ) -> None: # Given - mock_post_comment = mocker.patch( - "integrations.gitlab.tasks.post_comment_to_gitlab" - ) + mock_post_comment = mocker.patch("integrations.gitlab.tasks.post_comment_to_gitlab") resource_url = "https://gitlab.example.com/nonexistent/project/-/issues/1" # When @@ -1110,9 +1125,7 @@ def test_post_to_resource__no_iid_in_url__returns_early( mocker: MockerFixture, ) -> None: # Given — URL has /-/issues/ but no digits after it - mock_post_comment = mocker.patch( - "integrations.gitlab.tasks.post_comment_to_gitlab" - ) + mock_post_comment = mocker.patch("integrations.gitlab.tasks.post_comment_to_gitlab") resource_url = "https://gitlab.example.com/group/project/-/issues/notanumber" # When @@ -1131,9 +1144,7 @@ def test_post_to_resource__no_project_path_match__returns_early( mocker: MockerFixture, ) -> None: # Given — URL path starts directly with /-/ with no project path before it - mock_post_comment = mocker.patch( - "integrations.gitlab.tasks.post_comment_to_gitlab" - ) + mock_post_comment = mocker.patch("integrations.gitlab.tasks.post_comment_to_gitlab") resource_url = "https://gitlab.example.com/-/issues/1" # When @@ -1159,9 +1170,7 @@ def test_send_post_request__with_resources__calls_post_to_resource( mocker: MockerFixture, ) -> None: # Given - mock_post_to_resource = mocker.patch( - "integrations.gitlab.tasks._post_to_resource" - ) + mock_post_to_resource = mocker.patch("integrations.gitlab.tasks._post_to_resource") gitlab_data = GitLabData( gitlab_instance_url="https://gitlab.example.com", access_token="test-token", @@ -1175,7 +1184,10 @@ def test_send_post_request__with_resources__calls_post_to_resource( event_type=GitLabEventType.FLAG_UPDATED.value, gitlab_data=gitlab_data, feature_external_resources=[ - {"type": "GITLAB_ISSUE", "url": "https://gitlab.example.com/testgroup/testrepo/-/issues/1"}, + { + "type": "GITLAB_ISSUE", + "url": "https://gitlab.example.com/testgroup/testrepo/-/issues/1", + }, ], ) @@ -1257,7 +1269,9 @@ def test_call_gitlab_app_webhook_for_feature_state__resource_removed__posts_with # Then mock_send.assert_called_once() call_args = mock_send.call_args[0][0] - assert call_args.event_type == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value + assert ( + call_args.event_type == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value + ) assert call_args.feature_external_resources == [] @@ -1528,9 +1542,7 @@ def test_feature_segment_create_github_comment__with_gitlab_config__calls_gitlab metadata='{"state": "opened"}', ) - segment = Segment.objects.create( - name="test_segment", project=feature.project - ) + segment = Segment.objects.create(name="test_segment", project=feature.project) feature_segment = FeatureSegment.objects.create( feature=feature, segment=segment, @@ -1644,9 +1656,7 @@ def test_post_to_resource__issue_url__extracts_iid_and_project_path( mocker: MockerFixture, ) -> None: # Given - mock_post_comment = mocker.patch( - "integrations.gitlab.tasks.post_comment_to_gitlab" - ) + mock_post_comment = mocker.patch("integrations.gitlab.tasks.post_comment_to_gitlab") # testgroup/testrepo matches the gitlab_configuration fixture's project_name resource_url = "https://gitlab.example.com/testgroup/testrepo/-/issues/42"