From f7460631289a538faf76fb599a0218b50d9abd63 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 14 Apr 2026 15:24:50 -0300 Subject: [PATCH 1/6] Introduce GitLab integration basics --- api/app/settings/common.py | 1 + api/integrations/gitlab/__init__.py | 0 api/integrations/gitlab/apps.py | 5 + .../gitlab/migrations/0001_initial.py | 69 +++++++ .../gitlab/migrations/__init__.py | 0 api/integrations/gitlab/models.py | 13 ++ api/integrations/gitlab/serializers.py | 11 ++ api/integrations/gitlab/views.py | 42 ++++ api/projects/urls.py | 6 + .../unit/integrations/gitlab/__init__.py | 0 .../unit/integrations/gitlab/test_views.py | 179 ++++++++++++++++++ 11 files changed, 326 insertions(+) create mode 100644 api/integrations/gitlab/__init__.py create mode 100644 api/integrations/gitlab/apps.py create mode 100644 api/integrations/gitlab/migrations/0001_initial.py create mode 100644 api/integrations/gitlab/migrations/__init__.py create mode 100644 api/integrations/gitlab/models.py create mode 100644 api/integrations/gitlab/serializers.py create mode 100644 api/integrations/gitlab/views.py create mode 100644 api/tests/unit/integrations/gitlab/__init__.py create mode 100644 api/tests/unit/integrations/gitlab/test_views.py 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..3c84bc521d37 --- /dev/null +++ b/api/integrations/gitlab/serializers.py @@ -0,0 +1,11 @@ +from integrations.common.serializers import BaseProjectIntegrationModelSerializer +from integrations.gitlab.models import GitLabConfiguration + + +class GitLabConfigurationSerializer(BaseProjectIntegrationModelSerializer): + class Meta: + model = GitLabConfiguration + fields = ("id", "gitlab_instance_url", "access_token") + extra_kwargs = { + "access_token": {"write_only": True}, + } diff --git a/api/integrations/gitlab/views.py b/api/integrations/gitlab/views.py new file mode 100644 index 000000000000..1aa3172add99 --- /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..e7f40a35ea18 --- /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_hides_token( + admin_client_new: APIClient, + project: Project, + log: StructuredLogCapture, +) -> None: + # 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 "access_token" not in response.json() + + 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: + # 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_hides_token( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, + log: StructuredLogCapture, +) -> None: + # 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 "access_token" not in response.json() + + 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: + # 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__hides_token( + admin_client_new: APIClient, + project: Project, + gitlab_configuration: GitLabConfiguration, +) -> None: + # 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 "access_token" not in results[0] + assert results[0]["gitlab_instance_url"] == "https://gitlab.example.com" + + +def test_create_configuration__non_admin__returns_403( + staff_client: APIClient, + project: Project, +) -> None: + # 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: + # 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 From 9b5078f6ead16f181f0fe280b2fda47d721b036d Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 14 Apr 2026 16:56:52 -0300 Subject: [PATCH 2/6] Fix missing access token in REST contract --- api/integrations/gitlab/serializers.py | 12 +++++++++--- api/tests/unit/integrations/gitlab/test_views.py | 12 ++++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/api/integrations/gitlab/serializers.py b/api/integrations/gitlab/serializers.py index 3c84bc521d37..20f31b6ef43f 100644 --- a/api/integrations/gitlab/serializers.py +++ b/api/integrations/gitlab/serializers.py @@ -1,11 +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") - extra_kwargs = { - "access_token": {"write_only": True}, - } + + 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/tests/unit/integrations/gitlab/test_views.py b/api/tests/unit/integrations/gitlab/test_views.py index e7f40a35ea18..75cad05d9a0d 100644 --- a/api/tests/unit/integrations/gitlab/test_views.py +++ b/api/tests/unit/integrations/gitlab/test_views.py @@ -16,7 +16,7 @@ def gitlab_configuration(project: Project) -> GitLabConfiguration: ) -def test_create_configuration__valid_data__persists_and_hides_token( +def test_create_configuration__valid_data__persists_and_masks_token( admin_client_new: APIClient, project: Project, log: StructuredLogCapture, @@ -33,7 +33,7 @@ def test_create_configuration__valid_data__persists_and_hides_token( # Then assert response.status_code == status.HTTP_201_CREATED - assert "access_token" not in response.json() + assert response.json()["access_token"] == "write-only" config = GitLabConfiguration.objects.get(project=project) assert config.gitlab_instance_url == "https://gitlab.example.com" @@ -69,7 +69,7 @@ def test_create_configuration__already_exists__returns_400( assert response.status_code == status.HTTP_400_BAD_REQUEST -def test_update_configuration__valid_data__persists_and_hides_token( +def test_update_configuration__valid_data__persists_and_masks_token( admin_client_new: APIClient, project: Project, gitlab_configuration: GitLabConfiguration, @@ -87,7 +87,7 @@ def test_update_configuration__valid_data__persists_and_hides_token( # Then assert response.status_code == status.HTTP_200_OK - assert "access_token" not in response.json() + assert response.json()["access_token"] == "write-only" gitlab_configuration.refresh_from_db() assert gitlab_configuration.gitlab_instance_url == "https://gitlab.updated.com" @@ -129,7 +129,7 @@ def test_delete_configuration__existing__soft_deletes( ] -def test_list_configurations__existing__hides_token( +def test_list_configurations__existing__masks_token( admin_client_new: APIClient, project: Project, gitlab_configuration: GitLabConfiguration, @@ -143,8 +143,8 @@ def test_list_configurations__existing__hides_token( assert response.status_code == status.HTTP_200_OK results = response.json() assert len(results) == 1 - assert "access_token" not in results[0] 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( From cc3811b7674d47dbe7880028a8d8f1838c490de6 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 14 Apr 2026 16:58:40 -0300 Subject: [PATCH 3/6] Add GitLab to the Integrations tab --- frontend/common/stores/default-flags.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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.', From 626f8760a3d66d20d34534662cbb309ecab35684 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 14 Apr 2026 17:04:31 -0300 Subject: [PATCH 4/6] Update GitLab logo with latest official --- .../web/static/images/integrations/gitlab.svg | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) 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 From 1987f2db11415fef8067f20503bbe07a9b7e9340 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 14 Apr 2026 17:16:00 -0300 Subject: [PATCH 5/6] Good linter --- api/tests/unit/integrations/gitlab/test_views.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/api/tests/unit/integrations/gitlab/test_views.py b/api/tests/unit/integrations/gitlab/test_views.py index 75cad05d9a0d..61d92f955a44 100644 --- a/api/tests/unit/integrations/gitlab/test_views.py +++ b/api/tests/unit/integrations/gitlab/test_views.py @@ -21,7 +21,7 @@ def test_create_configuration__valid_data__persists_and_masks_token( project: Project, log: StructuredLogCapture, ) -> None: - # When + # Given / When response = admin_client_new.post( f"/api/v1/projects/{project.id}/integrations/gitlab/", data={ @@ -55,7 +55,7 @@ def test_create_configuration__already_exists__returns_400( project: Project, gitlab_configuration: GitLabConfiguration, ) -> None: - # When + # Given / When response = admin_client_new.post( f"/api/v1/projects/{project.id}/integrations/gitlab/", data={ @@ -75,7 +75,7 @@ def test_update_configuration__valid_data__persists_and_masks_token( gitlab_configuration: GitLabConfiguration, log: StructuredLogCapture, ) -> None: - # When + # Given / When response = admin_client_new.put( f"/api/v1/projects/{project.id}/integrations/gitlab/{gitlab_configuration.id}/", data={ @@ -110,7 +110,7 @@ def test_delete_configuration__existing__soft_deletes( gitlab_configuration: GitLabConfiguration, log: StructuredLogCapture, ) -> None: - # When + # Given / When response = admin_client_new.delete( f"/api/v1/projects/{project.id}/integrations/gitlab/{gitlab_configuration.id}/", ) @@ -134,7 +134,7 @@ def test_list_configurations__existing__masks_token( project: Project, gitlab_configuration: GitLabConfiguration, ) -> None: - # When + # Given / When response = admin_client_new.get( f"/api/v1/projects/{project.id}/integrations/gitlab/", ) @@ -151,7 +151,7 @@ def test_create_configuration__non_admin__returns_403( staff_client: APIClient, project: Project, ) -> None: - # When + # Given / When response = staff_client.post( f"/api/v1/projects/{project.id}/integrations/gitlab/", data={ @@ -170,7 +170,7 @@ def test_delete_configuration__non_admin__returns_403( project: Project, gitlab_configuration: GitLabConfiguration, ) -> None: - # When + # Given / When response = staff_client.delete( f"/api/v1/projects/{project.id}/integrations/gitlab/{gitlab_configuration.id}/", ) From c95b3140e44bd568386684d33b3f8325727257f8 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Wed, 15 Apr 2026 21:08:23 -0300 Subject: [PATCH 6/6] Better telemetry --- api/integrations/gitlab/views.py | 4 ++-- api/tests/unit/integrations/gitlab/test_views.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api/integrations/gitlab/views.py b/api/integrations/gitlab/views.py index 1aa3172add99..161a7707f240 100644 --- a/api/integrations/gitlab/views.py +++ b/api/integrations/gitlab/views.py @@ -16,8 +16,8 @@ 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, + project__id=instance.project.id, + organisation__id=instance.project.organisation_id, ) def perform_create(self, serializer: GitLabConfigurationSerializer) -> None: # type: ignore[override] diff --git a/api/tests/unit/integrations/gitlab/test_views.py b/api/tests/unit/integrations/gitlab/test_views.py index 61d92f955a44..12638a3f89d9 100644 --- a/api/tests/unit/integrations/gitlab/test_views.py +++ b/api/tests/unit/integrations/gitlab/test_views.py @@ -44,8 +44,8 @@ def test_create_configuration__valid_data__persists_and_masks_token( "event": "gitlab-configuration-created", "level": "info", "gitlab_instance_url": "https://gitlab.example.com", - "project_id": project.id, - "organisation_id": project.organisation_id, + "project__id": project.id, + "organisation__id": project.organisation_id, }, ] @@ -98,8 +98,8 @@ def test_update_configuration__valid_data__persists_and_masks_token( "event": "gitlab-configuration-updated", "level": "info", "gitlab_instance_url": "https://gitlab.updated.com", - "project_id": project.id, - "organisation_id": project.organisation_id, + "project__id": project.id, + "organisation__id": project.organisation_id, }, ] @@ -123,8 +123,8 @@ def test_delete_configuration__existing__soft_deletes( { "event": "gitlab-configuration-deleted", "level": "info", - "project_id": project.id, - "organisation_id": project.organisation_id, + "project__id": project.id, + "organisation__id": project.organisation_id, }, ]