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
7 changes: 7 additions & 0 deletions api/api/urls/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from features.feature_health.views import feature_health_webhook
from features.views import SDKFeatureStates, get_multivariate_options
from integrations.github.views import github_webhook
from integrations.gitlab.views import gitlab_webhook
from organisations.views import chargebee_webhook

schema_view_permission_class = ( # pragma: no cover
Expand Down Expand Up @@ -42,6 +43,12 @@
re_path(r"cb-webhook/", chargebee_webhook, name="chargebee-webhook"),
# GitHub integration webhook
re_path(r"github-webhook/", github_webhook, name="github-webhook"),
# GitLab integration webhook
re_path(
r"gitlab-webhook/(?P<project_pk>\d+)/",
gitlab_webhook,
name="gitlab-webhook",
),
re_path(r"cb-webhook/", chargebee_webhook, name="chargebee-webhook"),
# Feature health webhook
re_path(
Expand Down
69 changes: 11 additions & 58 deletions api/integrations/github/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,10 @@
from django.db.models import Q
from django.utils.formats import get_format

from core.helpers import get_current_site_url
from features.models import Feature, FeatureState, FeatureStateValue
from integrations.github.constants import (
DELETED_FEATURE_TEXT,
DELETED_SEGMENT_OVERRIDE_TEXT,
FEATURE_ENVIRONMENT_URL,
FEATURE_TABLE_HEADER,
FEATURE_TABLE_ROW,
GITHUB_TAG_COLOR,
LINK_FEATURE_TITLE,
LINK_SEGMENT_TITLE,
UNLINKED_FEATURE_TEXT,
UPDATED_FEATURE_TEXT,
GitHubEventType,
GitHubTag,
github_tag_description,
Expand Down Expand Up @@ -130,57 +121,19 @@ def generate_body_comment(
project_id: int | None = None,
segment_name: str | None = None,
) -> str:
is_removed = event_type == GitHubEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value
is_segment_override_deleted = (
event_type == GitHubEventType.SEGMENT_OVERRIDE_DELETED.value
from integrations.vcs.comments import (
generate_body_comment as _generate_body_comment,
)

if event_type == GitHubEventType.FLAG_DELETED.value:
return DELETED_FEATURE_TEXT % (name)

if is_removed:
return UNLINKED_FEATURE_TEXT % (name)

if is_segment_override_deleted and segment_name is not None:
return DELETED_SEGMENT_OVERRIDE_TEXT % (segment_name, name)

result = ""
if event_type == GitHubEventType.FLAG_UPDATED.value:
result = UPDATED_FEATURE_TEXT % (name)
else:
result = LINK_FEATURE_TITLE % (name)

last_segment_name = ""
if len(feature_states) > 0 and not feature_states[0].get("segment_name"):
result += FEATURE_TABLE_HEADER

for fs in feature_states:
feature_value = fs.get("feature_state_value")
tab = "segment-overrides" if fs.get("segment_name") is not None else "value"
environment_link_url = FEATURE_ENVIRONMENT_URL % (
get_current_site_url(),
project_id,
fs.get("environment_api_key"),
feature_id,
tab,
)
if (
fs.get("segment_name") is not None
and fs["segment_name"] != last_segment_name
):
result += "\n" + LINK_SEGMENT_TITLE % (fs["segment_name"])
last_segment_name = fs["segment_name"]
result += FEATURE_TABLE_HEADER
table_row = FEATURE_TABLE_ROW % (
fs["environment_name"],
environment_link_url,
"✅ Enabled" if fs["enabled"] else "❌ Disabled",
f"`{feature_value}`" if feature_value else "",
fs["last_updated"],
)
result += table_row

return result
return _generate_body_comment(
name=name,
event_type=event_type,
feature_id=feature_id,
feature_states=feature_states,
unlinked_feature_text=UNLINKED_FEATURE_TEXT,
project_id=project_id,
segment_name=segment_name,
)


def check_not_none(value: Any) -> bool:
Expand Down
8 changes: 8 additions & 0 deletions api/integrations/gitlab/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ class GitLabTag(Enum):
ISSUE_CLOSED = "Issue Closed"


class GitLabEventType(Enum):
FLAG_UPDATED = "FLAG_UPDATED"
FLAG_DELETED = "FLAG_DELETED"
FEATURE_EXTERNAL_RESOURCE_ADDED = "FEATURE_EXTERNAL_RESOURCE_ADDED"
FEATURE_EXTERNAL_RESOURCE_REMOVED = "FEATURE_EXTERNAL_RESOURCE_REMOVED"
SEGMENT_OVERRIDE_DELETED = "SEGMENT_OVERRIDE_DELETED"


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",
Expand Down
42 changes: 42 additions & 0 deletions api/integrations/gitlab/mappers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from typing import Any

from django.utils.formats import get_format

from features.models import FeatureState
from integrations.gitlab.constants import GitLabEventType


def map_feature_states_to_dicts(
feature_states: list[FeatureState],
event_type: str,
) -> list[dict[str, Any]]:
"""Map FeatureState objects to dicts suitable for comment generation.

Used by both GitHub and GitLab integrations.
"""
result: list[dict[str, Any]] = []

for feature_state in feature_states:
feature_state_value = feature_state.get_feature_state_value()
env_data: dict[str, Any] = {}

if feature_state_value is not None:
env_data["feature_state_value"] = feature_state_value

if event_type != GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value:
env_data["environment_name"] = feature_state.environment.name # type: ignore[union-attr]
env_data["enabled"] = feature_state.enabled
env_data["last_updated"] = feature_state.updated_at.strftime(
get_format("DATETIME_INPUT_FORMATS")[0]
)
env_data["environment_api_key"] = feature_state.environment.api_key # type: ignore[union-attr]

if (
hasattr(feature_state, "feature_segment")
and feature_state.feature_segment is not None
):
env_data["segment_name"] = feature_state.feature_segment.segment.name

result.append(env_data)

return result
161 changes: 161 additions & 0 deletions api/integrations/gitlab/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import logging
from typing import Any

from django.db.models import Q

from features.models import Feature, FeatureState
from integrations.gitlab.constants import (
GITLAB_TAG_COLOUR,
GitLabEventType,
GitLabTag,
gitlab_tag_description,
)
from integrations.gitlab.mappers import map_feature_states_to_dicts
from integrations.gitlab.models import GitLabConfiguration
from projects.tags.models import Tag, TagType

logger = logging.getLogger(__name__)

_tag_by_event_type: dict[str, dict[str, GitLabTag | None]] = {
"merge_request": {
"close": GitLabTag.MR_CLOSED,
"merge": GitLabTag.MR_MERGED,
"open": GitLabTag.MR_OPEN,
"reopen": GitLabTag.MR_OPEN,
"update": None,
},
"issue": {
"close": GitLabTag.ISSUE_CLOSED,
"open": GitLabTag.ISSUE_OPEN,
"reopen": GitLabTag.ISSUE_OPEN,
},
}


def get_tag_for_event(
event_type: str,
action: str,
metadata: dict[str, Any],
) -> GitLabTag | None:
"""Return the tag for a GitLab webhook event, or None if no tag change is needed."""
if event_type == "merge_request" and action == "update":
if metadata.get("draft", False):
return GitLabTag.MR_DRAFT
return None

event_actions = _tag_by_event_type.get(event_type, {})
return event_actions.get(action)


def tag_feature_per_gitlab_event(
event_type: str,
action: str,
metadata: dict[str, Any],
project_path: str,
) -> None:
"""Apply a tag to a feature based on a GitLab webhook event."""
web_url = metadata.get("web_url", "")

# GitLab webhooks send /-/issues/N but stored URL might be /-/work_items/N
url_variants = [web_url]
if "/-/issues/" in web_url:
url_variants.append(web_url.replace("/-/issues/", "/-/work_items/"))
elif "/-/work_items/" in web_url:
url_variants.append(web_url.replace("/-/work_items/", "/-/issues/"))

feature = None
for url in url_variants:
feature = Feature.objects.filter(
Q(external_resources__type="GITLAB_MR")
| Q(external_resources__type="GITLAB_ISSUE"),
external_resources__url=url,
).first()
if feature:
break

if not feature:
return

try:
gitlab_config = GitLabConfiguration.objects.get(
project=feature.project,
project_name=project_path,
deleted_at__isnull=True,
)
except GitLabConfiguration.DoesNotExist:
return

if not gitlab_config.tagging_enabled:
return

tag_enum = get_tag_for_event(event_type, action, metadata)
if tag_enum is None:
return

gitlab_tag, _ = Tag.objects.get_or_create(
color=GITLAB_TAG_COLOUR,
description=gitlab_tag_description[tag_enum.value],
label=tag_enum.value,
project=feature.project,
is_system_tag=True,
type=TagType.GITLAB.value,
)

tag_label_pattern = "Issue" if event_type == "issue" else "MR"
feature.tags.remove(
*feature.tags.filter(
Q(type=TagType.GITLAB.value) & Q(label__startswith=tag_label_pattern)
)
)
feature.tags.add(gitlab_tag)
feature.save()


def handle_gitlab_webhook_event(event_type: str, payload: dict[str, Any]) -> None:
"""Process a GitLab webhook payload and apply tags."""
attrs = payload.get("object_attributes", {})
action = attrs.get("action", "")
project_path = payload.get("project", {}).get("path_with_namespace", "")

metadata: dict[str, Any] = {"web_url": attrs.get("url", "")}
if event_type == "merge_request":
metadata["draft"] = attrs.get("work_in_progress", False)
metadata["merged"] = attrs.get("state") == "merged"

tag_feature_per_gitlab_event(event_type, action, metadata, project_path)


def dispatch_gitlab_comment(
project_id: int,
event_type: str,
feature: Feature,
feature_states: list[FeatureState] | None = None,
url: str | None = None,
segment_name: str | None = None,
) -> None:
"""Dispatch an async task to post a comment to linked GitLab resources.

Does NOT pass credentials through the task queue — only the project_id.
The task handler fetches the GitLabConfiguration from the DB.
"""
from integrations.gitlab.tasks import post_gitlab_comment

feature_states_data = (
map_feature_states_to_dicts(feature_states, event_type)
if feature_states
else []
)

post_gitlab_comment.delay(
kwargs={
"project_id": project_id,
"feature_id": feature.id,
"feature_name": feature.name,
"event_type": event_type,
"feature_states": feature_states_data,
"url": url
if event_type == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value
else None,
"segment_name": segment_name,
},
)
Loading
Loading