From a791102ffaa182a85973328dac6cecc2fa4ef919 Mon Sep 17 00:00:00 2001 From: "Asaph M. Kotzin" Date: Fri, 3 Apr 2026 14:20:53 +0100 Subject: [PATCH 1/2] feat(gitlab): add configuration persistence, views, and resource browsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GitLabConfiguration model, CRUD viewset, and resource browsing endpoints (projects, issues, MRs, members). Multi-repo support by design — no unique constraint on project FK. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/conftest.py | 14 + .../0003_add_gitlab_resource_types.py | 27 ++ .../feature_external_resources/models.py | 3 + api/integrations/gitlab/constants.py | 23 + api/integrations/gitlab/exceptions.py | 6 + .../gitlab/migrations/0001_initial.py | 100 ++++ api/integrations/gitlab/models.py | 109 +++++ api/integrations/gitlab/permissions.py | 15 + api/integrations/gitlab/serializers.py | 55 +++ api/integrations/gitlab/views.py | 221 +++++++++ .../0003_add_gitlab_vcs_provider.py | 20 + api/projects/code_references/types.py | 1 + .../migrations/0009_add_gitlab_tag_type.py | 30 ++ api/projects/tags/models.py | 1 + api/projects/urls.py | 32 ++ .../gitlab/test_unit_gitlab_views.py | 439 ++++++++++++++++++ 16 files changed, 1096 insertions(+) create mode 100644 api/features/feature_external_resources/migrations/0003_add_gitlab_resource_types.py create mode 100644 api/integrations/gitlab/exceptions.py create mode 100644 api/integrations/gitlab/migrations/0001_initial.py create mode 100644 api/integrations/gitlab/models.py create mode 100644 api/integrations/gitlab/permissions.py create mode 100644 api/integrations/gitlab/serializers.py create mode 100644 api/integrations/gitlab/views.py create mode 100644 api/projects/code_references/migrations/0003_add_gitlab_vcs_provider.py create mode 100644 api/projects/tags/migrations/0009_add_gitlab_tag_type.py create mode 100644 api/tests/unit/integrations/gitlab/test_unit_gitlab_views.py diff --git a/api/conftest.py b/api/conftest.py index 09b61c539016..fd4a194aa6fa 100644 --- a/api/conftest.py +++ b/api/conftest.py @@ -51,6 +51,7 @@ from features.versioning.tasks import enable_v2_versioning from features.workflows.core.models import ChangeRequest from integrations.github.models import GithubConfiguration, GitHubRepository +from integrations.gitlab.models import GitLabConfiguration from metadata.models import ( Metadata, MetadataField, @@ -1219,6 +1220,19 @@ def github_repository( ) +@pytest.fixture() +def gitlab_configuration(project: Project) -> GitLabConfiguration: + return GitLabConfiguration.objects.create( # type: ignore[no-any-return] + project=project, + gitlab_instance_url="https://gitlab.example.com", + access_token="test-gitlab-token", + webhook_secret="test-webhook-secret", + gitlab_project_id=1, + project_name="testgroup/testrepo", + tagging_enabled=True, + ) + + @pytest.fixture(params=AdminClientAuthType.__args__) # type: ignore[attr-defined] def admin_client_auth_type( request: pytest.FixtureRequest, diff --git a/api/features/feature_external_resources/migrations/0003_add_gitlab_resource_types.py b/api/features/feature_external_resources/migrations/0003_add_gitlab_resource_types.py new file mode 100644 index 000000000000..f540154ae5a1 --- /dev/null +++ b/api/features/feature_external_resources/migrations/0003_add_gitlab_resource_types.py @@ -0,0 +1,27 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "feature_external_resources", + "0002_featureexternalresource_feature_ext_type_2b2068_idx", + ), + ] + + operations = [ + migrations.AlterField( + model_name="featureexternalresource", + name="type", + field=models.CharField( + choices=[ + ("GITHUB_ISSUE", "GitHub Issue"), + ("GITHUB_PR", "GitHub PR"), + ("GITLAB_ISSUE", "GitLab Issue"), + ("GITLAB_MR", "GitLab MR"), + ], + max_length=20, + ), + ), + ] diff --git a/api/features/feature_external_resources/models.py b/api/features/feature_external_resources/models.py index 54699a0d76d2..2457bc2447a8 100644 --- a/api/features/feature_external_resources/models.py +++ b/api/features/feature_external_resources/models.py @@ -26,6 +26,9 @@ class ResourceType(models.TextChoices): # GitHub external resource types GITHUB_ISSUE = "GITHUB_ISSUE", "GitHub Issue" GITHUB_PR = "GITHUB_PR", "GitHub PR" + # GitLab external resource types + GITLAB_ISSUE = "GITLAB_ISSUE", "GitLab Issue" + GITLAB_MR = "GITLAB_MR", "GitLab MR" tag_by_type_and_state = { diff --git a/api/integrations/gitlab/constants.py b/api/integrations/gitlab/constants.py index f3a7a6b53985..8809e70c33b0 100644 --- a/api/integrations/gitlab/constants.py +++ b/api/integrations/gitlab/constants.py @@ -1,3 +1,5 @@ +from enum import Enum + GITLAB_API_CALLS_TIMEOUT = 10 GITLAB_FLAGSMITH_LABEL = "Flagsmith Flag" @@ -5,3 +7,24 @@ "This GitLab Issue/MR is linked to a Flagsmith Feature Flag" ) GITLAB_FLAGSMITH_LABEL_COLOUR = "6633FF" + +GITLAB_TAG_COLOUR = "#838992" + + +class GitLabTag(Enum): + MR_OPEN = "MR Open" + MR_MERGED = "MR Merged" + MR_CLOSED = "MR Closed" + MR_DRAFT = "MR Draft" + ISSUE_OPEN = "Issue Open" + ISSUE_CLOSED = "Issue Closed" + + +gitlab_tag_description: 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", + GitLabTag.MR_CLOSED.value: "This feature has a linked MR closed", + GitLabTag.MR_DRAFT.value: "This feature has a linked MR draft", + GitLabTag.ISSUE_OPEN.value: "This feature has a linked issue open", + GitLabTag.ISSUE_CLOSED.value: "This feature has a linked issue closed", +} diff --git a/api/integrations/gitlab/exceptions.py b/api/integrations/gitlab/exceptions.py new file mode 100644 index 000000000000..f332841d7bd8 --- /dev/null +++ b/api/integrations/gitlab/exceptions.py @@ -0,0 +1,6 @@ +from rest_framework.exceptions import APIException + + +class DuplicateGitLabIntegration(APIException): + status_code = 400 + default_detail = "A GitLab integration already exists for this project and repository." diff --git a/api/integrations/gitlab/migrations/0001_initial.py b/api/integrations/gitlab/migrations/0001_initial.py new file mode 100644 index 000000000000..d7318b92b6bd --- /dev/null +++ b/api/integrations/gitlab/migrations/0001_initial.py @@ -0,0 +1,100 @@ +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("projects", "0027_add_create_project_level_change_requests_permission"), + ] + + operations = [ + migrations.CreateModel( + name="GitLabConfiguration", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, + db_index=True, + default=None, + editable=False, + null=True, + ), + ), + ( + "uuid", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ( + "gitlab_instance_url", + models.URLField( + help_text="Base URL of the GitLab instance, e.g. https://gitlab.com", + max_length=200, + ), + ), + ( + "access_token", + models.CharField( + help_text="GitLab Group or Project Access Token with api scope", + max_length=255, + ), + ), + ( + "webhook_secret", + models.CharField( + help_text="Secret token for validating incoming GitLab webhooks", + max_length=255, + ), + ), + ( + "gitlab_project_id", + models.IntegerField( + blank=True, + help_text="GitLab's numeric project ID", + null=True, + ), + ), + ( + "project_name", + models.CharField( + blank=True, + default="", + help_text=( + "GitLab project path with namespace, " + "e.g. my-group/my-project" + ), + max_length=200, + ), + ), + ( + "tagging_enabled", + models.BooleanField(default=False), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="gitlab_configurations", + to="projects.project", + ), + ), + ], + options={ + "ordering": ("id",), + }, + ), + ] diff --git a/api/integrations/gitlab/models.py b/api/integrations/gitlab/models.py new file mode 100644 index 000000000000..16627382fbed --- /dev/null +++ b/api/integrations/gitlab/models.py @@ -0,0 +1,109 @@ +import logging +import re + +from django.db import models +from django_lifecycle import ( # type: ignore[import-untyped] + AFTER_CREATE, + AFTER_UPDATE, + BEFORE_DELETE, + LifecycleModelMixin, + hook, +) + +from core.models import SoftDeleteExportableModel +from integrations.gitlab.constants import ( + GITLAB_TAG_COLOUR, + GitLabTag, + gitlab_tag_description, +) + +logger: logging.Logger = logging.getLogger(name=__name__) + + +class GitLabConfiguration(LifecycleModelMixin, SoftDeleteExportableModel): # type: ignore[misc] + project = models.ForeignKey( + "projects.Project", + on_delete=models.CASCADE, + related_name="gitlab_configurations", + ) + gitlab_instance_url = models.URLField( + max_length=200, + blank=False, + null=False, + help_text="Base URL of the GitLab instance, e.g. https://gitlab.com", + ) + access_token = models.CharField( + max_length=255, + blank=False, + null=False, + help_text="GitLab Group or Project Access Token with api scope", + ) + webhook_secret = models.CharField( + max_length=255, + blank=False, + null=False, + help_text="Secret token for validating incoming GitLab webhooks", + ) + gitlab_project_id = models.IntegerField( + blank=True, + null=True, + help_text="GitLab's numeric project ID", + ) + project_name = models.CharField( + max_length=200, + blank=True, + default="", + help_text="GitLab project path with namespace, e.g. my-group/my-project", + ) + tagging_enabled = models.BooleanField(default=False) + + class Meta: + ordering = ("id",) + + @staticmethod + def has_gitlab_configuration(project_id: int) -> bool: + return GitLabConfiguration.objects.filter( # type: ignore[no-any-return] + project_id=project_id, + deleted_at__isnull=True, + ).exists() + + @hook(BEFORE_DELETE) # type: ignore[misc] + def delete_feature_external_resources(self) -> None: + # Local import to avoid circular dependency: features -> integrations + from features.feature_external_resources.models import ( + FeatureExternalResource, + ResourceType, + ) + + if self.project_name: + pattern = re.escape(f"/{self.project_name}/-/") + FeatureExternalResource.objects.filter( + feature_id__in=self.project.features.values_list("id", flat=True), + type__in=[ResourceType.GITLAB_ISSUE, ResourceType.GITLAB_MR], + url__regex=pattern, + ).delete() + + @hook(AFTER_CREATE) # type: ignore[misc] + @hook(AFTER_UPDATE, when="tagging_enabled", has_changed=True, was=False) # type: ignore[misc] + def create_gitlab_tags(self) -> None: + from projects.tags.models import Tag, TagType + + tags_to_create = [] + for tag_label in GitLabTag: + if not Tag.objects.filter( + label=tag_label.value, + project=self.project, + type=TagType.GITLAB.value, + ).exists(): + tags_to_create.append( + Tag( + color=GITLAB_TAG_COLOUR, + description=gitlab_tag_description[tag_label.value], + label=tag_label.value, + project=self.project, + is_system_tag=True, + type=TagType.GITLAB.value, + ) + ) + if tags_to_create: + Tag.objects.bulk_create(tags_to_create) diff --git a/api/integrations/gitlab/permissions.py b/api/integrations/gitlab/permissions.py new file mode 100644 index 000000000000..64030f815e28 --- /dev/null +++ b/api/integrations/gitlab/permissions.py @@ -0,0 +1,15 @@ +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.views import APIView + +from projects.models import Project + + +class HasPermissionToGitLabConfiguration(BasePermission): + def has_permission(self, request: Request, view: APIView) -> bool: + project_pk = view.kwargs.get("project_pk") + try: + project = Project.objects.get(id=project_pk) + except Project.DoesNotExist: + return False + return bool(request.user.belongs_to(organisation_id=project.organisation_id)) # type: ignore[union-attr] diff --git a/api/integrations/gitlab/serializers.py b/api/integrations/gitlab/serializers.py new file mode 100644 index 000000000000..5d4ff7d69de6 --- /dev/null +++ b/api/integrations/gitlab/serializers.py @@ -0,0 +1,55 @@ +from rest_framework import serializers +from rest_framework.serializers import ModelSerializer + +from integrations.gitlab.models import GitLabConfiguration + + +class GitLabConfigurationSerializer(ModelSerializer[GitLabConfiguration]): + class Meta: + model = GitLabConfiguration + fields = ( + "id", + "gitlab_instance_url", + "webhook_secret", + "gitlab_project_id", + "project_name", + "tagging_enabled", + "project", + ) + read_only_fields = ("project",) + + +class GitLabConfigurationCreateSerializer(ModelSerializer[GitLabConfiguration]): + class Meta: + model = GitLabConfiguration + fields = ( + "id", + "gitlab_instance_url", + "access_token", + "webhook_secret", + "gitlab_project_id", + "project_name", + "tagging_enabled", + "project", + ) + read_only_fields = ("project",) + extra_kwargs = { + "access_token": {"write_only": True}, + } + + +class PaginatedQueryParamsSerializer(serializers.Serializer[None]): + page = serializers.IntegerField(default=1, min_value=1) + page_size = serializers.IntegerField(default=100, min_value=1, max_value=100) + + +class ProjectQueryParamsSerializer(PaginatedQueryParamsSerializer): + gitlab_project_id = serializers.IntegerField(default=0) + project_name = serializers.CharField(default="", required=False) + + +class IssueQueryParamsSerializer(ProjectQueryParamsSerializer): + search_text = serializers.CharField(required=False, allow_blank=True) + state = serializers.CharField(default="opened", required=False) + author = serializers.CharField(required=False) + assignee = serializers.CharField(required=False) diff --git a/api/integrations/gitlab/views.py b/api/integrations/gitlab/views.py new file mode 100644 index 000000000000..c7e21e96508f --- /dev/null +++ b/api/integrations/gitlab/views.py @@ -0,0 +1,221 @@ +import logging +from functools import wraps +from typing import Any, Callable + +import requests +from django.db.utils import IntegrityError +from rest_framework import status, viewsets +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response + +from integrations.gitlab.client import ( + create_flagsmith_flag_label, + fetch_gitlab_project_members, + fetch_gitlab_projects, + fetch_search_gitlab_resource, +) +from integrations.gitlab.dataclasses import ( + IssueQueryParams, + PaginatedQueryParams, + ProjectQueryParams, +) +from integrations.gitlab.exceptions import DuplicateGitLabIntegration +from integrations.gitlab.models import GitLabConfiguration +from integrations.gitlab.permissions import HasPermissionToGitLabConfiguration +from integrations.gitlab.serializers import ( + GitLabConfigurationCreateSerializer, + GitLabConfigurationSerializer, + IssueQueryParamsSerializer, + PaginatedQueryParamsSerializer, + ProjectQueryParamsSerializer, +) + +logger = logging.getLogger(__name__) + + +def gitlab_auth_required( + func: Callable[..., Response], +) -> Callable[..., Response]: + @wraps(func) + def wrapper(request: Request, project_pk: int, **kwargs: Any) -> Response: + if not GitLabConfiguration.has_gitlab_configuration(project_id=project_pk): + return Response( + data={ + "detail": "This project doesn't have a valid GitLab configuration" + }, + content_type="application/json", + status=status.HTTP_400_BAD_REQUEST, + ) + return func(request, project_pk, **kwargs) + + return wrapper + + +def gitlab_api_call_error_handler( + error: str | None = None, +) -> Callable[..., Callable[..., Response]]: + def decorator( + func: Callable[..., Response], + ) -> Callable[..., Response]: + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Response: + default_error = "Failed to retrieve requested information from GitLab API." + try: + return func(*args, **kwargs) + except ValueError as e: + detail = f"{error or default_error} Error: {e}" + logger.error(detail, exc_info=e) + return Response( + data={"detail": detail}, + content_type="application/json", + status=status.HTTP_400_BAD_REQUEST, + ) + except requests.RequestException as e: + detail = f"{error or default_error} Error: {e}" + logger.error(detail, exc_info=e) + return Response( + data={"detail": detail}, + content_type="application/json", + status=status.HTTP_502_BAD_GATEWAY, + ) + + return wrapper + + return decorator + + +class GitLabConfigurationViewSet(viewsets.ModelViewSet): # type: ignore[type-arg] + permission_classes = ( + IsAuthenticated, + HasPermissionToGitLabConfiguration, + ) + model_class = GitLabConfiguration + + def get_serializer_class( + self, + ) -> type[GitLabConfigurationSerializer] | type[GitLabConfigurationCreateSerializer]: + if self.action == "create": + return GitLabConfigurationCreateSerializer + return GitLabConfigurationSerializer + + def perform_create( # type: ignore[override] + self, + serializer: GitLabConfigurationCreateSerializer, + ) -> None: + project_id = self.kwargs["project_pk"] + serializer.save(project_id=project_id) + if serializer.validated_data.get( + "tagging_enabled", False + ) and serializer.validated_data.get("gitlab_project_id"): + create_flagsmith_flag_label( + instance_url=serializer.validated_data["gitlab_instance_url"], + access_token=serializer.validated_data["access_token"], + gitlab_project_id=serializer.validated_data["gitlab_project_id"], + ) + + def get_queryset(self) -> Any: + if getattr(self, "swagger_fake_view", False): + return GitLabConfiguration.objects.none() + return GitLabConfiguration.objects.filter(project_id=self.kwargs["project_pk"]) + + def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: + try: + return super().create(request, *args, **kwargs) + except IntegrityError as e: + if "already exists" in str(e): + raise DuplicateGitLabIntegration from e + raise + + def update(self, request: Request, *args: Any, **kwargs: Any) -> Response: + response: Response = super().update(request, *args, **kwargs) + instance = self.get_object() + if request.data.get("tagging_enabled", False) and instance.gitlab_project_id: + create_flagsmith_flag_label( + instance_url=instance.gitlab_instance_url, + access_token=instance.access_token, + gitlab_project_id=instance.gitlab_project_id, + ) + return response + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated, HasPermissionToGitLabConfiguration]) +@gitlab_auth_required +@gitlab_api_call_error_handler(error="Failed to retrieve GitLab merge requests.") +def fetch_merge_requests(request: Request, project_pk: int) -> Response: + query_serializer = IssueQueryParamsSerializer(data=request.query_params) + if not query_serializer.is_valid(): + return Response({"error": query_serializer.errors}, status=400) + + gitlab_config = GitLabConfiguration.objects.get( + project_id=project_pk, deleted_at__isnull=True + ) + data = fetch_search_gitlab_resource( + resource_type="merge_requests", + instance_url=gitlab_config.gitlab_instance_url, + access_token=gitlab_config.access_token, + params=IssueQueryParams(**query_serializer.validated_data), + ) + return Response(data=data, status=status.HTTP_200_OK) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated, HasPermissionToGitLabConfiguration]) +@gitlab_auth_required +@gitlab_api_call_error_handler(error="Failed to retrieve GitLab issues.") +def fetch_issues(request: Request, project_pk: int) -> Response: + query_serializer = IssueQueryParamsSerializer(data=request.query_params) + if not query_serializer.is_valid(): + return Response({"error": query_serializer.errors}, status=400) + + gitlab_config = GitLabConfiguration.objects.get( + project_id=project_pk, deleted_at__isnull=True + ) + data = fetch_search_gitlab_resource( + resource_type="issues", + instance_url=gitlab_config.gitlab_instance_url, + access_token=gitlab_config.access_token, + params=IssueQueryParams(**query_serializer.validated_data), + ) + return Response(data=data, status=status.HTTP_200_OK) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated, HasPermissionToGitLabConfiguration]) +@gitlab_api_call_error_handler(error="Failed to retrieve GitLab projects.") +def fetch_projects(request: Request, project_pk: int) -> Response: + query_serializer = PaginatedQueryParamsSerializer(data=request.query_params) + if not query_serializer.is_valid(): + return Response({"error": query_serializer.errors}, status=400) + + gitlab_config = GitLabConfiguration.objects.get( + project_id=project_pk, deleted_at__isnull=True + ) + data = fetch_gitlab_projects( + instance_url=gitlab_config.gitlab_instance_url, + access_token=gitlab_config.access_token, + params=PaginatedQueryParams(**query_serializer.validated_data), + ) + return Response(data=data, status=status.HTTP_200_OK) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated, HasPermissionToGitLabConfiguration]) +@gitlab_auth_required +@gitlab_api_call_error_handler(error="Failed to retrieve GitLab project members.") +def fetch_project_members(request: Request, project_pk: int) -> Response: + query_serializer = ProjectQueryParamsSerializer(data=request.query_params) + if not query_serializer.is_valid(): + return Response({"error": query_serializer.errors}, status=400) + + gitlab_config = GitLabConfiguration.objects.get( + project_id=project_pk, deleted_at__isnull=True + ) + data = fetch_gitlab_project_members( + instance_url=gitlab_config.gitlab_instance_url, + access_token=gitlab_config.access_token, + params=ProjectQueryParams(**query_serializer.validated_data), + ) + return Response(data=data, status=status.HTTP_200_OK) diff --git a/api/projects/code_references/migrations/0003_add_gitlab_vcs_provider.py b/api/projects/code_references/migrations/0003_add_gitlab_vcs_provider.py new file mode 100644 index 000000000000..056fe814403e --- /dev/null +++ b/api/projects/code_references/migrations/0003_add_gitlab_vcs_provider.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("code_references", "0002_add_project_repo_created_index"), + ] + + operations = [ + migrations.AlterField( + model_name="featureflagcodereferencesscan", + name="vcs_provider", + field=models.CharField( + choices=[("github", "GitHub"), ("gitlab", "GitLab")], + default="github", + max_length=50, + ), + ), + ] diff --git a/api/projects/code_references/types.py b/api/projects/code_references/types.py index 346dde597742..d8277ec28c98 100644 --- a/api/projects/code_references/types.py +++ b/api/projects/code_references/types.py @@ -7,6 +7,7 @@ class VCSProvider(TextChoices): GITHUB = "github", "GitHub" + GITLAB = "gitlab", "GitLab" class JSONCodeReference(TypedDict): diff --git a/api/projects/tags/migrations/0009_add_gitlab_tag_type.py b/api/projects/tags/migrations/0009_add_gitlab_tag_type.py new file mode 100644 index 000000000000..65b238e06d4b --- /dev/null +++ b/api/projects/tags/migrations/0009_add_gitlab_tag_type.py @@ -0,0 +1,30 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("tags", "0008_alter_tag_type"), + ] + + operations = [ + migrations.AlterField( + model_name="tag", + name="type", + field=models.CharField( + choices=[ + ("NONE", "None"), + ("STALE", "Stale"), + ("GITHUB", "Github"), + ("GITLAB", "Gitlab"), + ("UNHEALTHY", "Unhealthy"), + ], + default="NONE", + help_text=( + "Field used to provide a consistent identifier for " + "the FE and API to use for business logic." + ), + max_length=100, + ), + ), + ] diff --git a/api/projects/tags/models.py b/api/projects/tags/models.py index 2c3de7f32293..e29719947056 100644 --- a/api/projects/tags/models.py +++ b/api/projects/tags/models.py @@ -8,6 +8,7 @@ class TagType(models.Choices): NONE = "NONE" STALE = "STALE" GITHUB = "GITHUB" + GITLAB = "GITLAB" UNHEALTHY = "UNHEALTHY" diff --git a/api/projects/urls.py b/api/projects/urls.py index e65b86ffb19f..09f571caec86 100644 --- a/api/projects/urls.py +++ b/api/projects/urls.py @@ -19,6 +19,13 @@ from features.multivariate.views import MultivariateFeatureOptionViewSet from features.views import FeatureViewSet from integrations.datadog.views import DataDogConfigurationViewSet +from integrations.gitlab.views import ( + GitLabConfigurationViewSet, + fetch_issues as gitlab_fetch_issues, + fetch_merge_requests, + fetch_project_members, + fetch_projects, +) from integrations.grafana.views import GrafanaProjectConfigurationViewSet from integrations.launch_darkly.views import LaunchDarklyImportRequestViewSet from integrations.new_relic.views import NewRelicConfigurationViewSet @@ -65,6 +72,11 @@ LaunchDarklyImportRequestViewSet, basename="imports-launch-darkly", ) +projects_router.register( + r"integrations/gitlab", + GitLabConfigurationViewSet, + basename="integrations-gitlab", +) projects_router.register( r"integrations/grafana", GrafanaProjectConfigurationViewSet, @@ -139,4 +151,24 @@ FeatureImportListView.as_view(), name="feature-imports", ), + path( + "/gitlab/issues/", + gitlab_fetch_issues, + name="get-gitlab-issues", + ), + path( + "/gitlab/project-members/", + fetch_project_members, + name="get-gitlab-project-members", + ), + path( + "/gitlab/merge-requests/", + fetch_merge_requests, + name="get-gitlab-merge-requests", + ), + path( + "/gitlab/projects/", + fetch_projects, + name="get-gitlab-projects", + ), ] diff --git a/api/tests/unit/integrations/gitlab/test_unit_gitlab_views.py b/api/tests/unit/integrations/gitlab/test_unit_gitlab_views.py new file mode 100644 index 000000000000..070a5519f7cd --- /dev/null +++ b/api/tests/unit/integrations/gitlab/test_unit_gitlab_views.py @@ -0,0 +1,439 @@ +import pytest +import requests as _requests +import responses +from django.urls import reverse +from pytest_mock import MockerFixture +from rest_framework import status +from rest_framework.test import APIClient + +from features.feature_external_resources.models import FeatureExternalResource +from features.models import Feature +from integrations.gitlab.models import GitLabConfiguration +from projects.models import Project + +GITLAB_INSTANCE_URL = "https://gitlab.example.com" + + +def test_get_gitlab_configuration__no_configuration_exists__returns_200( + admin_client_new: APIClient, + project: Project, +) -> None: + # Given + url = reverse( + "api-v1:projects:integrations-gitlab-list", + kwargs={"project_pk": project.id}, + ) + + # When + response = admin_client_new.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + + +def test_get_gitlab_configuration__existing_config__returns_list( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + url = reverse( + "api-v1:projects:integrations-gitlab-list", + kwargs={"project_pk": project.id}, + ) + + # When + response = admin_client_new.get(url) + response_json = response.json() + + # Then + assert response.status_code == status.HTTP_200_OK + assert len(response_json["results"]) == 1 + assert response_json["results"][0]["id"] == gitlab_configuration.id + assert "access_token" not in response_json["results"][0] + + +def test_create_gitlab_configuration__valid_data__returns_201( + admin_client_new: APIClient, + project: Project, +) -> None: + # Given + data = { + "gitlab_instance_url": GITLAB_INSTANCE_URL, + "access_token": "new-token", + "webhook_secret": "new-secret", + } + url = reverse( + "api-v1:projects:integrations-gitlab-list", + kwargs={"project_pk": project.id}, + ) + + # When + response = admin_client_new.post(url, data) + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert GitLabConfiguration.objects.filter(project=project).exists() + assert "access_token" not in response.json() + + +def test_create_gitlab_configuration__tagging_enabled__creates_label( + admin_client_new: APIClient, + project: Project, + mocker: MockerFixture, +) -> None: + # Given + mock_create_label = mocker.patch( + "integrations.gitlab.views.create_flagsmith_flag_label" + ) + data = { + "gitlab_instance_url": GITLAB_INSTANCE_URL, + "access_token": "new-token", + "webhook_secret": "new-secret", + "tagging_enabled": True, + "gitlab_project_id": 42, + } + url = reverse( + "api-v1:projects:integrations-gitlab-list", + kwargs={"project_pk": project.id}, + ) + + # When + response = admin_client_new.post(url, data, format="json") + + # Then + assert response.status_code == status.HTTP_201_CREATED + mock_create_label.assert_called_once_with( + instance_url=GITLAB_INSTANCE_URL, + access_token="new-token", + gitlab_project_id=42, + ) + + +def test_update_gitlab_configuration__tagging_enabled__creates_label( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mock_create_label = mocker.patch( + "integrations.gitlab.views.create_flagsmith_flag_label" + ) + data = { + "gitlab_instance_url": gitlab_configuration.gitlab_instance_url, + "access_token": gitlab_configuration.access_token, + "webhook_secret": gitlab_configuration.webhook_secret, + "tagging_enabled": True, + "gitlab_project_id": gitlab_configuration.gitlab_project_id, + } + url = reverse( + "api-v1:projects:integrations-gitlab-detail", + args=[project.id, gitlab_configuration.id], + ) + + # When + response = admin_client_new.put(url, data, format="json") + + # Then + assert response.status_code == status.HTTP_200_OK + mock_create_label.assert_called_once_with( + instance_url=gitlab_configuration.gitlab_instance_url, + access_token=gitlab_configuration.access_token, + gitlab_project_id=gitlab_configuration.gitlab_project_id, + ) + + +def test_delete_gitlab_configuration__valid_configuration__returns_204( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + url = reverse( + "api-v1:projects:integrations-gitlab-detail", + args=[project.id, gitlab_configuration.id], + ) + + # When + response = admin_client_new.delete(url) + + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not GitLabConfiguration.objects.filter(id=gitlab_configuration.id).exists() + + +def test_delete_gitlab_configuration__has_external_resources__removes_them( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, + feature: Feature, + mocker: MockerFixture, +) -> None: + # Given + FeatureExternalResource.objects.create( + url="https://gitlab.example.com/testgroup/testrepo/-/issues/1", + type="GITLAB_ISSUE", + feature=feature, + metadata='{"state": "opened"}', + ) + url = reverse( + "api-v1:projects:integrations-gitlab-detail", + args=[project.id, gitlab_configuration.id], + ) + assert FeatureExternalResource.objects.filter(feature=feature).exists() + + # When + response = admin_client_new.delete(url) + + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not FeatureExternalResource.objects.filter( + feature=feature, type="GITLAB_ISSUE" + ).exists() + + +def test_gitlab_configuration__non_existent_project__returns_403( + admin_client_new: APIClient, +) -> None: + # Given + url = reverse( + "api-v1:projects:integrations-gitlab-list", + kwargs={"project_pk": 999999}, + ) + + # When + response = admin_client_new.get(url) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@responses.activate +def test_fetch_projects__valid_request__returns_projects( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + responses.add( + method="GET", + url=f"{GITLAB_INSTANCE_URL}/api/v4/projects", + status=200, + json=[ + { + "id": 1, + "name": "My Project", + "path_with_namespace": "testgroup/myproject", + } + ], + headers={"x-page": "1", "x-total-pages": "1", "x-total": "1"}, + ) + url = reverse("api-v1:projects:get-gitlab-projects", args=[project.id]) + + # When + response = admin_client_new.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["results"]) == 1 + assert response.json()["results"][0]["name"] == "My Project" + + +@responses.activate +def test_fetch_issues__valid_request__returns_results( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + responses.add( + method="GET", + url=f"{GITLAB_INSTANCE_URL}/api/v4/projects/1/issues", + status=200, + json=[ + { + "web_url": f"{GITLAB_INSTANCE_URL}/testgroup/testrepo/-/issues/1", + "id": 101, + "title": "Test Issue", + "iid": 1, + "state": "opened", + } + ], + headers={"x-page": "1", "x-total-pages": "1", "x-total": "1"}, + ) + url = reverse("api-v1:projects:get-gitlab-issues", args=[project.id]) + + # When + response = admin_client_new.get(url, data={"gitlab_project_id": 1}) + + # Then + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["results"]) == 1 + assert response.json()["results"][0]["title"] == "Test Issue" + + +@responses.activate +def test_fetch_merge_requests__valid_request__returns_results( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + responses.add( + method="GET", + url=f"{GITLAB_INSTANCE_URL}/api/v4/projects/1/merge_requests", + status=200, + json=[ + { + "web_url": f"{GITLAB_INSTANCE_URL}/testgroup/testrepo/-/merge_requests/1", + "id": 201, + "title": "Test MR", + "iid": 1, + "state": "opened", + "draft": False, + "merged_at": None, + } + ], + headers={"x-page": "1", "x-total-pages": "1", "x-total": "1"}, + ) + url = reverse("api-v1:projects:get-gitlab-merge-requests", args=[project.id]) + + # When + response = admin_client_new.get(url, data={"gitlab_project_id": 1}) + + # Then + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["results"]) == 1 + assert response.json()["results"][0]["title"] == "Test MR" + assert response.json()["results"][0]["merged"] is False + + +@responses.activate +def test_fetch_project_members__valid_request__returns_members( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given + responses.add( + method="GET", + url=f"{GITLAB_INSTANCE_URL}/api/v4/projects/1/members", + status=200, + json=[ + { + "username": "jdoe", + "avatar_url": f"{GITLAB_INSTANCE_URL}/avatar/jdoe", + "name": "John Doe", + } + ], + headers={"x-page": "1", "x-total-pages": "1"}, + ) + url = reverse("api-v1:projects:get-gitlab-project-members", args=[project.id]) + + # When + response = admin_client_new.get(url, data={"gitlab_project_id": 1}) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["results"][0]["username"] == "jdoe" + + +def test_fetch_issues__missing_config__returns_400( + admin_client_new: APIClient, + project: Project, +) -> None: + # Given + url = reverse("api-v1:projects:get-gitlab-issues", args=[project.id]) + + # When + response = admin_client_new.get(url, data={"gitlab_project_id": 1}) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "doesn't have a valid GitLab configuration" in response.json()["detail"] + + +def test_fetch_merge_requests__missing_config__returns_400( + admin_client_new: APIClient, + project: Project, +) -> None: + # Given + url = reverse("api-v1:projects:get-gitlab-merge-requests", args=[project.id]) + + # When + response = admin_client_new.get(url, data={"gitlab_project_id": 1}) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "doesn't have a valid GitLab configuration" in response.json()["detail"] + + +@pytest.mark.parametrize( + "url_name", + [ + "get-gitlab-merge-requests", + "get-gitlab-issues", + "get-gitlab-projects", + "get-gitlab-project-members", + ], +) +def test_fetch_resource__invalid_query_params__returns_400( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, + url_name: str, +) -> None: + # Given + url = reverse(f"api-v1:projects:{url_name}", args=[project.id]) + + # When + response = admin_client_new.get(url, data={"gitlab_project_id": "1", "page": "abc"}) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "error" in response.json() + + +def test_gitlab_api_call_error_handler__value_error__returns_400( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "integrations.gitlab.views.fetch_search_gitlab_resource", + side_effect=ValueError("bad value"), + ) + url = reverse("api-v1:projects:get-gitlab-issues", args=[project.id]) + + # When + response = admin_client_new.get(url, data={"gitlab_project_id": 1}) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Failed to retrieve GitLab issues" in response.json()["detail"] + assert "bad value" in response.json()["detail"] + + +def test_gitlab_api_call_error_handler__request_exception__returns_502( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "integrations.gitlab.views.fetch_search_gitlab_resource", + side_effect=_requests.RequestException("connection failed"), + ) + url = reverse("api-v1:projects:get-gitlab-issues", args=[project.id]) + + # When + response = admin_client_new.get(url, data={"gitlab_project_id": 1}) + + # Then + assert response.status_code == status.HTTP_502_BAD_GATEWAY + assert "Failed to retrieve GitLab issues" in response.json()["detail"] + assert "connection failed" in response.json()["detail"] From cb3a6ef66b67867c8979f9c9142b97e4707be4db Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:21:52 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/integrations/gitlab/exceptions.py | 4 +++- api/integrations/gitlab/views.py | 4 +++- api/projects/urls.py | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/api/integrations/gitlab/exceptions.py b/api/integrations/gitlab/exceptions.py index f332841d7bd8..be94cc3b13e8 100644 --- a/api/integrations/gitlab/exceptions.py +++ b/api/integrations/gitlab/exceptions.py @@ -3,4 +3,6 @@ class DuplicateGitLabIntegration(APIException): status_code = 400 - default_detail = "A GitLab integration already exists for this project and repository." + default_detail = ( + "A GitLab integration already exists for this project and repository." + ) diff --git a/api/integrations/gitlab/views.py b/api/integrations/gitlab/views.py index c7e21e96508f..a6e31638e18b 100644 --- a/api/integrations/gitlab/views.py +++ b/api/integrations/gitlab/views.py @@ -95,7 +95,9 @@ class GitLabConfigurationViewSet(viewsets.ModelViewSet): # type: ignore[type-ar def get_serializer_class( self, - ) -> type[GitLabConfigurationSerializer] | type[GitLabConfigurationCreateSerializer]: + ) -> ( + type[GitLabConfigurationSerializer] | type[GitLabConfigurationCreateSerializer] + ): if self.action == "create": return GitLabConfigurationCreateSerializer return GitLabConfigurationSerializer diff --git a/api/projects/urls.py b/api/projects/urls.py index 09f571caec86..4889ff793be5 100644 --- a/api/projects/urls.py +++ b/api/projects/urls.py @@ -21,11 +21,13 @@ from integrations.datadog.views import DataDogConfigurationViewSet from integrations.gitlab.views import ( GitLabConfigurationViewSet, - fetch_issues as gitlab_fetch_issues, fetch_merge_requests, fetch_project_members, fetch_projects, ) +from integrations.gitlab.views import ( + fetch_issues as gitlab_fetch_issues, +) from integrations.grafana.views import GrafanaProjectConfigurationViewSet from integrations.launch_darkly.views import LaunchDarklyImportRequestViewSet from integrations.new_relic.views import NewRelicConfigurationViewSet