From 48dc94cd148595f3b33d1743ba3ed6efff9959b9 Mon Sep 17 00:00:00 2001 From: "Asaph M. Kotzin" Date: Fri, 3 Apr 2026 14:30:07 +0100 Subject: [PATCH 01/15] feat(gitlab): add webhook handling, tagging, and shared VCS module Add webhook endpoint, event-driven tagging, async comment posting, and extract shared VCS comment generation from the GitHub integration. Key architectural improvements over #7028: - Credentials never passed through task queue (security) - Business logic in services.py, data mapping in mappers.py - Thin task entry point fetches config from DB - Shared VCS module for comment generation (GitHub refactored too) Co-Authored-By: Claude Opus 4.6 (1M context) --- api/api/urls/v1.py | 7 + api/integrations/github/github.py | 60 +---- api/integrations/gitlab/constants.py | 8 + api/integrations/gitlab/mappers.py | 44 +++ api/integrations/gitlab/services.py | 159 +++++++++++ api/integrations/gitlab/tasks.py | 122 +++++++++ api/integrations/gitlab/views.py | 42 ++- api/integrations/vcs/__init__.py | 0 api/integrations/vcs/comments.py | 74 +++++ api/integrations/vcs/constants.py | 12 + api/integrations/vcs/helpers.py | 26 ++ .../gitlab/test_unit_gitlab_services.py | 252 ++++++++++++++++++ 12 files changed, 756 insertions(+), 50 deletions(-) create mode 100644 api/integrations/gitlab/mappers.py create mode 100644 api/integrations/gitlab/services.py create mode 100644 api/integrations/gitlab/tasks.py create mode 100644 api/integrations/vcs/__init__.py create mode 100644 api/integrations/vcs/comments.py create mode 100644 api/integrations/vcs/constants.py create mode 100644 api/integrations/vcs/helpers.py create mode 100644 api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py 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/integrations/github/github.py b/api/integrations/github/github.py index bc8172e3b6d4..1c9f2f9696de 100644 --- a/api/integrations/github/github.py +++ b/api/integrations/github/github.py @@ -130,57 +130,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/constants.py b/api/integrations/gitlab/constants.py index 8809e70c33b0..b4da986b5f24 100644 --- a/api/integrations/gitlab/constants.py +++ b/api/integrations/gitlab/constants.py @@ -20,6 +20,14 @@ class GitLabTag(Enum): ISSUE_CLOSED = "Issue Closed" +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" + + gitlab_tag_description: dict[str, str] = { GitLabTag.MR_OPEN.value: "This feature has a linked MR open", GitLabTag.MR_MERGED.value: "This feature has a linked MR merged", diff --git a/api/integrations/gitlab/mappers.py b/api/integrations/gitlab/mappers.py new file mode 100644 index 000000000000..a9d9c147f9b3 --- /dev/null +++ b/api/integrations/gitlab/mappers.py @@ -0,0 +1,44 @@ +from typing import Any + +from django.utils.formats import get_format + +from features.models import FeatureState +from integrations.gitlab.constants import GitLabEventType + + +def map_feature_states_to_dicts( + feature_states: list[FeatureState], + event_type: str, +) -> list[dict[str, Any]]: + """Map FeatureState objects to dicts suitable for comment generation. + + Used by both GitHub and GitLab integrations. + """ + result: list[dict[str, Any]] = [] + + for feature_state in feature_states: + feature_state_value = feature_state.get_feature_state_value() + env_data: dict[str, Any] = {} + + if feature_state_value is not None: + env_data["feature_state_value"] = feature_state_value + + if event_type != GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value: + env_data["environment_name"] = feature_state.environment.name # type: ignore[union-attr] + env_data["enabled"] = feature_state.enabled + env_data["last_updated"] = feature_state.updated_at.strftime( + get_format("DATETIME_INPUT_FORMATS")[0] + ) + 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 + ): + env_data["segment_name"] = feature_state.feature_segment.segment.name + + result.append(env_data) + + return result diff --git a/api/integrations/gitlab/services.py b/api/integrations/gitlab/services.py new file mode 100644 index 000000000000..7ea578f380fd --- /dev/null +++ b/api/integrations/gitlab/services.py @@ -0,0 +1,159 @@ +import logging +from typing import Any + +from django.db.models import Q + +from features.models import Feature, FeatureState +from integrations.gitlab.constants import ( + GITLAB_TAG_COLOUR, + GitLabEventType, + GitLabTag, + gitlab_tag_description, +) +from integrations.gitlab.mappers import map_feature_states_to_dicts +from integrations.gitlab.models import GitLabConfiguration +from projects.tags.models import Tag, TagType + +logger = logging.getLogger(__name__) + +_tag_by_event_type: dict[str, dict[str, GitLabTag | None]] = { + "merge_request": { + "close": GitLabTag.MR_CLOSED, + "merge": GitLabTag.MR_MERGED, + "open": GitLabTag.MR_OPEN, + "reopen": GitLabTag.MR_OPEN, + "update": None, + }, + "issue": { + "close": GitLabTag.ISSUE_CLOSED, + "open": GitLabTag.ISSUE_OPEN, + "reopen": GitLabTag.ISSUE_OPEN, + }, +} + + +def get_tag_for_event( + event_type: str, + action: str, + metadata: dict[str, Any], +) -> GitLabTag | None: + """Return the tag for a GitLab webhook 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 + return None + + event_actions = _tag_by_event_type.get(event_type, {}) + return event_actions.get(action) + + +def tag_feature_per_gitlab_event( + event_type: str, + action: str, + metadata: dict[str, Any], + project_path: str, +) -> None: + """Apply a tag to a feature based on a GitLab webhook event.""" + web_url = metadata.get("web_url", "") + + # GitLab webhooks send /-/issues/N but stored URL might be /-/work_items/N + 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 + + try: + gitlab_config = GitLabConfiguration.objects.get( + project=feature.project, + project_name=project_path, + deleted_at__isnull=True, + ) + except GitLabConfiguration.DoesNotExist: + return + + if not gitlab_config.tagging_enabled: + return + + tag_enum = get_tag_for_event(event_type, action, metadata) + if tag_enum is None: + return + + gitlab_tag, _ = Tag.objects.get_or_create( + color=GITLAB_TAG_COLOUR, + description=gitlab_tag_description[tag_enum.value], + label=tag_enum.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() + + +def handle_gitlab_webhook_event(event_type: str, payload: dict[str, Any]) -> None: + """Process a GitLab webhook payload and apply tags.""" + attrs = payload.get("object_attributes", {}) + action = attrs.get("action", "") + project_path = payload.get("project", {}).get("path_with_namespace", "") + + metadata: dict[str, Any] = {"web_url": attrs.get("url", "")} + if event_type == "merge_request": + metadata["draft"] = attrs.get("work_in_progress", False) + metadata["merged"] = attrs.get("state") == "merged" + + tag_feature_per_gitlab_event(event_type, action, metadata, project_path) + + +def dispatch_gitlab_comment( + project_id: int, + event_type: str, + feature: Feature, + feature_states: list[FeatureState] | None = None, + url: str | None = None, + segment_name: str | None = None, +) -> None: + """Dispatch an async task to post a comment to linked GitLab resources. + + Does NOT pass credentials through the task queue — only the project_id. + The task handler fetches the GitLabConfiguration from the DB. + """ + from integrations.gitlab.tasks import post_gitlab_comment + + feature_states_data = ( + map_feature_states_to_dicts(feature_states, event_type) + if feature_states + else [] + ) + + post_gitlab_comment.delay( + kwargs={ + "project_id": project_id, + "feature_id": feature.id, + "feature_name": feature.name, + "event_type": event_type, + "feature_states": feature_states_data, + "url": url if event_type == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value else None, + "segment_name": segment_name, + }, + ) diff --git a/api/integrations/gitlab/tasks.py b/api/integrations/gitlab/tasks.py new file mode 100644 index 000000000000..ae490e7df601 --- /dev/null +++ b/api/integrations/gitlab/tasks.py @@ -0,0 +1,122 @@ +import logging +import re +from typing import Any +from urllib.parse import urlparse + +from task_processor.decorators import register_task_handler + +from integrations.gitlab.client import post_comment_to_gitlab +from integrations.gitlab.constants import GitLabEventType +from integrations.gitlab.types import GitLabResourceEndpoint + +logger = logging.getLogger(__name__) + +UNLINKED_FEATURE_TEXT = "**The feature flag `%s` was unlinked from the issue/MR**" + + +def _parse_resource_url(resource_url: str) -> tuple[str, GitLabResourceEndpoint, int] | None: + """Parse a GitLab resource URL into (project_path, resource_type, iid). + + Returns None if the URL format is not recognised. + """ + parsed = urlparse(resource_url) + path = parsed.path + + if "/-/merge_requests/" in path: + resource_type: GitLabResourceEndpoint = "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: + return None + + if not iid_match: + return None + + project_path_match = re.search(r"^/([^/]+(?:/[^/]+)*)/-/", path) + if not project_path_match: + return None + + return project_path_match.group(1), resource_type, int(iid_match.group(1)) + + +@register_task_handler() +def post_gitlab_comment( + project_id: int, + feature_id: int, + feature_name: str, + event_type: str, + feature_states: list[dict[str, Any]], + url: str | None = None, + segment_name: str | None = None, +) -> None: + """Post a comment to linked GitLab resources when a feature changes. + + Fetches credentials from the DB — they are never passed through the + task queue. + """ + from features.feature_external_resources.models import ( + FeatureExternalResource, + ResourceType, + ) + from integrations.gitlab.models import GitLabConfiguration + from integrations.vcs.comments import generate_body_comment + + try: + gitlab_config = GitLabConfiguration.objects.get( + project_id=project_id, + deleted_at__isnull=True, + ) + except GitLabConfiguration.DoesNotExist: + logger.warning( + "No GitLabConfiguration found for project_id=%s", project_id + ) + return + + if not gitlab_config.gitlab_project_id: + return + + body = generate_body_comment( + name=feature_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, + ) + + # Determine which resource URLs to post to + if event_type == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value: + resource_urls = [url] if url else [] + else: + resources = FeatureExternalResource.objects.filter( + feature_id=feature_id, + type__in=[ResourceType.GITLAB_ISSUE, ResourceType.GITLAB_MR], + ) + resource_urls = [r.url for r in resources] + + if not resource_urls: + logger.debug( + "No GitLab resources linked to feature_id=%s, skipping comment.", + feature_id, + ) + return + + for resource_url in resource_urls: + parsed = _parse_resource_url(resource_url) + if not parsed: + logger.warning("Could not parse GitLab resource URL: %s", resource_url) + continue + + _project_path, resource_type, resource_iid = parsed + + post_comment_to_gitlab( + instance_url=gitlab_config.gitlab_instance_url, + access_token=gitlab_config.access_token, + gitlab_project_id=gitlab_config.gitlab_project_id, + resource_type=resource_type, + resource_iid=resource_iid, + body=body, + ) diff --git a/api/integrations/gitlab/views.py b/api/integrations/gitlab/views.py index 32ae7964dbf0..87c9978b1dcb 100644 --- a/api/integrations/gitlab/views.py +++ b/api/integrations/gitlab/views.py @@ -1,3 +1,4 @@ +import json import logging from functools import wraps from typing import Any, Callable @@ -5,7 +6,7 @@ import requests from rest_framework import status, viewsets from rest_framework.decorators import api_view, permission_classes -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response @@ -211,3 +212,42 @@ def fetch_project_members(request: Request, project_pk: int) -> Response: params=ProjectQueryParams(**query_serializer.validated_data), ) return Response(data=data, status=status.HTTP_200_OK) + + +# GitLab webhook event type mapping. +# See: https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html +_GITLAB_EVENT_TYPE_MAP: dict[str, str] = { + "Merge Request Hook": "merge_request", + "Issue Hook": "issue", +} + + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def gitlab_webhook(request: Request, project_pk: int) -> Response: + """Receive GitLab webhook events and apply tags to linked features.""" + 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=status.HTTP_404_NOT_FOUND, + ) + + if not gitlab_token or gitlab_token != gitlab_config.webhook_secret: + return Response({"error": "Invalid token"}, status=status.HTTP_400_BAD_REQUEST) + + event_type = _GITLAB_EVENT_TYPE_MAP.get(gitlab_event or "") + if not event_type: + return Response({"detail": "Event bypassed"}, status=status.HTTP_200_OK) + + from integrations.gitlab.services import handle_gitlab_webhook_event + + payload = json.loads(request.body.decode("utf-8")) + handle_gitlab_webhook_event(event_type=event_type, payload=payload) + return Response({"detail": "Event processed"}, status=status.HTTP_200_OK) 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..0190429946f7 --- /dev/null +++ b/api/integrations/vcs/comments.py @@ -0,0 +1,74 @@ +from typing import Any + +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, 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. + + Shared between GitHub and GitLab integrations. The only difference + is 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) + + if event_type == "FLAG_UPDATED": + result = UPDATED_FEATURE_TEXT % name + else: + result = LINK_FEATURE_TITLE % name + + last_segment_name = "" + if feature_states 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 + result += FEATURE_TABLE_ROW % ( + fs["environment_name"], + environment_link_url, + "\u2705 Enabled" if fs["enabled"] else "\u274c Disabled", + f"`{feature_value}`" if feature_value else "", + fs["last_updated"], + ) + + 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/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 diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py new file mode 100644 index 000000000000..72114c7601c3 --- /dev/null +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py @@ -0,0 +1,252 @@ +import json + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from features.feature_external_resources.models import ( + FeatureExternalResource, + ResourceType, +) +from features.models import Feature +from integrations.gitlab.constants import GitLabTag +from integrations.gitlab.models import GitLabConfiguration +from integrations.gitlab.services import get_tag_for_event, handle_gitlab_webhook_event +from projects.models import Project +from projects.tags.models import TagType + + +@pytest.mark.parametrize( + "event_type,action,metadata,expected_tag", + [ + ("merge_request", "close", {}, GitLabTag.MR_CLOSED), + ("merge_request", "merge", {}, GitLabTag.MR_MERGED), + ("merge_request", "open", {}, GitLabTag.MR_OPEN), + ("merge_request", "reopen", {}, GitLabTag.MR_OPEN), + ("merge_request", "update", {"draft": True}, GitLabTag.MR_DRAFT), + ("merge_request", "update", {"draft": False}, None), + ("merge_request", "update", {}, None), + ("issue", "close", {}, GitLabTag.ISSUE_CLOSED), + ("issue", "open", {}, GitLabTag.ISSUE_OPEN), + ("issue", "reopen", {}, GitLabTag.ISSUE_OPEN), + ("issue", "unknown_action", {}, None), + ("unknown_event", "open", {}, None), + ], +) +def test_get_tag_for_event__returns_correct_tag( + event_type: str, + action: str, + metadata: dict[str, object], + expected_tag: GitLabTag | None, +) -> None: + # When + result = get_tag_for_event(event_type, action, metadata) + + # Then + assert result == expected_tag + + +@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]) + 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, + }, + } + ) + + # 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": "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="{}", + content_type="application/json", + **{"HTTP_X_GITLAB_TOKEN": "wrong-secret", "HTTP_X_GITLAB_EVENT": "Merge Request Hook"}, # type: ignore[arg-type] + ) + + # 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_config__returns_404( + project: Project, +) -> None: + # Given + client = APIClient() + url = reverse("api-v1:gitlab-webhook", args=[project.id]) + + # When + response = client.post( + url, + data="{}", + content_type="application/json", + **{"HTTP_X_GITLAB_TOKEN": "some-secret", "HTTP_X_GITLAB_EVENT": "Merge Request Hook"}, # type: ignore[arg-type] + ) + + # Then + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@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]) + + # When + response = client.post( + url, + data="{}", + 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_tag_feature_per_gitlab_event__matching_feature__adds_tag( + project: Project, + feature: Feature, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/merge_requests/1", + type=ResourceType.GITLAB_MR, + feature=feature, + metadata='{"state": "opened"}', + ) + payload = { + "object_kind": "merge_request", + "project": {"path_with_namespace": "testgroup/testrepo"}, + "object_attributes": { + "action": "merge", + "url": "https://gitlab.example.com/testgroup/testrepo/-/merge_requests/1", + "state": "merged", + "work_in_progress": False, + }, + } + + # When + handle_gitlab_webhook_event(event_type="merge_request", payload=payload) + + # Then + feature.refresh_from_db() + gitlab_tags = feature.tags.filter(type=TagType.GITLAB.value) + assert gitlab_tags.count() == 1 + assert gitlab_tags.first().label == GitLabTag.MR_MERGED.value # type: ignore[union-attr] + + +@pytest.mark.django_db +def test_tag_feature_per_gitlab_event__tagging_disabled__does_not_add_tag( + project: Project, + feature: Feature, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + gitlab_configuration.tagging_enabled = False + gitlab_configuration.save() + + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/issues/1", + type=ResourceType.GITLAB_ISSUE, + feature=feature, + metadata='{"state": "opened"}', + ) + payload = { + "object_kind": "issue", + "project": {"path_with_namespace": "testgroup/testrepo"}, + "object_attributes": { + "action": "close", + "url": "https://gitlab.example.com/testgroup/testrepo/-/issues/1", + }, + } + + # When + handle_gitlab_webhook_event(event_type="issue", payload=payload) + + # Then + feature.refresh_from_db() + assert feature.tags.filter(type=TagType.GITLAB.value).count() == 0 + + +@pytest.mark.django_db +def test_tag_feature_per_gitlab_event__work_items_url_variant__finds_feature( + project: Project, + feature: Feature, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given — resource stored as work_items URL + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/work_items/5", + type=ResourceType.GITLAB_ISSUE, + feature=feature, + metadata='{"state": "opened"}', + ) + # Webhook sends issues URL + payload = { + "object_kind": "issue", + "project": {"path_with_namespace": "testgroup/testrepo"}, + "object_attributes": { + "action": "close", + "url": "https://gitlab.example.com/testgroup/testrepo/-/issues/5", + }, + } + + # When + handle_gitlab_webhook_event(event_type="issue", payload=payload) + + # Then + feature.refresh_from_db() + gitlab_tags = feature.tags.filter(type=TagType.GITLAB.value) + assert gitlab_tags.count() == 1 + assert gitlab_tags.first().label == GitLabTag.ISSUE_CLOSED.value # type: ignore[union-attr] From 5977a4a251b784ed76fe470d00f2c5e2aa149cf0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:12:09 +0000 Subject: [PATCH 02/15] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/integrations/github/github.py | 9 --------- api/integrations/gitlab/mappers.py | 4 +--- api/integrations/gitlab/services.py | 4 +++- api/integrations/gitlab/tasks.py | 8 ++++---- .../integrations/gitlab/test_unit_gitlab_services.py | 10 ++++++++-- 5 files changed, 16 insertions(+), 19 deletions(-) diff --git a/api/integrations/github/github.py b/api/integrations/github/github.py index 1c9f2f9696de..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, diff --git a/api/integrations/gitlab/mappers.py b/api/integrations/gitlab/mappers.py index a9d9c147f9b3..e6cf08e172e0 100644 --- a/api/integrations/gitlab/mappers.py +++ b/api/integrations/gitlab/mappers.py @@ -29,9 +29,7 @@ def map_feature_states_to_dicts( env_data["last_updated"] = feature_state.updated_at.strftime( get_format("DATETIME_INPUT_FORMATS")[0] ) - env_data["environment_api_key"] = ( - feature_state.environment.api_key # type: ignore[union-attr] - ) + env_data["environment_api_key"] = feature_state.environment.api_key # type: ignore[union-attr] if ( hasattr(feature_state, "feature_segment") diff --git a/api/integrations/gitlab/services.py b/api/integrations/gitlab/services.py index 7ea578f380fd..9b50a1d3d750 100644 --- a/api/integrations/gitlab/services.py +++ b/api/integrations/gitlab/services.py @@ -153,7 +153,9 @@ def dispatch_gitlab_comment( "feature_name": feature.name, "event_type": event_type, "feature_states": feature_states_data, - "url": url if event_type == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value else None, + "url": url + if event_type == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value + else None, "segment_name": segment_name, }, ) diff --git a/api/integrations/gitlab/tasks.py b/api/integrations/gitlab/tasks.py index ae490e7df601..ebbb845cb4fd 100644 --- a/api/integrations/gitlab/tasks.py +++ b/api/integrations/gitlab/tasks.py @@ -14,7 +14,9 @@ UNLINKED_FEATURE_TEXT = "**The feature flag `%s` was unlinked from the issue/MR**" -def _parse_resource_url(resource_url: str) -> tuple[str, GitLabResourceEndpoint, int] | None: +def _parse_resource_url( + resource_url: str, +) -> tuple[str, GitLabResourceEndpoint, int] | None: """Parse a GitLab resource URL into (project_path, resource_type, iid). Returns None if the URL format is not recognised. @@ -69,9 +71,7 @@ def post_gitlab_comment( deleted_at__isnull=True, ) except GitLabConfiguration.DoesNotExist: - logger.warning( - "No GitLabConfiguration found for project_id=%s", project_id - ) + logger.warning("No GitLabConfiguration found for project_id=%s", project_id) return if not gitlab_config.gitlab_project_id: diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py index 72114c7601c3..6057995c2165 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py @@ -99,7 +99,10 @@ def test_gitlab_webhook__invalid_token__returns_400( url, data="{}", content_type="application/json", - **{"HTTP_X_GITLAB_TOKEN": "wrong-secret", "HTTP_X_GITLAB_EVENT": "Merge Request Hook"}, # type: ignore[arg-type] + **{ + "HTTP_X_GITLAB_TOKEN": "wrong-secret", + "HTTP_X_GITLAB_EVENT": "Merge Request Hook", + }, # type: ignore[arg-type] ) # Then @@ -120,7 +123,10 @@ def test_gitlab_webhook__missing_config__returns_404( url, data="{}", content_type="application/json", - **{"HTTP_X_GITLAB_TOKEN": "some-secret", "HTTP_X_GITLAB_EVENT": "Merge Request Hook"}, # type: ignore[arg-type] + **{ + "HTTP_X_GITLAB_TOKEN": "some-secret", + "HTTP_X_GITLAB_EVENT": "Merge Request Hook", + }, # type: ignore[arg-type] ) # Then From ea06ad948d8ab1345c595473bd9d79fee694da3a Mon Sep 17 00:00:00 2001 From: "Asaph M. Kotzin" Date: Mon, 6 Apr 2026 11:35:34 +0100 Subject: [PATCH 03/15] test(gitlab): add tests for tasks, mappers, and shared VCS modules Cover post_gitlab_comment task, _parse_resource_url, feature state mapping, shared comment generation, and feature state collection helper to reach 100% diff coverage. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gitlab/test_unit_gitlab_mappers.py | 72 +++++++ .../gitlab/test_unit_gitlab_tasks.py | 179 ++++++++++++++++++ api/tests/unit/integrations/vcs/__init__.py | 0 .../vcs/test_unit_vcs_comments.py | 138 ++++++++++++++ .../integrations/vcs/test_unit_vcs_helpers.py | 43 +++++ 5 files changed, 432 insertions(+) create mode 100644 api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py create mode 100644 api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py create mode 100644 api/tests/unit/integrations/vcs/__init__.py create mode 100644 api/tests/unit/integrations/vcs/test_unit_vcs_comments.py create mode 100644 api/tests/unit/integrations/vcs/test_unit_vcs_helpers.py diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py new file mode 100644 index 000000000000..3cce004398c5 --- /dev/null +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py @@ -0,0 +1,72 @@ +import pytest + +from environments.models import Environment +from features.models import Feature, FeatureState +from integrations.gitlab.constants import GitLabEventType +from integrations.gitlab.mappers import map_feature_states_to_dicts +from projects.models import Project + + +@pytest.mark.django_db +def test_map_feature_states_to_dicts__flag_updated__includes_env_data( + project: Project, + environment: Environment, + feature: Feature, +) -> None: + # Given + feature_state = FeatureState.objects.get( + feature=feature, + environment=environment, + identity__isnull=True, + feature_segment__isnull=True, + ) + + # When + result = map_feature_states_to_dicts( + [feature_state], + GitLabEventType.FLAG_UPDATED.value, + ) + + # Then + assert len(result) == 1 + assert result[0]["environment_name"] == environment.name + assert "enabled" in result[0] + assert "last_updated" in result[0] + assert "environment_api_key" in result[0] + + +@pytest.mark.django_db +def test_map_feature_states_to_dicts__resource_removed__skips_env_data( + project: Project, + environment: Environment, + feature: Feature, +) -> None: + # Given + feature_state = FeatureState.objects.get( + feature=feature, + environment=environment, + identity__isnull=True, + feature_segment__isnull=True, + ) + + # When + result = map_feature_states_to_dicts( + [feature_state], + GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value, + ) + + # Then + assert len(result) == 1 + assert "environment_name" not in result[0] + assert "enabled" not in result[0] + + +@pytest.mark.django_db +def test_map_feature_states_to_dicts__empty_list__returns_empty( +) -> None: + # Given + # When + result = map_feature_states_to_dicts([], GitLabEventType.FLAG_UPDATED.value) + + # Then + assert result == [] diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py new file mode 100644 index 000000000000..988fc7ae677c --- /dev/null +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py @@ -0,0 +1,179 @@ +import pytest +import responses + +from features.feature_external_resources.models import ( + FeatureExternalResource, + ResourceType, +) +from features.models import Feature +from integrations.gitlab.constants import GitLabEventType +from integrations.gitlab.models import GitLabConfiguration +from integrations.gitlab.tasks import _parse_resource_url, post_gitlab_comment +from projects.models import Project + + +@pytest.mark.parametrize( + "url,expected", + [ + ( + "https://gitlab.example.com/group/project/-/merge_requests/42", + ("group/project", "merge_requests", 42), + ), + ( + "https://gitlab.example.com/group/project/-/issues/7", + ("group/project", "issues", 7), + ), + ( + "https://gitlab.example.com/group/project/-/work_items/7", + ("group/project", "issues", 7), + ), + ( + "https://gitlab.example.com/org/sub-group/project/-/merge_requests/1", + ("org/sub-group/project", "merge_requests", 1), + ), + ( + "https://gitlab.example.com/unknown/path/to/resource", + None, + ), + ], + ids=["mr", "issue", "work-item", "nested-group", "unknown-format"], +) +def test_parse_resource_url__returns_correct_tuple( + url: str, + expected: tuple[str, str, int] | None, +) -> None: + # Given + # When + result = _parse_resource_url(url) + + # Then + assert result == expected + + +@pytest.mark.django_db +@responses.activate +def test_post_gitlab_comment__linked_resource__posts_comment( + project: Project, + feature: Feature, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/issues/1", + type=ResourceType.GITLAB_ISSUE, + feature=feature, + metadata='{"state": "opened"}', + ) + responses.add( + responses.POST, + "https://gitlab.example.com/api/v4/projects/1/issues/1/notes", + json={"id": 1, "body": "comment"}, + status=201, + ) + + # When + post_gitlab_comment( + project_id=project.id, + feature_id=feature.id, + feature_name=feature.name, + event_type=GitLabEventType.FLAG_UPDATED.value, + feature_states=[], + ) + + # Then + assert len(responses.calls) == 1 + + +@pytest.mark.django_db +def test_post_gitlab_comment__no_config__returns_early( + project: Project, + feature: Feature, +) -> None: + # Given — no GitLabConfiguration exists + + # When + post_gitlab_comment( + project_id=project.id, + feature_id=feature.id, + feature_name=feature.name, + event_type=GitLabEventType.FLAG_UPDATED.value, + feature_states=[], + ) + + # Then — no error raised, returns early + + +@pytest.mark.django_db +def test_post_gitlab_comment__no_gitlab_project_id__returns_early( + project: Project, + feature: Feature, +) -> None: + # Given + GitLabConfiguration.objects.create( + project=project, + gitlab_instance_url="https://gitlab.example.com", + access_token="token", + webhook_secret="secret", + gitlab_project_id=None, + ) + + # When + post_gitlab_comment( + project_id=project.id, + feature_id=feature.id, + feature_name=feature.name, + event_type=GitLabEventType.FLAG_UPDATED.value, + feature_states=[], + ) + + # Then — no error raised, returns early + + +@pytest.mark.django_db +def test_post_gitlab_comment__no_linked_resources__returns_early( + project: Project, + feature: Feature, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given — no FeatureExternalResource linked + + # When + post_gitlab_comment( + project_id=project.id, + feature_id=feature.id, + feature_name=feature.name, + event_type=GitLabEventType.FLAG_UPDATED.value, + feature_states=[], + ) + + # Then — no error raised, returns early + + +@pytest.mark.django_db +@responses.activate +def test_post_gitlab_comment__resource_removed__posts_to_url( + project: Project, + feature: Feature, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + resource_url = "https://gitlab.example.com/testgroup/testrepo/-/issues/5" + responses.add( + responses.POST, + "https://gitlab.example.com/api/v4/projects/1/issues/5/notes", + json={"id": 1, "body": "unlinked"}, + status=201, + ) + + # When + post_gitlab_comment( + project_id=project.id, + feature_id=feature.id, + feature_name=feature.name, + event_type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value, + feature_states=[], + url=resource_url, + ) + + # Then + assert len(responses.calls) == 1 diff --git a/api/tests/unit/integrations/vcs/__init__.py b/api/tests/unit/integrations/vcs/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/unit/integrations/vcs/test_unit_vcs_comments.py b/api/tests/unit/integrations/vcs/test_unit_vcs_comments.py new file mode 100644 index 000000000000..67513d9dbe42 --- /dev/null +++ b/api/tests/unit/integrations/vcs/test_unit_vcs_comments.py @@ -0,0 +1,138 @@ +import pytest + +from integrations.vcs.comments import generate_body_comment + +pytestmark = pytest.mark.django_db + + +def test_generate_body_comment__flag_deleted__returns_deleted_text() -> None: + # Given + # When + result = generate_body_comment( + name="my_flag", + event_type="FLAG_DELETED", + feature_id=1, + feature_states=[], + unlinked_feature_text="unlinked %s", + ) + + # Then + assert "my_flag" in result + assert "deleted" in result.lower() + + +def test_generate_body_comment__resource_removed__returns_unlinked_text() -> None: + # Given + # When + result = generate_body_comment( + name="my_flag", + event_type="FEATURE_EXTERNAL_RESOURCE_REMOVED", + feature_id=1, + feature_states=[], + unlinked_feature_text="**The flag `%s` was unlinked**", + ) + + # Then + assert result == "**The flag `my_flag` was unlinked**" + + +def test_generate_body_comment__flag_updated__returns_table() -> None: + # Given + feature_states = [ + { + "environment_name": "Production", + "enabled": True, + "feature_state_value": "on", + "last_updated": "2026-01-01", + "environment_api_key": "api-key-123", + }, + ] + + # When + result = generate_body_comment( + name="my_flag", + event_type="FLAG_UPDATED", + feature_id=1, + feature_states=feature_states, + unlinked_feature_text="unlinked %s", + project_id=1, + ) + + # Then + assert "my_flag" in result + assert "Production" in result + assert "Enabled" in result + assert "`on`" in result + + +def test_generate_body_comment__segment_override_deleted__returns_segment_text() -> None: + # Given + # When + result = generate_body_comment( + name="my_flag", + event_type="SEGMENT_OVERRIDE_DELETED", + feature_id=1, + feature_states=[], + unlinked_feature_text="unlinked %s", + segment_name="beta_users", + ) + + # Then + assert "beta_users" in result + assert "my_flag" in result + + +def test_generate_body_comment__resource_added__returns_linked_table() -> None: + # Given + feature_states = [ + { + "environment_name": "Staging", + "enabled": False, + "feature_state_value": None, + "last_updated": "2026-01-01", + "environment_api_key": "api-key-456", + }, + ] + + # When + result = generate_body_comment( + name="my_flag", + event_type="FEATURE_EXTERNAL_RESOURCE_ADDED", + feature_id=1, + feature_states=feature_states, + unlinked_feature_text="unlinked %s", + project_id=1, + ) + + # Then + assert "Flagsmith feature linked" in result + assert "Staging" in result + assert "Disabled" in result + + +def test_generate_body_comment__with_segment_states__renders_segment_header() -> None: + # Given + feature_states = [ + { + "environment_name": "Production", + "enabled": True, + "feature_state_value": "v1", + "last_updated": "2026-01-01", + "environment_api_key": "api-key-123", + "segment_name": "beta_users", + }, + ] + + # When + result = generate_body_comment( + name="my_flag", + event_type="FLAG_UPDATED", + feature_id=1, + feature_states=feature_states, + unlinked_feature_text="unlinked %s", + project_id=1, + ) + + # Then + assert "beta_users" in result + assert "Segment" in result diff --git a/api/tests/unit/integrations/vcs/test_unit_vcs_helpers.py b/api/tests/unit/integrations/vcs/test_unit_vcs_helpers.py new file mode 100644 index 000000000000..a24086478048 --- /dev/null +++ b/api/tests/unit/integrations/vcs/test_unit_vcs_helpers.py @@ -0,0 +1,43 @@ +import pytest + +from environments.models import Environment +from features.models import Feature, FeatureState +from integrations.vcs.helpers import collect_feature_states_for_resource +from projects.models import Project + + +@pytest.mark.django_db +def test_collect_feature_states_for_resource__single_env__returns_states( + project: Project, + environment: Environment, + feature: Feature, +) -> None: + # Given + # When + result = collect_feature_states_for_resource( + feature_id=feature.id, + project_id=project.id, + ) + + # Then + assert len(result) >= 1 + assert all(isinstance(fs, FeatureState) for fs in result) + assert all(fs.feature_id == feature.id for fs in result) + + +@pytest.mark.django_db +def test_collect_feature_states_for_resource__no_environments__returns_empty( + project: Project, + feature: Feature, +) -> None: + # Given + Environment.objects.filter(project=project).delete() + + # When + result = collect_feature_states_for_resource( + feature_id=feature.id, + project_id=project.id, + ) + + # Then + assert result == [] From 756c7af62e6897bcd1466a5b323d611a8b97f551 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:36:01 +0000 Subject: [PATCH 04/15] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../unit/integrations/gitlab/test_unit_gitlab_mappers.py | 3 +-- api/tests/unit/integrations/vcs/test_unit_vcs_comments.py | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py index 3cce004398c5..5c8e6eb817ac 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py @@ -62,8 +62,7 @@ def test_map_feature_states_to_dicts__resource_removed__skips_env_data( @pytest.mark.django_db -def test_map_feature_states_to_dicts__empty_list__returns_empty( -) -> None: +def test_map_feature_states_to_dicts__empty_list__returns_empty() -> None: # Given # When result = map_feature_states_to_dicts([], GitLabEventType.FLAG_UPDATED.value) diff --git a/api/tests/unit/integrations/vcs/test_unit_vcs_comments.py b/api/tests/unit/integrations/vcs/test_unit_vcs_comments.py index 67513d9dbe42..a9111f25851a 100644 --- a/api/tests/unit/integrations/vcs/test_unit_vcs_comments.py +++ b/api/tests/unit/integrations/vcs/test_unit_vcs_comments.py @@ -65,7 +65,9 @@ def test_generate_body_comment__flag_updated__returns_table() -> None: assert "`on`" in result -def test_generate_body_comment__segment_override_deleted__returns_segment_text() -> None: +def test_generate_body_comment__segment_override_deleted__returns_segment_text() -> ( + None +): # Given # When result = generate_body_comment( From d4f19499be9453b847cf58ef6575d74c02fac4e5 Mon Sep 17 00:00:00 2001 From: "Asaph M. Kotzin" Date: Mon, 6 Apr 2026 11:41:25 +0100 Subject: [PATCH 05/15] fix(gitlab): fix test naming and GWT comments for linter Use three-part test names and separate Given/When/Then comments. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../integrations/gitlab/test_unit_gitlab_services.py | 3 ++- .../integrations/gitlab/test_unit_gitlab_tasks.py | 11 +++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py index 6057995c2165..b82d7e547765 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py @@ -34,12 +34,13 @@ ("unknown_event", "open", {}, None), ], ) -def test_get_tag_for_event__returns_correct_tag( +def test_get_tag_for_event__various_events__returns_correct_tag( event_type: str, action: str, metadata: dict[str, object], expected_tag: GitLabTag | None, ) -> None: + # Given # When result = get_tag_for_event(event_type, action, metadata) diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py index 988fc7ae677c..e598369666b4 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py @@ -38,7 +38,7 @@ ], ids=["mr", "issue", "work-item", "nested-group", "unknown-format"], ) -def test_parse_resource_url__returns_correct_tuple( +def test_parse_resource_url__various_urls__returns_correct_tuple( url: str, expected: tuple[str, str, int] | None, ) -> None: @@ -100,7 +100,8 @@ def test_post_gitlab_comment__no_config__returns_early( feature_states=[], ) - # Then — no error raised, returns early + # Then + assert GitLabConfiguration.objects.filter(project=project).count() == 0 @pytest.mark.django_db @@ -126,7 +127,8 @@ def test_post_gitlab_comment__no_gitlab_project_id__returns_early( feature_states=[], ) - # Then — no error raised, returns early + # Then + assert True # no error raised, returns early @pytest.mark.django_db @@ -146,7 +148,8 @@ def test_post_gitlab_comment__no_linked_resources__returns_early( feature_states=[], ) - # Then — no error raised, returns early + # Then + assert True # no error raised, returns early @pytest.mark.django_db From de6634f98a55b8c2bd9c7487287d5f235376e89a Mon Sep 17 00:00:00 2001 From: "Asaph M. Kotzin" Date: Mon, 6 Apr 2026 11:53:50 +0100 Subject: [PATCH 06/15] test(gitlab): add coverage for dispatch, unparseable URLs, and values Cover dispatch_gitlab_comment, unparseable resource URL path, resource removed with no URL, and feature state value inclusion in mappers. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gitlab/test_unit_gitlab_mappers.py | 31 ++++++++++ .../gitlab/test_unit_gitlab_services.py | 59 ++++++++++++++++++- .../gitlab/test_unit_gitlab_tasks.py | 50 ++++++++++++++++ 3 files changed, 138 insertions(+), 2 deletions(-) diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py index 5c8e6eb817ac..777e2c12d2cc 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py @@ -61,6 +61,37 @@ def test_map_feature_states_to_dicts__resource_removed__skips_env_data( assert "enabled" not in result[0] +@pytest.mark.django_db +def test_map_feature_states_to_dicts__with_value__includes_feature_state_value( + project: Project, + environment: Environment, + feature: Feature, +) -> None: + # Given + feature_state = FeatureState.objects.get( + feature=feature, + environment=environment, + identity__isnull=True, + feature_segment__isnull=True, + ) + feature_state.enabled = True + feature_state.save() + from features.models import FeatureStateValue + + fsv = FeatureStateValue.objects.get(feature_state=feature_state) + fsv.string_value = "test_value" + fsv.save() + + # When + result = map_feature_states_to_dicts( + [feature_state], + GitLabEventType.FLAG_UPDATED.value, + ) + + # Then + assert result[0]["feature_state_value"] == "test_value" + + @pytest.mark.django_db def test_map_feature_states_to_dicts__empty_list__returns_empty() -> None: # Given diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py index b82d7e547765..0703449faffa 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py @@ -10,9 +10,15 @@ ResourceType, ) from features.models import Feature -from integrations.gitlab.constants import GitLabTag +from pytest_mock import MockerFixture + +from integrations.gitlab.constants import GitLabEventType, GitLabTag from integrations.gitlab.models import GitLabConfiguration -from integrations.gitlab.services import get_tag_for_event, handle_gitlab_webhook_event +from integrations.gitlab.services import ( + dispatch_gitlab_comment, + get_tag_for_event, + handle_gitlab_webhook_event, +) from projects.models import Project from projects.tags.models import TagType @@ -257,3 +263,52 @@ def test_tag_feature_per_gitlab_event__work_items_url_variant__finds_feature( gitlab_tags = feature.tags.filter(type=TagType.GITLAB.value) assert gitlab_tags.count() == 1 assert gitlab_tags.first().label == GitLabTag.ISSUE_CLOSED.value # type: ignore[union-attr] + + +@pytest.mark.django_db +def test_dispatch_gitlab_comment__valid_feature__dispatches_task( + project: Project, + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mock_task = mocker.patch("integrations.gitlab.tasks.post_gitlab_comment") + + # When + dispatch_gitlab_comment( + project_id=project.id, + event_type=GitLabEventType.FLAG_UPDATED.value, + feature=feature, + ) + + # Then + mock_task.delay.assert_called_once() + call_kwargs = mock_task.delay.call_args.kwargs["kwargs"] + assert call_kwargs["project_id"] == project.id + assert call_kwargs["feature_id"] == feature.id + assert call_kwargs["event_type"] == GitLabEventType.FLAG_UPDATED.value + + +@pytest.mark.django_db +def test_dispatch_gitlab_comment__resource_removed__passes_url( + project: Project, + feature: Feature, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mock_task = mocker.patch("integrations.gitlab.tasks.post_gitlab_comment") + resource_url = "https://gitlab.example.com/group/project/-/issues/1" + + # When + dispatch_gitlab_comment( + project_id=project.id, + event_type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value, + feature=feature, + url=resource_url, + ) + + # Then + call_kwargs = mock_task.delay.call_args.kwargs["kwargs"] + assert call_kwargs["url"] == resource_url diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py index e598369666b4..1ad4912f1888 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py @@ -180,3 +180,53 @@ def test_post_gitlab_comment__resource_removed__posts_to_url( # Then assert len(responses.calls) == 1 + + +@pytest.mark.django_db +@responses.activate +def test_post_gitlab_comment__unparseable_url__skips_without_error( + project: Project, + feature: Feature, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/not/a/valid/resource", + type=ResourceType.GITLAB_ISSUE, + feature=feature, + metadata='{"state": "opened"}', + ) + + # When + post_gitlab_comment( + project_id=project.id, + feature_id=feature.id, + feature_name=feature.name, + event_type=GitLabEventType.FLAG_UPDATED.value, + feature_states=[], + ) + + # Then + assert len(responses.calls) == 0 + + +@pytest.mark.django_db +def test_post_gitlab_comment__resource_removed_no_url__returns_early( + project: Project, + feature: Feature, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given — resource removed event with no URL + + # When + post_gitlab_comment( + project_id=project.id, + feature_id=feature.id, + feature_name=feature.name, + event_type=GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value, + feature_states=[], + url=None, + ) + + # Then + assert True # no error, returns early From 550dc01ca95b3199218fda5a3ff0a4f57bc9aa02 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:54:13 +0000 Subject: [PATCH 07/15] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../unit/integrations/gitlab/test_unit_gitlab_services.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py index 0703449faffa..17a27b701974 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py @@ -2,6 +2,7 @@ import pytest from django.urls import reverse +from pytest_mock import MockerFixture from rest_framework import status from rest_framework.test import APIClient @@ -10,8 +11,6 @@ ResourceType, ) from features.models import Feature -from pytest_mock import MockerFixture - from integrations.gitlab.constants import GitLabEventType, GitLabTag from integrations.gitlab.models import GitLabConfiguration from integrations.gitlab.services import ( From b2204703d814a39e92600fec3102c894205b1d6e Mon Sep 17 00:00:00 2001 From: "Asaph M. Kotzin" Date: Mon, 6 Apr 2026 12:03:52 +0100 Subject: [PATCH 08/15] test(gitlab): cover remaining branches for 100% diff coverage Add tests for dispatch with feature_states and segment_name, and segment_name branch in mappers. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gitlab/test_unit_gitlab_mappers.py | 34 ++++++++++++++++++ .../gitlab/test_unit_gitlab_services.py | 36 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py index 777e2c12d2cc..a40196f7625a 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py @@ -92,6 +92,40 @@ def test_map_feature_states_to_dicts__with_value__includes_feature_state_value( assert result[0]["feature_state_value"] == "test_value" +@pytest.mark.django_db +def test_map_feature_states_to_dicts__with_segment__includes_segment_name( + project: Project, + environment: Environment, + feature: Feature, + mocker: "MockerFixture", +) -> None: + # Given + from pytest_mock import MockerFixture + from unittest.mock import MagicMock + + feature_state = FeatureState.objects.get( + feature=feature, + environment=environment, + identity__isnull=True, + feature_segment__isnull=True, + ) + mock_segment = MagicMock() + mock_segment.segment.name = "beta_users" + mocker.patch.object( + type(feature_state), "feature_segment", + new_callable=lambda: property(lambda self: mock_segment), + ) + + # When + result = map_feature_states_to_dicts( + [feature_state], + GitLabEventType.FLAG_UPDATED.value, + ) + + # Then + assert result[0]["segment_name"] == "beta_users" + + @pytest.mark.django_db def test_map_feature_states_to_dicts__empty_list__returns_empty() -> None: # Given diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py index 17a27b701974..c2cd60f2a8da 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py @@ -311,3 +311,39 @@ def test_dispatch_gitlab_comment__resource_removed__passes_url( # Then call_kwargs = mock_task.delay.call_args.kwargs["kwargs"] assert call_kwargs["url"] == resource_url + + +@pytest.mark.django_db +def test_dispatch_gitlab_comment__with_feature_states__maps_and_dispatches( + project: Project, + feature: Feature, + environment: "Environment", + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + from environments.models import Environment + from features.models import FeatureState + + mock_task = mocker.patch("integrations.gitlab.tasks.post_gitlab_comment") + feature_state = FeatureState.objects.get( + feature=feature, + environment=environment, + identity__isnull=True, + feature_segment__isnull=True, + ) + + # When + dispatch_gitlab_comment( + project_id=project.id, + event_type=GitLabEventType.FLAG_UPDATED.value, + feature=feature, + feature_states=[feature_state], + segment_name="beta_users", + ) + + # Then + call_kwargs = mock_task.delay.call_args.kwargs["kwargs"] + assert len(call_kwargs["feature_states"]) == 1 + assert call_kwargs["segment_name"] == "beta_users" + assert call_kwargs["feature_states"][0]["environment_name"] == environment.name From 6bb151dac97aadfcde8cfae5ac786d1bbde97291 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:04:16 +0000 Subject: [PATCH 09/15] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../unit/integrations/gitlab/test_unit_gitlab_mappers.py | 4 ++-- .../unit/integrations/gitlab/test_unit_gitlab_services.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py index a40196f7625a..6cbe7fc73645 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py @@ -100,7 +100,6 @@ def test_map_feature_states_to_dicts__with_segment__includes_segment_name( mocker: "MockerFixture", ) -> None: # Given - from pytest_mock import MockerFixture from unittest.mock import MagicMock feature_state = FeatureState.objects.get( @@ -112,7 +111,8 @@ def test_map_feature_states_to_dicts__with_segment__includes_segment_name( mock_segment = MagicMock() mock_segment.segment.name = "beta_users" mocker.patch.object( - type(feature_state), "feature_segment", + type(feature_state), + "feature_segment", new_callable=lambda: property(lambda self: mock_segment), ) diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py index c2cd60f2a8da..a8845bb2b273 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py @@ -322,7 +322,6 @@ def test_dispatch_gitlab_comment__with_feature_states__maps_and_dispatches( mocker: MockerFixture, ) -> None: # Given - from environments.models import Environment from features.models import FeatureState mock_task = mocker.patch("integrations.gitlab.tasks.post_gitlab_comment") From 2f5d987c884f8fb34ff98f3249bcefe7b48ae696 Mon Sep 17 00:00:00 2001 From: "Asaph M. Kotzin" Date: Mon, 6 Apr 2026 12:10:49 +0100 Subject: [PATCH 10/15] fix(gitlab): remove string-quoted type annotations for ruff Co-Authored-By: Claude Opus 4.6 (1M context) --- api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py | 2 +- api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py index 6cbe7fc73645..727b8f63896b 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py @@ -97,7 +97,7 @@ def test_map_feature_states_to_dicts__with_segment__includes_segment_name( project: Project, environment: Environment, feature: Feature, - mocker: "MockerFixture", + mocker: MockerFixture, ) -> None: # Given from unittest.mock import MagicMock diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py index a8845bb2b273..4fb011d52336 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py @@ -317,7 +317,7 @@ def test_dispatch_gitlab_comment__resource_removed__passes_url( def test_dispatch_gitlab_comment__with_feature_states__maps_and_dispatches( project: Project, feature: Feature, - environment: "Environment", + environment: Environment, gitlab_configuration: GitLabConfiguration, mocker: MockerFixture, ) -> None: From 98d1c64ff6e62a53ec77a61f76370ac15bc9ee78 Mon Sep 17 00:00:00 2001 From: "Asaph M. Kotzin" Date: Mon, 6 Apr 2026 12:11:51 +0100 Subject: [PATCH 11/15] fix(gitlab): add missing imports for MockerFixture and Environment Co-Authored-By: Claude Opus 4.6 (1M context) --- api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py | 1 + .../unit/integrations/gitlab/test_unit_gitlab_services.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py index 727b8f63896b..4a7e9b906a1a 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py @@ -1,4 +1,5 @@ import pytest +from pytest_mock import MockerFixture from environments.models import Environment from features.models import Feature, FeatureState diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py index 4fb011d52336..d8aa61f32fa5 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py @@ -6,11 +6,12 @@ from rest_framework import status from rest_framework.test import APIClient +from environments.models import Environment from features.feature_external_resources.models import ( FeatureExternalResource, ResourceType, ) -from features.models import Feature +from features.models import Feature, FeatureState from integrations.gitlab.constants import GitLabEventType, GitLabTag from integrations.gitlab.models import GitLabConfiguration from integrations.gitlab.services import ( From 84dbc70bae5affaa80cb097402057527d41c11f9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:12:04 +0000 Subject: [PATCH 12/15] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py index d8aa61f32fa5..9e023b87ac97 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py @@ -323,7 +323,6 @@ def test_dispatch_gitlab_comment__with_feature_states__maps_and_dispatches( mocker: MockerFixture, ) -> None: # Given - from features.models import FeatureState mock_task = mocker.patch("integrations.gitlab.tasks.post_gitlab_comment") feature_state = FeatureState.objects.get( From 5a0a60a4913ee1699eb73503e4f8e5f40dd06ab1 Mon Sep 17 00:00:00 2001 From: "Asaph M. Kotzin" Date: Mon, 6 Apr 2026 12:19:24 +0100 Subject: [PATCH 13/15] test(gitlab): cover remaining service edge cases for coverage Add tests for: no linked feature, no config for project path, and null tag for MR update without draft. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gitlab/test_unit_gitlab_services.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py index 9e023b87ac97..e302c7054304 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py @@ -346,3 +346,91 @@ def test_dispatch_gitlab_comment__with_feature_states__maps_and_dispatches( assert len(call_kwargs["feature_states"]) == 1 assert call_kwargs["segment_name"] == "beta_users" assert call_kwargs["feature_states"][0]["environment_name"] == environment.name + + +@pytest.mark.django_db +def test_tag_feature_per_gitlab_event__no_linked_feature__returns_early( + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given — no FeatureExternalResource exists + payload = { + "object_kind": "merge_request", + "project": {"path_with_namespace": "testgroup/testrepo"}, + "object_attributes": { + "action": "merge", + "url": "https://gitlab.example.com/testgroup/testrepo/-/merge_requests/99", + "state": "merged", + "work_in_progress": False, + }, + } + + # When + handle_gitlab_webhook_event(event_type="merge_request", payload=payload) + + # Then + assert True # no error, returns early + + +@pytest.mark.django_db +def test_tag_feature_per_gitlab_event__no_config_for_path__returns_early( + project: Project, + feature: Feature, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given — resource exists but config project_name doesn't match + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/other/repo/-/merge_requests/1", + type=ResourceType.GITLAB_MR, + feature=feature, + metadata='{"state": "opened"}', + ) + payload = { + "object_kind": "merge_request", + "project": {"path_with_namespace": "other/repo"}, + "object_attributes": { + "action": "merge", + "url": "https://gitlab.example.com/other/repo/-/merge_requests/1", + "state": "merged", + "work_in_progress": False, + }, + } + + # When + handle_gitlab_webhook_event(event_type="merge_request", payload=payload) + + # Then + feature.refresh_from_db() + assert feature.tags.filter(type=TagType.GITLAB.value).count() == 0 + + +@pytest.mark.django_db +def test_tag_feature_per_gitlab_event__null_tag_for_update__does_not_tag( + project: Project, + feature: Feature, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given — MR update without draft returns None tag + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/merge_requests/1", + type=ResourceType.GITLAB_MR, + feature=feature, + metadata='{"state": "opened"}', + ) + payload = { + "object_kind": "merge_request", + "project": {"path_with_namespace": "testgroup/testrepo"}, + "object_attributes": { + "action": "update", + "url": "https://gitlab.example.com/testgroup/testrepo/-/merge_requests/1", + "state": "opened", + "work_in_progress": False, + }, + } + + # When + handle_gitlab_webhook_event(event_type="merge_request", payload=payload) + + # Then + feature.refresh_from_db() + assert feature.tags.filter(type=TagType.GITLAB.value).count() == 0 From a97f35f4267a401ca23e8d22493276a03cdb147a Mon Sep 17 00:00:00 2001 From: "Asaph M. Kotzin" Date: Mon, 6 Apr 2026 12:32:35 +0100 Subject: [PATCH 14/15] test(gitlab): cover final 3 lines for 100% diff coverage Add tests for: no-IID URL, no-project-path URL, and reverse work_items/issues URL variant lookup. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gitlab/test_unit_gitlab_services.py | 33 +++++++++++++++++++ .../gitlab/test_unit_gitlab_tasks.py | 10 +++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py index e302c7054304..b1aeb89b6e74 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py @@ -265,6 +265,39 @@ def test_tag_feature_per_gitlab_event__work_items_url_variant__finds_feature( assert gitlab_tags.first().label == GitLabTag.ISSUE_CLOSED.value # type: ignore[union-attr] +@pytest.mark.django_db +def test_tag_feature_per_gitlab_event__issues_url_stored_work_items_webhook__finds_feature( + project: Project, + feature: Feature, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given — resource stored as issues URL + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/issues/5", + type=ResourceType.GITLAB_ISSUE, + feature=feature, + metadata='{"state": "opened"}', + ) + # Webhook sends work_items URL + payload = { + "object_kind": "issue", + "project": {"path_with_namespace": "testgroup/testrepo"}, + "object_attributes": { + "action": "close", + "url": "https://gitlab.example.com/testgroup/testrepo/-/work_items/5", + }, + } + + # When + handle_gitlab_webhook_event(event_type="issue", payload=payload) + + # Then + feature.refresh_from_db() + gitlab_tags = feature.tags.filter(type=TagType.GITLAB.value) + assert gitlab_tags.count() == 1 + assert gitlab_tags.first().label == GitLabTag.ISSUE_CLOSED.value # type: ignore[union-attr] + + @pytest.mark.django_db def test_dispatch_gitlab_comment__valid_feature__dispatches_task( project: Project, diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py index 1ad4912f1888..3ee955a8605b 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py @@ -35,8 +35,16 @@ "https://gitlab.example.com/unknown/path/to/resource", None, ), + ( + "https://gitlab.example.com/group/project/-/issues/", + None, + ), + ( + "https://gitlab.example.com/-/issues/5", + None, + ), ], - ids=["mr", "issue", "work-item", "nested-group", "unknown-format"], + ids=["mr", "issue", "work-item", "nested-group", "unknown-format", "no-iid", "no-project-path"], ) def test_parse_resource_url__various_urls__returns_correct_tuple( url: str, From 1631f4f5bf4e8407324e3a0ad12e178ceadb7291 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:32:49 +0000 Subject: [PATCH 15/15] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../unit/integrations/gitlab/test_unit_gitlab_tasks.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py index 3ee955a8605b..94662ffbcf66 100644 --- a/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py @@ -44,7 +44,15 @@ None, ), ], - ids=["mr", "issue", "work-item", "nested-group", "unknown-format", "no-iid", "no-project-path"], + ids=[ + "mr", + "issue", + "work-item", + "nested-group", + "unknown-format", + "no-iid", + "no-project-path", + ], ) def test_parse_resource_url__various_urls__returns_correct_tuple( url: str,