Skip to content
Open
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
14 changes: 14 additions & 0 deletions api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
3 changes: 3 additions & 0 deletions api/features/feature_external_resources/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
23 changes: 23 additions & 0 deletions api/integrations/gitlab/constants.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,30 @@
from enum import Enum

GITLAB_API_CALLS_TIMEOUT = 10

GITLAB_FLAGSMITH_LABEL = "Flagsmith Flag"
GITLAB_FLAGSMITH_LABEL_DESCRIPTION = (
"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",
}
8 changes: 8 additions & 0 deletions api/integrations/gitlab/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from rest_framework.exceptions import APIException


class DuplicateGitLabIntegration(APIException):
status_code = 400
default_detail = (
"A GitLab integration already exists for this project and repository."
)
100 changes: 100 additions & 0 deletions api/integrations/gitlab/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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",),
},
),
]
109 changes: 109 additions & 0 deletions api/integrations/gitlab/models.py
Original file line number Diff line number Diff line change
@@ -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)
15 changes: 15 additions & 0 deletions api/integrations/gitlab/permissions.py
Original file line number Diff line number Diff line change
@@ -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]
55 changes: 55 additions & 0 deletions api/integrations/gitlab/serializers.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading