Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
8 changes: 8 additions & 0 deletions api/integrations/gitlab/client/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from integrations.gitlab.client.api import (
GitLabResourceKind,
add_flagsmith_label_to_gitlab_resource,
create_flagsmith_label,
create_issue_note,
create_merge_request_note,
create_project_hook,
delete_project_hook,
fetch_gitlab_projects,
remove_flagsmith_label_from_gitlab_resource,
search_gitlab_issues,
search_gitlab_merge_requests,
)
Expand All @@ -21,11 +25,15 @@
"GitLabPage",
"GitLabProject",
"GitLabProjectHook",
"GitLabResourceKind",
"add_flagsmith_label_to_gitlab_resource",
"create_flagsmith_label",
"create_issue_note",
"create_merge_request_note",
"create_project_hook",
"delete_project_hook",
"fetch_gitlab_projects",
"remove_flagsmith_label_from_gitlab_resource",
"search_gitlab_issues",
"search_gitlab_merge_requests",
]
82 changes: 80 additions & 2 deletions api/integrations/gitlab/client/api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from collections.abc import Mapping
from typing import Any
from typing import Any, Literal
from urllib.parse import quote

import requests
Expand All @@ -12,7 +12,14 @@
GitLabProjectHook,
T,
)
from integrations.gitlab.constants import GITLAB_CLIENT_TIMEOUT_SECONDS
from integrations.gitlab.constants import (
GITLAB_CLIENT_TIMEOUT_SECONDS,
GITLAB_FLAGSMITH_LABEL,
GITLAB_FLAGSMITH_LABEL_COLOUR,
GITLAB_FLAGSMITH_LABEL_DESCRIPTION,
)

GitLabResourceKind = Literal["issues", "merge_requests"]
Comment thread
khvn26 marked this conversation as resolved.


def _get_from_gitlab_api(
Expand All @@ -26,6 +33,7 @@ def _get_from_gitlab_api(
f"{instance_url}/api/v4/{path}",
headers={"PRIVATE-TOKEN": access_token},
params=params,
timeout=GITLAB_CLIENT_TIMEOUT_SECONDS,
)
response.raise_for_status()
return response
Expand Down Expand Up @@ -171,6 +179,7 @@ def create_project_hook(
"merge_requests_events": True,
"enable_ssl_verification": True,
},
timeout=GITLAB_CLIENT_TIMEOUT_SECONDS,
)
response.raise_for_status()
payload = response.json()
Expand All @@ -187,6 +196,7 @@ def delete_project_hook(
response = requests.delete(
f"{instance_url}/api/v4/projects/{project_id}/hooks/{hook_id}",
headers={"PRIVATE-TOKEN": access_token},
timeout=GITLAB_CLIENT_TIMEOUT_SECONDS,
)
if response.status_code == 404:
return
Expand Down Expand Up @@ -227,3 +237,71 @@ def create_merge_request_note(
timeout=GITLAB_CLIENT_TIMEOUT_SECONDS,
)
response.raise_for_status()


def create_flagsmith_label(
instance_url: str,
access_token: str,
*,
project_path: str,
) -> bool:
"""Create the "Flagsmith Feature" label on a GitLab project.

Returns True if the label was created, False if it already existed.
"""
encoded_path = quote(project_path, safe="")
try:
response = requests.post(
f"{instance_url}/api/v4/projects/{encoded_path}/labels",
headers={"PRIVATE-TOKEN": access_token},
json={
"name": GITLAB_FLAGSMITH_LABEL,
"color": GITLAB_FLAGSMITH_LABEL_COLOUR,
"description": GITLAB_FLAGSMITH_LABEL_DESCRIPTION,
},
timeout=GITLAB_CLIENT_TIMEOUT_SECONDS,
)
response.raise_for_status()
except requests.HTTPError as exc:
if exc.response is not None and exc.response.status_code == 409:
return False
raise
return True


def add_flagsmith_label_to_gitlab_resource(
instance_url: str,
access_token: str,
*,
project_path: str,
resource_kind: GitLabResourceKind,
resource_iid: int,
) -> None:
"""Apply the "Flagsmith Feature" label to a GitLab issue or MR, additively."""
encoded_path = quote(project_path, safe="")
response = requests.put(
f"{instance_url}/api/v4/projects/{encoded_path}/{resource_kind}/{resource_iid}",
headers={"PRIVATE-TOKEN": access_token},
json={"add_labels": GITLAB_FLAGSMITH_LABEL},
timeout=GITLAB_CLIENT_TIMEOUT_SECONDS,
)
response.raise_for_status()


def remove_flagsmith_label_from_gitlab_resource(
instance_url: str,
access_token: str,
*,
project_path: str,
resource_kind: GitLabResourceKind,
resource_iid: int,
) -> None:
"""Remove the "Flagsmith Feature" label from a GitLab issue or MR."""
encoded_path = quote(project_path, safe="")
response = requests.put(
f"{instance_url}/api/v4/projects/{encoded_path}/{resource_kind}/{resource_iid}",
headers={"PRIVATE-TOKEN": access_token},
json={"remove_labels": GITLAB_FLAGSMITH_LABEL},
timeout=GITLAB_CLIENT_TIMEOUT_SECONDS,
)
response.raise_for_status()
6 changes: 6 additions & 0 deletions api/integrations/gitlab/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
GITLAB_TAG_COLOR = "#FC6D26"
GITLAB_CLIENT_TIMEOUT_SECONDS = 10

GITLAB_FLAGSMITH_LABEL = "Flagsmith Feature"
GITLAB_FLAGSMITH_LABEL_COLOUR = "#6633FF"
GITLAB_FLAGSMITH_LABEL_DESCRIPTION = (
"This GitLab Issue/MR is linked to a Flagsmith feature"
)


class GitLabTagLabel(Enum):
ISSUE_OPEN = "Issue Open"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("gitlab", "0002_add_gitlab_webhook_model"),
]

operations = [
migrations.AddField(
model_name="gitlabconfiguration",
name="labeling_enabled",
field=models.BooleanField(default=False),
),
]
1 change: 1 addition & 0 deletions api/integrations/gitlab/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class GitLabConfiguration(SoftDeleteExportableModel):
)
gitlab_instance_url = models.URLField(max_length=200)
access_token = models.CharField(max_length=300)
labeling_enabled = models.BooleanField(default=False)


class GitLabWebhook(SoftDeleteExportableModel):
Expand Down
2 changes: 1 addition & 1 deletion api/integrations/gitlab/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
class GitLabConfigurationSerializer(BaseProjectIntegrationModelSerializer):
class Meta:
model = GitLabConfiguration
fields = ("id", "gitlab_instance_url", "access_token")
fields = ("id", "gitlab_instance_url", "access_token", "labeling_enabled")

def to_representation(self, instance: GitLabConfiguration) -> dict[str, Any]:
data = super().to_representation(instance)
Expand Down
6 changes: 6 additions & 0 deletions api/integrations/gitlab/services/__init__.py
Comment thread
khvn26 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
post_state_change_comment,
post_unlinked_comment,
)
from integrations.gitlab.services.labels import (
GITLAB_RESOURCE_KIND_BY_TYPE,
apply_flagsmith_label_to_resource,
)
from integrations.gitlab.services.tagging import (
apply_initial_tag,
apply_tag_for_event,
Expand All @@ -24,6 +28,8 @@
)

__all__ = [
"GITLAB_RESOURCE_KIND_BY_TYPE",
"apply_flagsmith_label_to_resource",
"apply_initial_tag",
"apply_tag_for_event",
"clear_tag_for_resource",
Expand Down
76 changes: 76 additions & 0 deletions api/integrations/gitlab/services/labels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from typing import Literal

import requests
import structlog

from features.feature_external_resources.models import (
FeatureExternalResource,
ResourceType,
)
from integrations.gitlab.client import (
add_flagsmith_label_to_gitlab_resource,
create_flagsmith_label,
)
from integrations.gitlab.models import GitLabConfiguration
from integrations.gitlab.services.url_parsing import (
parse_project_path,
parse_resource_iid,
)

logger = structlog.get_logger("gitlab")

GitLabResourceKind = Literal["issues", "merge_requests"]

GITLAB_RESOURCE_KIND_BY_TYPE: dict[str, GitLabResourceKind] = {
ResourceType.GITLAB_ISSUE.value: "issues",
ResourceType.GITLAB_MR.value: "merge_requests",
}


def apply_flagsmith_label_to_resource(
resource: FeatureExternalResource,
) -> None:
"""Ensure the "Flagsmith Feature" label exists on the resource's GitLab
project and apply it to the resource. No-op if labelling is disabled,
unconfigured, or the URL is unparseable. Never raises — failures are
logged via ``label.failed``.
"""
project = resource.feature.project
config: GitLabConfiguration | None = GitLabConfiguration.objects.filter(
project=project
).first()
if not config or not config.labeling_enabled:
return

path_with_namespace = parse_project_path(resource.url)
resource_iid = parse_resource_iid(resource.url)
if path_with_namespace is None or resource_iid is None:
return

log = logger.bind(
organisation__id=project.organisation_id,
project__id=project.id,
feature__id=resource.feature_id,
gitlab_project__path=path_with_namespace,
resource__type=resource.type,
resource__iid=resource_iid,
)

try:
created = create_flagsmith_label(
config.gitlab_instance_url,
config.access_token,
project_path=path_with_namespace,
)
if created:
log.info("label.created")

add_flagsmith_label_to_gitlab_resource(
config.gitlab_instance_url,
config.access_token,
project_path=path_with_namespace,
resource_kind=GITLAB_RESOURCE_KIND_BY_TYPE[resource.type],
resource_iid=resource_iid,
)
except requests.RequestException:
log.exception("label.failed")
75 changes: 75 additions & 0 deletions api/integrations/gitlab/tasks.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import requests
import structlog
from task_processor.decorators import register_task_handler

from features.feature_external_resources.models import FeatureExternalResource
from features.models import FeatureState
from integrations.gitlab.client import remove_flagsmith_label_from_gitlab_resource
from integrations.gitlab.models import GitLabConfiguration
from integrations.gitlab.services import (
deregister_webhook_for_path,
Expand All @@ -12,6 +15,16 @@
post_state_change_comment,
post_unlinked_comment,
)
from integrations.gitlab.services.labels import (
GITLAB_RESOURCE_KIND_BY_TYPE,
apply_flagsmith_label_to_resource,
)
from integrations.gitlab.services.url_parsing import (
parse_project_path,
parse_resource_iid,
)

logger = structlog.get_logger("gitlab")


@register_task_handler()
Expand Down Expand Up @@ -108,3 +121,65 @@ def post_gitlab_feature_deleted_comment(
feature_id=feature_id,
project_id=project_id,
)


@register_task_handler()
def apply_gitlab_label(resource_id: int) -> None:
"""Apply the "Flagsmith Feature" label to the linked GitLab resource.
Dispatched at link time. No-op if labelling is disabled or unconfigured.
"""
try:
resource = FeatureExternalResource.objects.get(id=resource_id)
except FeatureExternalResource.DoesNotExist:
return
apply_flagsmith_label_to_resource(resource)


@register_task_handler()
def remove_gitlab_label(
*,
project_id: int,
feature_id: int,
resource_pk: int,
resource_url: str,
resource_type: str,
) -> None:
"""Remove the "Flagsmith Feature" label from a GitLab issue/MR.
No-op if another FeatureExternalResource still references the same URL.
"""
config: GitLabConfiguration | None = GitLabConfiguration.objects.filter(
project_id=project_id
).first()
if not config or not config.labeling_enabled:
return
if (
FeatureExternalResource.objects.filter(url=resource_url)
.exclude(pk=resource_pk)
.exists()
):
return

path_with_namespace = parse_project_path(resource_url)
resource_iid = parse_resource_iid(resource_url)
if path_with_namespace is None or resource_iid is None:
return

log = logger.bind(
project__id=project_id,
feature__id=feature_id,
gitlab_project__path=path_with_namespace,
resource__type=resource_type,
resource__iid=resource_iid,
)

try:
remove_flagsmith_label_from_gitlab_resource(
config.gitlab_instance_url,
config.access_token,
project_path=path_with_namespace,
resource_kind=GITLAB_RESOURCE_KIND_BY_TYPE[resource_type],
resource_iid=resource_iid,
)
log.info("label.removed")
except requests.RequestException:
log.exception("label.removal_failed")
Loading
Loading