Skip to content

Commit 2a91d0c

Browse files
authored
feat(GitLab): Receive webhooks for automatic state sync (#7301)
1 parent ec95aef commit 2a91d0c

27 files changed

Lines changed: 1724 additions & 15 deletions

File tree

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+
path(
48+
"gitlab-webhook/<uuid:webhook_uuid>/",
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/features/feature_external_resources/models.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.db import models
66
from django.db.models import Q
77
from django_lifecycle import ( # type: ignore[import-untyped]
8+
AFTER_DELETE,
89
AFTER_SAVE,
910
BEFORE_DELETE,
1011
LifecycleModelMixin,
@@ -32,6 +33,12 @@ class ResourceType(models.TextChoices):
3233
GITLAB_MR = "GITLAB_MR", "GitLab MR"
3334

3435

36+
GITLAB_RESOURCE_TYPES: tuple[ResourceType, ...] = (
37+
ResourceType.GITLAB_ISSUE,
38+
ResourceType.GITLAB_MR,
39+
)
40+
41+
3542
tag_by_type_and_state = {
3643
ResourceType.GITHUB_ISSUE.value: {
3744
"open": GitHubTag.ISSUE_OPEN.value,
@@ -135,6 +142,32 @@ def notify_github_on_link(self): # type: ignore[no-untyped-def]
135142
feature_states=feature_states,
136143
)
137144

145+
@hook(AFTER_SAVE, when="type", is_now="GITLAB_ISSUE") # type: ignore[misc]
146+
@hook(AFTER_SAVE, when="type", is_now="GITLAB_MR") # type: ignore[misc]
147+
def apply_gitlab_tag(self) -> None:
148+
from integrations.gitlab.services import apply_initial_tag
149+
150+
apply_initial_tag(self)
151+
152+
@hook(AFTER_DELETE, when="type", is_now="GITLAB_ISSUE") # type: ignore[misc]
153+
@hook(AFTER_DELETE, when="type", is_now="GITLAB_MR") # type: ignore[misc]
154+
def deregister_gitlab_webhook(self) -> None:
155+
from integrations.gitlab.models import GitLabConfiguration
156+
from integrations.gitlab.services import parse_project_path
157+
from integrations.gitlab.tasks import (
158+
deregister_gitlab_webhook as deregister_task,
159+
)
160+
161+
project_path = parse_project_path(self.url)
162+
if project_path is None:
163+
return
164+
config = GitLabConfiguration.objects.filter(
165+
project=self.feature.project,
166+
).first()
167+
if config is None:
168+
return
169+
deregister_task.delay(args=(config.id, project_path))
170+
138171
@hook(BEFORE_DELETE, when="type", is_now="GITHUB_ISSUE") # type: ignore[misc]
139172
@hook(BEFORE_DELETE, when="type", is_now="GITHUB_PR") # type: ignore[misc]
140173
def notify_github_on_unlink(self) -> None:

api/features/feature_external_resources/views.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@
1414
label_github_issue_pr,
1515
)
1616
from integrations.github.models import GitHubRepository
17+
from integrations.gitlab.models import GitLabConfiguration
18+
from integrations.gitlab.services import parse_project_path
19+
from integrations.gitlab.tasks import register_gitlab_webhook
1720
from organisations.models import Organisation
1821

19-
from .models import FeatureExternalResource, ResourceType
22+
from .models import GITLAB_RESOURCE_TYPES, FeatureExternalResource, ResourceType
2023
from .serializers import FeatureExternalResourceSerializer
2124

2225

@@ -72,10 +75,16 @@ def list(self, request, *args, **kwargs) -> Response: # type: ignore[no-untyped
7275
return Response(data={"results": data})
7376

7477
def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
75-
if request.data.get("type") not in [
78+
resource_type = request.data.get("type")
79+
80+
# GitLab side effects run in ``perform_create`` below.
81+
if resource_type in GITLAB_RESOURCE_TYPES:
82+
return super().create(request, *args, **kwargs)
83+
84+
if resource_type not in (
7685
ResourceType.GITHUB_ISSUE,
7786
ResourceType.GITHUB_PR,
78-
]:
87+
):
7988
return super().create(request, *args, **kwargs)
8089

8190
feature = get_object_or_404(
@@ -137,12 +146,21 @@ def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
137146

138147
def perform_create(self, serializer: FeatureExternalResourceSerializer) -> None: # type: ignore[override]
139148
resource = serializer.save()
149+
resource_type = ResourceType(resource.type)
150+
151+
if resource_type in GITLAB_RESOURCE_TYPES:
152+
project_path = parse_project_path(resource.url)
153+
config = GitLabConfiguration.objects.filter(
154+
project=resource.feature.project,
155+
).first()
156+
if config is not None and project_path is not None:
157+
register_gitlab_webhook.delay(args=(config.id, project_path))
140158

141159
log_event_names: dict[ResourceType, tuple[str, str]] = {
142160
ResourceType.GITLAB_ISSUE: ("gitlab", "issue.linked"),
143161
ResourceType.GITLAB_MR: ("gitlab", "merge_request.linked"),
144162
}
145-
if (resource_type := ResourceType(resource.type)) in log_event_names:
163+
if resource_type in log_event_names:
146164
logger_name, event_name = log_event_names[resource_type]
147165
structlog.get_logger(logger_name).info(
148166
event_name,

api/integrations/gitlab/client/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from integrations.gitlab.client.api import (
2+
create_project_hook,
3+
delete_project_hook,
24
fetch_gitlab_projects,
35
search_gitlab_issues,
46
search_gitlab_merge_requests,
@@ -8,13 +10,17 @@
810
GitLabMergeRequest,
911
GitLabPage,
1012
GitLabProject,
13+
GitLabProjectHook,
1114
)
1215

1316
__all__ = [
1417
"GitLabIssue",
1518
"GitLabMergeRequest",
1619
"GitLabPage",
1720
"GitLabProject",
21+
"GitLabProjectHook",
22+
"create_project_hook",
23+
"delete_project_hook",
1824
"fetch_gitlab_projects",
1925
"search_gitlab_issues",
2026
"search_gitlab_merge_requests",

api/integrations/gitlab/client/api.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections.abc import Mapping
22
from typing import Any
3+
from urllib.parse import quote
34

45
import requests
56

@@ -8,6 +9,7 @@
89
GitLabMergeRequest,
910
GitLabPage,
1011
GitLabProject,
12+
GitLabProjectHook,
1113
T,
1214
)
1315

@@ -147,3 +149,44 @@ def search_gitlab_merge_requests(
147149
for item in response.json()
148150
]
149151
return _gitlab_page(results, response.headers)
152+
153+
154+
def create_project_hook(
155+
instance_url: str,
156+
access_token: str,
157+
*,
158+
project_path: str,
159+
hook_url: str,
160+
secret: str,
161+
) -> GitLabProjectHook:
162+
encoded_path = quote(project_path, safe="")
163+
response = requests.post(
164+
f"{instance_url}/api/v4/projects/{encoded_path}/hooks",
165+
headers={"PRIVATE-TOKEN": access_token},
166+
json={
167+
"url": hook_url,
168+
"token": secret,
169+
"issues_events": True,
170+
"merge_requests_events": True,
171+
"enable_ssl_verification": True,
172+
},
173+
)
174+
response.raise_for_status()
175+
payload = response.json()
176+
return {"id": payload["id"], "project_id": payload["project_id"]}
177+
178+
179+
def delete_project_hook(
180+
instance_url: str,
181+
access_token: str,
182+
*,
183+
project_id: int,
184+
hook_id: int,
185+
) -> None:
186+
response = requests.delete(
187+
f"{instance_url}/api/v4/projects/{project_id}/hooks/{hook_id}",
188+
headers={"PRIVATE-TOKEN": access_token},
189+
)
190+
if response.status_code == 404:
191+
return
192+
response.raise_for_status()

api/integrations/gitlab/client/types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ class GitLabMergeRequest(TypedDict):
2727
draft: bool
2828

2929

30+
class GitLabProjectHook(TypedDict):
31+
id: int
32+
project_id: int
33+
34+
3035
class GitLabPage(TypedDict, Generic[T]):
3136
results: list[T]
3237
current_page: int
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from enum import Enum
2+
3+
GITLAB_TAG_COLOR = "#FC6D26"
4+
GITLAB_WEBHOOK_TIMEOUT = 10
5+
6+
7+
class GitLabTagLabel(Enum):
8+
ISSUE_OPEN = "Issue Open"
9+
ISSUE_CLOSED = "Issue Closed"
10+
MR_OPEN = "MR Open"
11+
MR_CLOSED = "MR Closed"
12+
MR_MERGED = "MR Merged"
13+
MR_DRAFT = "MR Draft"
14+
15+
16+
GITLAB_TAG_KIND_BY_LABEL: dict[GitLabTagLabel, str] = {
17+
GitLabTagLabel.ISSUE_OPEN: "Issue",
18+
GitLabTagLabel.ISSUE_CLOSED: "Issue",
19+
GitLabTagLabel.MR_OPEN: "MR",
20+
GitLabTagLabel.MR_CLOSED: "MR",
21+
GitLabTagLabel.MR_MERGED: "MR",
22+
GitLabTagLabel.MR_DRAFT: "MR",
23+
}
24+
25+
26+
GITLAB_TAG_DESCRIPTION_BY_LABEL: dict[GitLabTagLabel, str] = {
27+
GitLabTagLabel.ISSUE_OPEN: "Has a linked GitLab issue open",
28+
GitLabTagLabel.ISSUE_CLOSED: "Has a linked GitLab issue closed",
29+
GitLabTagLabel.MR_OPEN: "Has a linked GitLab merge request open",
30+
GitLabTagLabel.MR_CLOSED: "Has a linked GitLab merge request closed",
31+
GitLabTagLabel.MR_MERGED: "Has a linked GitLab merge request merged",
32+
GitLabTagLabel.MR_DRAFT: "Has a linked GitLab merge request draft",
33+
}

api/integrations/gitlab/mappers.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from pydantic import TypeAdapter, ValidationError
2+
3+
from features.feature_external_resources.models import (
4+
FeatureExternalResource,
5+
ResourceType,
6+
)
7+
from integrations.gitlab.constants import GitLabTagLabel
8+
from integrations.gitlab.types import GitLabResourceMetadata, GitLabWebhookPayload
9+
10+
_resource_metadata_adapter: TypeAdapter[GitLabResourceMetadata] = TypeAdapter(
11+
GitLabResourceMetadata,
12+
)
13+
14+
15+
def map_issue_state_to_tag_label(state: str | None) -> GitLabTagLabel | None:
16+
if state in {"opened", "reopened", "open"}:
17+
return GitLabTagLabel.ISSUE_OPEN
18+
if state == "closed":
19+
return GitLabTagLabel.ISSUE_CLOSED
20+
return None
21+
22+
23+
def map_merge_request_state_to_tag_label(
24+
state: str | None,
25+
action: str | None,
26+
is_draft: bool,
27+
) -> GitLabTagLabel | None:
28+
if action == "merge" or state == "merged":
29+
return GitLabTagLabel.MR_MERGED
30+
if state == "closed":
31+
return GitLabTagLabel.MR_CLOSED
32+
if state in {"opened", "reopened", "open"}:
33+
return GitLabTagLabel.MR_DRAFT if is_draft else GitLabTagLabel.MR_OPEN
34+
return None
35+
36+
37+
def map_gitlab_resource_to_tag_label(
38+
resource: FeatureExternalResource,
39+
) -> GitLabTagLabel | None:
40+
"""Derive the GitLab tag label for ``resource.feature`` from the JSON
41+
metadata snapshot the client supplied at link time.
42+
"""
43+
try:
44+
metadata = _resource_metadata_adapter.validate_json(resource.metadata or "")
45+
except ValidationError:
46+
return None
47+
state = metadata.get("state")
48+
if resource.type == ResourceType.GITLAB_ISSUE.value:
49+
return map_issue_state_to_tag_label(state)
50+
if resource.type == ResourceType.GITLAB_MR.value:
51+
return map_merge_request_state_to_tag_label(
52+
state,
53+
action=None,
54+
is_draft=bool(metadata.get("draft")),
55+
)
56+
return None
57+
58+
59+
def map_gitlab_webhook_payload_to_tag_label(
60+
payload: GitLabWebhookPayload,
61+
) -> GitLabTagLabel | None:
62+
attrs = payload.get("object_attributes")
63+
if not attrs:
64+
return None
65+
state = attrs.get("state")
66+
object_kind = payload.get("object_kind")
67+
if object_kind == "issue":
68+
return map_issue_state_to_tag_label(state)
69+
if object_kind == "merge_request":
70+
return map_merge_request_state_to_tag_label(
71+
state,
72+
action=attrs.get("action"),
73+
is_draft=bool(attrs.get("draft") or attrs.get("work_in_progress")),
74+
)
75+
return None

0 commit comments

Comments
 (0)