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..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/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..e6cf08e172e0 --- /dev/null +++ b/api/integrations/gitlab/mappers.py @@ -0,0 +1,42 @@ +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..9b50a1d3d750 --- /dev/null +++ b/api/integrations/gitlab/services.py @@ -0,0 +1,161 @@ +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..ebbb845cb4fd --- /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_mappers.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py new file mode 100644 index 000000000000..4a7e9b906a1a --- /dev/null +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_mappers.py @@ -0,0 +1,137 @@ +import pytest +from pytest_mock import MockerFixture + +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__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__with_segment__includes_segment_name( + project: Project, + environment: Environment, + feature: Feature, + mocker: MockerFixture, +) -> None: + # Given + 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 + # 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_services.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py new file mode 100644 index 000000000000..b1aeb89b6e74 --- /dev/null +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_services.py @@ -0,0 +1,469 @@ +import json + +import pytest +from django.urls import reverse +from pytest_mock import MockerFixture +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, FeatureState +from integrations.gitlab.constants import GitLabEventType, GitLabTag +from integrations.gitlab.models import GitLabConfiguration +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 + + +@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__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) + + # 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] + + +@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, + 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 + + +@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 + + 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 + + +@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 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..94662ffbcf66 --- /dev/null +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_tasks.py @@ -0,0 +1,248 @@ +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, + ), + ( + "https://gitlab.example.com/group/project/-/issues/", + None, + ), + ( + "https://gitlab.example.com/-/issues/5", + None, + ), + ], + 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, + 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 + assert GitLabConfiguration.objects.filter(project=project).count() == 0 + + +@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 + assert True # 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 + assert True # 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 + + +@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 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..a9111f25851a --- /dev/null +++ b/api/tests/unit/integrations/vcs/test_unit_vcs_comments.py @@ -0,0 +1,140 @@ +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 == []