Skip to content

Commit ae0f757

Browse files
asaphkoclaude
andcommitted
feat(gitlab): add webhook handling, tagging, and shared VCS module
Add webhook endpoint, event-driven tagging, async comment posting, and extract shared VCS comment generation from the GitHub integration. Key architectural improvements over #7028: - Credentials never passed through task queue (security) - Business logic in services.py, data mapping in mappers.py - Thin task entry point fetches config from DB - Shared VCS module for comment generation (GitHub refactored too) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 54c9b1c commit ae0f757

File tree

12 files changed

+756
-50
lines changed

12 files changed

+756
-50
lines changed

api/api/urls/v1.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from features.feature_health.views import feature_health_webhook
1212
from features.views import SDKFeatureStates, get_multivariate_options
1313
from integrations.github.views import github_webhook
14+
from integrations.gitlab.views import gitlab_webhook
1415
from organisations.views import chargebee_webhook
1516

1617
schema_view_permission_class = ( # pragma: no cover
@@ -42,6 +43,12 @@
4243
re_path(r"cb-webhook/", chargebee_webhook, name="chargebee-webhook"),
4344
# GitHub integration webhook
4445
re_path(r"github-webhook/", github_webhook, name="github-webhook"),
46+
# GitLab integration webhook
47+
re_path(
48+
r"gitlab-webhook/(?P<project_pk>\d+)/",
49+
gitlab_webhook,
50+
name="gitlab-webhook",
51+
),
4552
re_path(r"cb-webhook/", chargebee_webhook, name="chargebee-webhook"),
4653
# Feature health webhook
4754
re_path(

api/integrations/github/github.py

Lines changed: 11 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -130,57 +130,19 @@ def generate_body_comment(
130130
project_id: int | None = None,
131131
segment_name: str | None = None,
132132
) -> str:
133-
is_removed = event_type == GitHubEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value
134-
is_segment_override_deleted = (
135-
event_type == GitHubEventType.SEGMENT_OVERRIDE_DELETED.value
133+
from integrations.vcs.comments import (
134+
generate_body_comment as _generate_body_comment,
136135
)
137136

138-
if event_type == GitHubEventType.FLAG_DELETED.value:
139-
return DELETED_FEATURE_TEXT % (name)
140-
141-
if is_removed:
142-
return UNLINKED_FEATURE_TEXT % (name)
143-
144-
if is_segment_override_deleted and segment_name is not None:
145-
return DELETED_SEGMENT_OVERRIDE_TEXT % (segment_name, name)
146-
147-
result = ""
148-
if event_type == GitHubEventType.FLAG_UPDATED.value:
149-
result = UPDATED_FEATURE_TEXT % (name)
150-
else:
151-
result = LINK_FEATURE_TITLE % (name)
152-
153-
last_segment_name = ""
154-
if len(feature_states) > 0 and not feature_states[0].get("segment_name"):
155-
result += FEATURE_TABLE_HEADER
156-
157-
for fs in feature_states:
158-
feature_value = fs.get("feature_state_value")
159-
tab = "segment-overrides" if fs.get("segment_name") is not None else "value"
160-
environment_link_url = FEATURE_ENVIRONMENT_URL % (
161-
get_current_site_url(),
162-
project_id,
163-
fs.get("environment_api_key"),
164-
feature_id,
165-
tab,
166-
)
167-
if (
168-
fs.get("segment_name") is not None
169-
and fs["segment_name"] != last_segment_name
170-
):
171-
result += "\n" + LINK_SEGMENT_TITLE % (fs["segment_name"])
172-
last_segment_name = fs["segment_name"]
173-
result += FEATURE_TABLE_HEADER
174-
table_row = FEATURE_TABLE_ROW % (
175-
fs["environment_name"],
176-
environment_link_url,
177-
"✅ Enabled" if fs["enabled"] else "❌ Disabled",
178-
f"`{feature_value}`" if feature_value else "",
179-
fs["last_updated"],
180-
)
181-
result += table_row
182-
183-
return result
137+
return _generate_body_comment(
138+
name=name,
139+
event_type=event_type,
140+
feature_id=feature_id,
141+
feature_states=feature_states,
142+
unlinked_feature_text=UNLINKED_FEATURE_TEXT,
143+
project_id=project_id,
144+
segment_name=segment_name,
145+
)
184146

185147

186148
def check_not_none(value: Any) -> bool:

api/integrations/gitlab/constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ class GitLabTag(Enum):
2020
ISSUE_CLOSED = "Issue Closed"
2121

2222

23+
class GitLabEventType(Enum):
24+
FLAG_UPDATED = "FLAG_UPDATED"
25+
FLAG_DELETED = "FLAG_DELETED"
26+
FEATURE_EXTERNAL_RESOURCE_ADDED = "FEATURE_EXTERNAL_RESOURCE_ADDED"
27+
FEATURE_EXTERNAL_RESOURCE_REMOVED = "FEATURE_EXTERNAL_RESOURCE_REMOVED"
28+
SEGMENT_OVERRIDE_DELETED = "SEGMENT_OVERRIDE_DELETED"
29+
30+
2331
gitlab_tag_description: dict[str, str] = {
2432
GitLabTag.MR_OPEN.value: "This feature has a linked MR open",
2533
GitLabTag.MR_MERGED.value: "This feature has a linked MR merged",

api/integrations/gitlab/mappers.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from typing import Any
2+
3+
from django.utils.formats import get_format
4+
5+
from features.models import FeatureState
6+
from integrations.gitlab.constants import GitLabEventType
7+
8+
9+
def map_feature_states_to_dicts(
10+
feature_states: list[FeatureState],
11+
event_type: str,
12+
) -> list[dict[str, Any]]:
13+
"""Map FeatureState objects to dicts suitable for comment generation.
14+
15+
Used by both GitHub and GitLab integrations.
16+
"""
17+
result: list[dict[str, Any]] = []
18+
19+
for feature_state in feature_states:
20+
feature_state_value = feature_state.get_feature_state_value()
21+
env_data: dict[str, Any] = {}
22+
23+
if feature_state_value is not None:
24+
env_data["feature_state_value"] = feature_state_value
25+
26+
if event_type != GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value:
27+
env_data["environment_name"] = feature_state.environment.name # type: ignore[union-attr]
28+
env_data["enabled"] = feature_state.enabled
29+
env_data["last_updated"] = feature_state.updated_at.strftime(
30+
get_format("DATETIME_INPUT_FORMATS")[0]
31+
)
32+
env_data["environment_api_key"] = (
33+
feature_state.environment.api_key # type: ignore[union-attr]
34+
)
35+
36+
if (
37+
hasattr(feature_state, "feature_segment")
38+
and feature_state.feature_segment is not None
39+
):
40+
env_data["segment_name"] = feature_state.feature_segment.segment.name
41+
42+
result.append(env_data)
43+
44+
return result
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import logging
2+
from typing import Any
3+
4+
from django.db.models import Q
5+
6+
from features.models import Feature, FeatureState
7+
from integrations.gitlab.constants import (
8+
GITLAB_TAG_COLOUR,
9+
GitLabEventType,
10+
GitLabTag,
11+
gitlab_tag_description,
12+
)
13+
from integrations.gitlab.mappers import map_feature_states_to_dicts
14+
from integrations.gitlab.models import GitLabConfiguration
15+
from projects.tags.models import Tag, TagType
16+
17+
logger = logging.getLogger(__name__)
18+
19+
_tag_by_event_type: dict[str, dict[str, GitLabTag | None]] = {
20+
"merge_request": {
21+
"close": GitLabTag.MR_CLOSED,
22+
"merge": GitLabTag.MR_MERGED,
23+
"open": GitLabTag.MR_OPEN,
24+
"reopen": GitLabTag.MR_OPEN,
25+
"update": None,
26+
},
27+
"issue": {
28+
"close": GitLabTag.ISSUE_CLOSED,
29+
"open": GitLabTag.ISSUE_OPEN,
30+
"reopen": GitLabTag.ISSUE_OPEN,
31+
},
32+
}
33+
34+
35+
def get_tag_for_event(
36+
event_type: str,
37+
action: str,
38+
metadata: dict[str, Any],
39+
) -> GitLabTag | None:
40+
"""Return the tag for a GitLab webhook event, or None if no tag change is needed."""
41+
if event_type == "merge_request" and action == "update":
42+
if metadata.get("draft", False):
43+
return GitLabTag.MR_DRAFT
44+
return None
45+
46+
event_actions = _tag_by_event_type.get(event_type, {})
47+
return event_actions.get(action)
48+
49+
50+
def tag_feature_per_gitlab_event(
51+
event_type: str,
52+
action: str,
53+
metadata: dict[str, Any],
54+
project_path: str,
55+
) -> None:
56+
"""Apply a tag to a feature based on a GitLab webhook event."""
57+
web_url = metadata.get("web_url", "")
58+
59+
# GitLab webhooks send /-/issues/N but stored URL might be /-/work_items/N
60+
url_variants = [web_url]
61+
if "/-/issues/" in web_url:
62+
url_variants.append(web_url.replace("/-/issues/", "/-/work_items/"))
63+
elif "/-/work_items/" in web_url:
64+
url_variants.append(web_url.replace("/-/work_items/", "/-/issues/"))
65+
66+
feature = None
67+
for url in url_variants:
68+
feature = Feature.objects.filter(
69+
Q(external_resources__type="GITLAB_MR")
70+
| Q(external_resources__type="GITLAB_ISSUE"),
71+
external_resources__url=url,
72+
).first()
73+
if feature:
74+
break
75+
76+
if not feature:
77+
return
78+
79+
try:
80+
gitlab_config = GitLabConfiguration.objects.get(
81+
project=feature.project,
82+
project_name=project_path,
83+
deleted_at__isnull=True,
84+
)
85+
except GitLabConfiguration.DoesNotExist:
86+
return
87+
88+
if not gitlab_config.tagging_enabled:
89+
return
90+
91+
tag_enum = get_tag_for_event(event_type, action, metadata)
92+
if tag_enum is None:
93+
return
94+
95+
gitlab_tag, _ = Tag.objects.get_or_create(
96+
color=GITLAB_TAG_COLOUR,
97+
description=gitlab_tag_description[tag_enum.value],
98+
label=tag_enum.value,
99+
project=feature.project,
100+
is_system_tag=True,
101+
type=TagType.GITLAB.value,
102+
)
103+
104+
tag_label_pattern = "Issue" if event_type == "issue" else "MR"
105+
feature.tags.remove(
106+
*feature.tags.filter(
107+
Q(type=TagType.GITLAB.value) & Q(label__startswith=tag_label_pattern)
108+
)
109+
)
110+
feature.tags.add(gitlab_tag)
111+
feature.save()
112+
113+
114+
def handle_gitlab_webhook_event(event_type: str, payload: dict[str, Any]) -> None:
115+
"""Process a GitLab webhook payload and apply tags."""
116+
attrs = payload.get("object_attributes", {})
117+
action = attrs.get("action", "")
118+
project_path = payload.get("project", {}).get("path_with_namespace", "")
119+
120+
metadata: dict[str, Any] = {"web_url": attrs.get("url", "")}
121+
if event_type == "merge_request":
122+
metadata["draft"] = attrs.get("work_in_progress", False)
123+
metadata["merged"] = attrs.get("state") == "merged"
124+
125+
tag_feature_per_gitlab_event(event_type, action, metadata, project_path)
126+
127+
128+
def dispatch_gitlab_comment(
129+
project_id: int,
130+
event_type: str,
131+
feature: Feature,
132+
feature_states: list[FeatureState] | None = None,
133+
url: str | None = None,
134+
segment_name: str | None = None,
135+
) -> None:
136+
"""Dispatch an async task to post a comment to linked GitLab resources.
137+
138+
Does NOT pass credentials through the task queue — only the project_id.
139+
The task handler fetches the GitLabConfiguration from the DB.
140+
"""
141+
from integrations.gitlab.tasks import post_gitlab_comment
142+
143+
feature_states_data = (
144+
map_feature_states_to_dicts(feature_states, event_type)
145+
if feature_states
146+
else []
147+
)
148+
149+
post_gitlab_comment.delay(
150+
kwargs={
151+
"project_id": project_id,
152+
"feature_id": feature.id,
153+
"feature_name": feature.name,
154+
"event_type": event_type,
155+
"feature_states": feature_states_data,
156+
"url": url if event_type == GitLabEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value else None,
157+
"segment_name": segment_name,
158+
},
159+
)

0 commit comments

Comments
 (0)