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