Skip to content

Commit 237f215

Browse files
authored
feat(GitLab): Link GitLab issues and merge requests to feature flags (#7274)
1 parent 76d6573 commit 237f215

16 files changed

Lines changed: 489 additions & 147 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
6+
dependencies = [
7+
("feature_external_resources", "0002_featureexternalresource_feature_ext_type_2b2068_idx"),
8+
]
9+
10+
operations = [
11+
migrations.AlterField(
12+
model_name="featureexternalresource",
13+
name="type",
14+
field=models.CharField(
15+
choices=[
16+
("GITHUB_ISSUE", "GitHub Issue"),
17+
("GITHUB_PR", "GitHub PR"),
18+
("GITLAB_ISSUE", "GitLab Issue"),
19+
("GITLAB_MR", "GitLab MR"),
20+
],
21+
max_length=20,
22+
),
23+
),
24+
]

api/features/feature_external_resources/models.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ class ResourceType(models.TextChoices):
2727
GITHUB_ISSUE = "GITHUB_ISSUE", "GitHub Issue"
2828
GITHUB_PR = "GITHUB_PR", "GitHub PR"
2929

30+
# GitLab external resource types
31+
GITLAB_ISSUE = "GITLAB_ISSUE", "GitLab Issue"
32+
GITLAB_MR = "GITLAB_MR", "GitLab MR"
33+
3034

3135
tag_by_type_and_state = {
3236
ResourceType.GITHUB_ISSUE.value: {
@@ -65,8 +69,9 @@ class Meta:
6569
models.Index(fields=["type"]),
6670
]
6771

68-
@hook(AFTER_SAVE)
69-
def execute_after_save_actions(self): # type: ignore[no-untyped-def]
72+
@hook(AFTER_SAVE, when="type", is_now="GITHUB_ISSUE")
73+
@hook(AFTER_SAVE, when="type", is_now="GITHUB_PR")
74+
def notify_github_on_link(self): # type: ignore[no-untyped-def]
7075
# Tag the feature with the external resource type
7176
metadata = json.loads(self.metadata) if self.metadata else {}
7277
state = metadata.get("state", "open")
@@ -130,8 +135,9 @@ def execute_after_save_actions(self): # type: ignore[no-untyped-def]
130135
feature_states=feature_states,
131136
)
132137

133-
@hook(BEFORE_DELETE) # type: ignore[misc]
134-
def execute_before_save_actions(self) -> None:
138+
@hook(BEFORE_DELETE, when="type", is_now="GITHUB_ISSUE") # type: ignore[misc]
139+
@hook(BEFORE_DELETE, when="type", is_now="GITHUB_PR") # type: ignore[misc]
140+
def notify_github_on_unlink(self) -> None:
135141
# Add a comment to GitHub Issue/PR when feature is unlinked to the GH external resource
136142
if (
137143
Organisation.objects.prefetch_related("github_config")

api/features/feature_external_resources/views.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import re
22

3+
import structlog
34
from django.shortcuts import get_object_or_404
45
from django.utils.decorators import method_decorator
56
from drf_spectacular.utils import extend_schema
@@ -15,7 +16,7 @@
1516
from integrations.github.models import GitHubRepository
1617
from organisations.models import Organisation
1718

18-
from .models import FeatureExternalResource
19+
from .models import FeatureExternalResource, ResourceType
1920
from .serializers import FeatureExternalResourceSerializer
2021

2122

@@ -45,7 +46,6 @@ def get_queryset(self): # type: ignore[no-untyped-def]
4546
features_pk = self.kwargs["feature_pk"]
4647
return FeatureExternalResource.objects.filter(feature=features_pk)
4748

48-
# Override get list view to add github issue/pr name to each linked external resource
4949
def list(self, request, *args, **kwargs) -> Response: # type: ignore[no-untyped-def]
5050
queryset = self.get_queryset() # type: ignore[no-untyped-call]
5151
serializer = self.get_serializer(queryset, many=True)
@@ -56,7 +56,14 @@ def list(self, request, *args, **kwargs) -> Response: # type: ignore[no-untyped
5656
Feature.objects.filter(id=self.kwargs["feature_pk"]),
5757
).project.organisation_id
5858

59+
# Add github issue/PR name to each linked external resource
5960
for resource in data if isinstance(data, list) else []:
61+
if ResourceType(resource["type"]) not in [
62+
ResourceType.GITHUB_ISSUE,
63+
ResourceType.GITHUB_PR,
64+
]:
65+
continue
66+
6067
if resource_url := resource.get("url"):
6168
resource["metadata"] = get_github_issue_pr_title_and_state(
6269
organisation_id=organisation_id, resource_url=resource_url
@@ -65,6 +72,12 @@ def list(self, request, *args, **kwargs) -> Response: # type: ignore[no-untyped
6572
return Response(data={"results": data})
6673

6774
def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
75+
if request.data.get("type") not in [
76+
ResourceType.GITHUB_ISSUE,
77+
ResourceType.GITHUB_PR,
78+
]:
79+
return super().create(request, *args, **kwargs)
80+
6881
feature = get_object_or_404(
6982
Feature.objects.filter(
7083
id=self.kwargs["feature_pk"],
@@ -122,6 +135,22 @@ def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
122135
status=status.HTTP_400_BAD_REQUEST,
123136
)
124137

138+
def perform_create(self, serializer: FeatureExternalResourceSerializer) -> None: # type: ignore[override]
139+
resource = serializer.save()
140+
141+
log_event_names: dict[ResourceType, tuple[str, str]] = {
142+
ResourceType.GITLAB_ISSUE: ("gitlab", "issue.linked"),
143+
ResourceType.GITLAB_MR: ("gitlab", "merge_request.linked"),
144+
}
145+
if (resource_type := ResourceType(resource.type)) in log_event_names:
146+
logger_name, event_name = log_event_names[resource_type]
147+
structlog.get_logger(logger_name).info(
148+
event_name,
149+
organisation__id=resource.feature.project.organisation_id,
150+
project__id=resource.feature.project_id,
151+
feature__id=resource.feature.id,
152+
)
153+
125154
def perform_update(self, serializer): # type: ignore[no-untyped-def]
126155
external_resource_id = int(self.kwargs["pk"])
127156
serializer.save(id=external_resource_id)

api/integrations/gitlab/views/browse_gitlab.py

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from rest_framework.generics import ListAPIView
88
from rest_framework.request import Request
99
from rest_framework.response import Response
10+
from structlog.typing import FilteringBoundLogger
1011

1112
from integrations.gitlab.client import (
1213
GitLabIssue,
@@ -55,14 +56,20 @@ def list(self, request: Request, *args: Any, **kwargs: Any) -> Response:
5556
try:
5657
page_data = self.fetch_page(config, serializer.validated_data)
5758
except requests.RequestException as exc:
58-
logger.error("api-call-failed", exc_info=exc)
59+
self._log_for(config).error("api_call.failed", exc_info=exc)
5960
return Response(
6061
data={"detail": "GitLab API is unreachable"},
6162
status=status.HTTP_503_SERVICE_UNAVAILABLE,
6263
)
6364

6465
return self._paginated_response(page_data, request)
6566

67+
def _log_for(self, config: GitLabConfiguration) -> FilteringBoundLogger:
68+
return logger.bind( # type: ignore[no-any-return]
69+
organisation__id=config.project.organisation_id,
70+
project__id=config.project_id,
71+
)
72+
6673
def _get_gitlab_config(self) -> GitLabConfiguration:
6774
return GitLabConfiguration.objects.get( # type: ignore[no-any-return]
6875
project_id=self.kwargs["project_pk"],
@@ -105,10 +112,7 @@ def fetch_page(
105112
page_size=validated_data["page_size"],
106113
)
107114

108-
logger.info(
109-
"projects-fetched",
110-
project__id=self.kwargs["project_pk"],
111-
)
115+
self._log_for(config).info("projects.fetched")
112116
return page_data
113117

114118

@@ -130,10 +134,9 @@ def fetch_page(
130134
state=validated_data.get("state", "opened"),
131135
)
132136

133-
logger.info(
134-
"issues-fetched",
135-
project__id=self.kwargs["project_pk"],
136-
gitlab_project_id=validated_data["gitlab_project_id"],
137+
self._log_for(config).info(
138+
"issues.fetched",
139+
gitlab_project__id=validated_data["gitlab_project_id"],
137140
)
138141
return page_data
139142

@@ -156,9 +159,8 @@ def fetch_page(
156159
state=validated_data.get("state", "opened"),
157160
)
158161

159-
logger.info(
160-
"merge-requests-fetched",
161-
project__id=self.kwargs["project_pk"],
162-
gitlab_project_id=validated_data["gitlab_project_id"],
162+
self._log_for(config).info(
163+
"merge_requests.fetched",
164+
gitlab_project__id=validated_data["gitlab_project_id"],
163165
)
164166
return page_data
Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import structlog
2+
from structlog.typing import FilteringBoundLogger
23

34
from integrations.common.views import ProjectIntegrationBaseViewSet
45
from integrations.gitlab.models import GitLabConfiguration
@@ -12,31 +13,29 @@ class GitLabConfigurationViewSet(ProjectIntegrationBaseViewSet):
1213
model_class = GitLabConfiguration # type: ignore[assignment]
1314
pagination_class = None
1415

15-
def _log_for(
16-
self, instance: GitLabConfiguration
17-
) -> structlog.typing.FilteringBoundLogger:
16+
def _log_for(self, config: GitLabConfiguration) -> FilteringBoundLogger:
1817
return logger.bind( # type: ignore[no-any-return]
19-
project__id=instance.project.id,
20-
organisation__id=instance.project.organisation_id,
18+
project__id=config.project.id,
19+
organisation__id=config.project.organisation_id,
2120
)
2221

2322
def perform_create(self, serializer: GitLabConfigurationSerializer) -> None: # type: ignore[override]
2423
super().perform_create(serializer)
2524
instance: GitLabConfiguration = serializer.instance # type: ignore[assignment]
2625
self._log_for(instance).info(
27-
"configuration-created",
26+
"configuration.created",
2827
gitlab_instance_url=instance.gitlab_instance_url,
2928
)
3029

3130
def perform_update(self, serializer: GitLabConfigurationSerializer) -> None: # type: ignore[override]
3231
super().perform_update(serializer)
3332
instance: GitLabConfiguration = serializer.instance # type: ignore[assignment]
3433
self._log_for(instance).info(
35-
"configuration-updated",
34+
"configuration.updated",
3635
gitlab_instance_url=instance.gitlab_instance_url,
3736
)
3837

3938
def perform_destroy(self, instance: GitLabConfiguration) -> None:
4039
log = self._log_for(instance)
4140
super().perform_destroy(instance)
42-
log.info("configuration-deleted")
41+
log.info("configuration.deleted")

0 commit comments

Comments
 (0)