Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@
"integrations.flagsmith",
"integrations.launch_darkly",
"integrations.github",
"integrations.gitlab",
"integrations.grafana",
# Rate limiting admin endpoints
"axes",
Expand Down
Empty file.
5 changes: 5 additions & 0 deletions api/integrations/gitlab/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class GitLabIntegrationConfig(AppConfig):
name = "integrations.gitlab"
69 changes: 69 additions & 0 deletions api/integrations/gitlab/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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,
},
),
]
Empty file.
13 changes: 13 additions & 0 deletions api/integrations/gitlab/models.py
Original file line number Diff line number Diff line change
@@ -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)
17 changes: 17 additions & 0 deletions api/integrations/gitlab/serializers.py
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions api/integrations/gitlab/views.py
Original file line number Diff line number Diff line change
@@ -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]
Comment thread
khvn26 marked this conversation as resolved.
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")
6 changes: 6 additions & 0 deletions api/projects/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Empty file.
179 changes: 179 additions & 0 deletions api/tests/unit/integrations/gitlab/test_views.py
Original file line number Diff line number Diff line change
@@ -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,
},
]
Comment thread
khvn26 marked this conversation as resolved.


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
21 changes: 21 additions & 0 deletions frontend/common/stores/default-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
Loading
Loading