diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 0eb5a9c742e4..0674c2eb1718 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -154,6 +154,7 @@ "integrations.flagsmith", "integrations.launch_darkly", "integrations.github", + "integrations.gitlab", "integrations.grafana", # Rate limiting admin endpoints "axes", diff --git a/api/integrations/gitlab/__init__.py b/api/integrations/gitlab/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/gitlab/apps.py b/api/integrations/gitlab/apps.py new file mode 100644 index 000000000000..ad1b3f3221de --- /dev/null +++ b/api/integrations/gitlab/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class GitLabIntegrationConfig(AppConfig): + name = "integrations.gitlab" diff --git a/api/integrations/gitlab/migrations/0001_initial.py b/api/integrations/gitlab/migrations/0001_initial.py new file mode 100644 index 000000000000..d67c979e556d --- /dev/null +++ b/api/integrations/gitlab/migrations/0001_initial.py @@ -0,0 +1,69 @@ +# Generated by Django 5.2.13 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("projects", "0028_add_enforce_feature_owners_to_project"), + ] + + 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(max_length=200), + ), + ( + "access_token", + models.CharField(max_length=300), + ), + ( + "project", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="gitlab_config", + to="projects.project", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/api/integrations/gitlab/migrations/__init__.py b/api/integrations/gitlab/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/integrations/gitlab/models.py b/api/integrations/gitlab/models.py new file mode 100644 index 000000000000..068fa3008e44 --- /dev/null +++ b/api/integrations/gitlab/models.py @@ -0,0 +1,13 @@ +from django.db import models + +from core.models import SoftDeleteExportableModel + + +class GitLabConfiguration(SoftDeleteExportableModel): + project = models.OneToOneField( + "projects.Project", + on_delete=models.CASCADE, + related_name="gitlab_config", + ) + gitlab_instance_url = models.URLField(max_length=200) + access_token = models.CharField(max_length=300) diff --git a/api/integrations/gitlab/serializers.py b/api/integrations/gitlab/serializers.py new file mode 100644 index 000000000000..20f31b6ef43f --- /dev/null +++ b/api/integrations/gitlab/serializers.py @@ -0,0 +1,17 @@ +from typing import Any + +from integrations.common.serializers import BaseProjectIntegrationModelSerializer +from integrations.gitlab.models import GitLabConfiguration + +WRITE_ONLY_PLACEHOLDER = "write-only" + + +class GitLabConfigurationSerializer(BaseProjectIntegrationModelSerializer): + class Meta: + model = GitLabConfiguration + fields = ("id", "gitlab_instance_url", "access_token") + + def to_representation(self, instance: GitLabConfiguration) -> dict[str, Any]: + data = super().to_representation(instance) + data["access_token"] = WRITE_ONLY_PLACEHOLDER + return data diff --git a/api/integrations/gitlab/views.py b/api/integrations/gitlab/views.py new file mode 100644 index 000000000000..161a7707f240 --- /dev/null +++ b/api/integrations/gitlab/views.py @@ -0,0 +1,42 @@ +import structlog + +from integrations.common.views import ProjectIntegrationBaseViewSet +from integrations.gitlab.models import GitLabConfiguration +from integrations.gitlab.serializers import GitLabConfigurationSerializer + +logger = structlog.get_logger("gitlab") + + +class GitLabConfigurationViewSet(ProjectIntegrationBaseViewSet): + serializer_class = GitLabConfigurationSerializer # type: ignore[assignment] + model_class = GitLabConfiguration # type: ignore[assignment] + pagination_class = None + + def _log_for( + self, instance: GitLabConfiguration + ) -> structlog.typing.FilteringBoundLogger: + return logger.bind( # type: ignore[no-any-return] + project__id=instance.project.id, + organisation__id=instance.project.organisation_id, + ) + + def perform_create(self, serializer: GitLabConfigurationSerializer) -> None: # type: ignore[override] + super().perform_create(serializer) + instance: GitLabConfiguration = serializer.instance # type: ignore[assignment] + self._log_for(instance).info( + "gitlab-configuration-created", + gitlab_instance_url=instance.gitlab_instance_url, + ) + + def perform_update(self, serializer: GitLabConfigurationSerializer) -> None: # type: ignore[override] + super().perform_update(serializer) + instance: GitLabConfiguration = serializer.instance # type: ignore[assignment] + self._log_for(instance).info( + "gitlab-configuration-updated", + gitlab_instance_url=instance.gitlab_instance_url, + ) + + def perform_destroy(self, instance: GitLabConfiguration) -> None: + log = self._log_for(instance) + super().perform_destroy(instance) + log.info("gitlab-configuration-deleted") diff --git a/api/projects/urls.py b/api/projects/urls.py index e65b86ffb19f..07a68b79fd3d 100644 --- a/api/projects/urls.py +++ b/api/projects/urls.py @@ -19,6 +19,7 @@ from features.multivariate.views import MultivariateFeatureOptionViewSet from features.views import FeatureViewSet from integrations.datadog.views import DataDogConfigurationViewSet +from integrations.gitlab.views import GitLabConfigurationViewSet from integrations.grafana.views import GrafanaProjectConfigurationViewSet from integrations.launch_darkly.views import LaunchDarklyImportRequestViewSet from integrations.new_relic.views import NewRelicConfigurationViewSet @@ -65,6 +66,11 @@ LaunchDarklyImportRequestViewSet, basename="imports-launch-darkly", ) +projects_router.register( + r"integrations/gitlab", + GitLabConfigurationViewSet, + basename="integrations-gitlab", +) projects_router.register( r"integrations/grafana", GrafanaProjectConfigurationViewSet, diff --git a/api/tests/unit/integrations/gitlab/__init__.py b/api/tests/unit/integrations/gitlab/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/unit/integrations/gitlab/test_views.py b/api/tests/unit/integrations/gitlab/test_views.py new file mode 100644 index 000000000000..12638a3f89d9 --- /dev/null +++ b/api/tests/unit/integrations/gitlab/test_views.py @@ -0,0 +1,179 @@ +import pytest +from pytest_structlog import StructuredLogCapture +from rest_framework import status +from rest_framework.test import APIClient + +from integrations.gitlab.models import GitLabConfiguration +from projects.models import Project + + +@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="glpat-test-token", + ) + + +def test_create_configuration__valid_data__persists_and_masks_token( + admin_client_new: APIClient, + project: Project, + log: StructuredLogCapture, +) -> None: + # Given / When + response = admin_client_new.post( + f"/api/v1/projects/{project.id}/integrations/gitlab/", + data={ + "gitlab_instance_url": "https://gitlab.example.com", + "access_token": "glpat-xxxxxxxxxxxxxxxxxxxx", + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["access_token"] == "write-only" + + config = GitLabConfiguration.objects.get(project=project) + assert config.gitlab_instance_url == "https://gitlab.example.com" + assert config.access_token == "glpat-xxxxxxxxxxxxxxxxxxxx" + + assert log.events == [ + { + "event": "gitlab-configuration-created", + "level": "info", + "gitlab_instance_url": "https://gitlab.example.com", + "project__id": project.id, + "organisation__id": project.organisation_id, + }, + ] + + +def test_create_configuration__already_exists__returns_400( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given / When + response = admin_client_new.post( + f"/api/v1/projects/{project.id}/integrations/gitlab/", + data={ + "gitlab_instance_url": "https://gitlab.other.com", + "access_token": "glpat-another-token", + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_update_configuration__valid_data__persists_and_masks_token( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, + log: StructuredLogCapture, +) -> None: + # Given / When + response = admin_client_new.put( + f"/api/v1/projects/{project.id}/integrations/gitlab/{gitlab_configuration.id}/", + data={ + "gitlab_instance_url": "https://gitlab.updated.com", + "access_token": "glpat-updated-token", + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["access_token"] == "write-only" + + gitlab_configuration.refresh_from_db() + assert gitlab_configuration.gitlab_instance_url == "https://gitlab.updated.com" + assert gitlab_configuration.access_token == "glpat-updated-token" + + assert log.events == [ + { + "event": "gitlab-configuration-updated", + "level": "info", + "gitlab_instance_url": "https://gitlab.updated.com", + "project__id": project.id, + "organisation__id": project.organisation_id, + }, + ] + + +def test_delete_configuration__existing__soft_deletes( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, + log: StructuredLogCapture, +) -> None: + # Given / When + response = admin_client_new.delete( + f"/api/v1/projects/{project.id}/integrations/gitlab/{gitlab_configuration.id}/", + ) + + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not GitLabConfiguration.objects.filter(project=project).exists() + + assert log.events == [ + { + "event": "gitlab-configuration-deleted", + "level": "info", + "project__id": project.id, + "organisation__id": project.organisation_id, + }, + ] + + +def test_list_configurations__existing__masks_token( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given / When + response = admin_client_new.get( + f"/api/v1/projects/{project.id}/integrations/gitlab/", + ) + + # Then + assert response.status_code == status.HTTP_200_OK + results = response.json() + assert len(results) == 1 + assert results[0]["gitlab_instance_url"] == "https://gitlab.example.com" + assert results[0]["access_token"] == "write-only" + + +def test_create_configuration__non_admin__returns_403( + staff_client: APIClient, + project: Project, +) -> None: + # Given / When + response = staff_client.post( + f"/api/v1/projects/{project.id}/integrations/gitlab/", + data={ + "gitlab_instance_url": "https://gitlab.example.com", + "access_token": "glpat-token", + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_delete_configuration__non_admin__returns_403( + staff_client: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # Given / When + response = staff_client.delete( + f"/api/v1/projects/{project.id}/integrations/gitlab/{gitlab_configuration.id}/", + ) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/frontend/common/stores/default-flags.ts b/frontend/common/stores/default-flags.ts index 7bd8d4415353..cb8cb6f03fe0 100644 --- a/frontend/common/stores/default-flags.ts +++ b/frontend/common/stores/default-flags.ts @@ -87,6 +87,27 @@ const defaultFlags = { 'tags': ['logging'], 'title': 'Dynatrace', }, + 'gitlab': { + 'categories': ['CI/CD'], + 'description': 'Link GitLab issues and merge requests to feature flags.', + 'docs': + 'https://docs.flagsmith.com/third-party-integrations/project-management/gitlab', + 'fields': [ + { + 'key': 'gitlab_instance_url', + 'label': 'GitLab Instance URL', + }, + { + 'hidden': true, + 'key': 'access_token', + 'label': 'Access Token', + }, + ], + 'image': '/static/images/integrations/gitlab.svg', + 'perEnvironment': false, + 'project': true, + 'title': 'GitLab', + }, 'grafana': { 'description': 'Receive Flagsmith annotations to your Grafana instance on feature flag and segment changes.', diff --git a/frontend/web/static/images/integrations/gitlab.svg b/frontend/web/static/images/integrations/gitlab.svg index 031716902a11..bf8ed1a83f0d 100644 --- a/frontend/web/static/images/integrations/gitlab.svg +++ b/frontend/web/static/images/integrations/gitlab.svg @@ -1 +1,22 @@ - \ No newline at end of file + + + + + + + + + + \ No newline at end of file