Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("feature_external_resources", "0002_featureexternalresource_feature_ext_type_2b2068_idx"),
]

operations = [
migrations.AlterField(
model_name="featureexternalresource",
name="type",
field=models.CharField(
choices=[
("GITHUB_ISSUE", "GitHub Issue"),
("GITHUB_PR", "GitHub PR"),
("GITLAB_ISSUE", "GitLab Issue"),
("GITLAB_MR", "GitLab MR"),
],
max_length=20,
),
),
]
14 changes: 10 additions & 4 deletions api/features/feature_external_resources/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ class ResourceType(models.TextChoices):
GITHUB_ISSUE = "GITHUB_ISSUE", "GitHub Issue"
GITHUB_PR = "GITHUB_PR", "GitHub PR"

# GitLab external resource types
GITLAB_ISSUE = "GITLAB_ISSUE", "GitLab Issue"
GITLAB_MR = "GITLAB_MR", "GitLab MR"


tag_by_type_and_state = {
ResourceType.GITHUB_ISSUE.value: {
Expand Down Expand Up @@ -65,8 +69,9 @@ class Meta:
models.Index(fields=["type"]),
]

@hook(AFTER_SAVE)
def execute_after_save_actions(self): # type: ignore[no-untyped-def]
@hook(AFTER_SAVE, when="type", is_now="GITHUB_ISSUE")
@hook(AFTER_SAVE, when="type", is_now="GITHUB_PR")
def notify_github_on_link(self): # type: ignore[no-untyped-def]
# Tag the feature with the external resource type
metadata = json.loads(self.metadata) if self.metadata else {}
state = metadata.get("state", "open")
Expand Down Expand Up @@ -130,8 +135,9 @@ def execute_after_save_actions(self): # type: ignore[no-untyped-def]
feature_states=feature_states,
)

@hook(BEFORE_DELETE) # type: ignore[misc]
def execute_before_save_actions(self) -> None:
@hook(BEFORE_DELETE, when="type", is_now="GITHUB_ISSUE") # type: ignore[misc]
@hook(BEFORE_DELETE, when="type", is_now="GITHUB_PR") # type: ignore[misc]
def notify_github_on_unlink(self) -> None:
# Add a comment to GitHub Issue/PR when feature is unlinked to the GH external resource
if (
Organisation.objects.prefetch_related("github_config")
Expand Down
33 changes: 31 additions & 2 deletions api/features/feature_external_resources/views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import re

import structlog
from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator
from drf_spectacular.utils import extend_schema
Expand All @@ -15,7 +16,7 @@
from integrations.github.models import GitHubRepository
from organisations.models import Organisation

from .models import FeatureExternalResource
from .models import FeatureExternalResource, ResourceType
from .serializers import FeatureExternalResourceSerializer


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

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

# Add github issue/PR name to each linked external resource
for resource in data if isinstance(data, list) else []:
if ResourceType(resource["type"]) not in [
ResourceType.GITHUB_ISSUE,
ResourceType.GITHUB_PR,
]:
continue

if resource_url := resource.get("url"):
resource["metadata"] = get_github_issue_pr_title_and_state(
organisation_id=organisation_id, resource_url=resource_url
Expand All @@ -65,6 +72,12 @@ def list(self, request, *args, **kwargs) -> Response: # type: ignore[no-untyped
return Response(data={"results": data})

def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
if request.data.get("type") not in [
ResourceType.GITHUB_ISSUE,
ResourceType.GITHUB_PR,
]:
return super().create(request, *args, **kwargs)

feature = get_object_or_404(
Feature.objects.filter(
id=self.kwargs["feature_pk"],
Expand Down Expand Up @@ -122,6 +135,22 @@ def create(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
status=status.HTTP_400_BAD_REQUEST,
)

def perform_create(self, serializer: FeatureExternalResourceSerializer) -> None: # type: ignore[override]
resource = serializer.save()

log_event_names: dict[ResourceType, tuple[str, str]] = {
ResourceType.GITLAB_ISSUE: ("gitlab", "issue.linked"),
ResourceType.GITLAB_MR: ("gitlab", "merge_request.linked"),
}
if (resource_type := ResourceType(resource.type)) in log_event_names:
logger_name, event_name = log_event_names[resource_type]
structlog.get_logger(logger_name).info(
event_name,
organisation__id=resource.feature.project.organisation_id,
project__id=resource.feature.project_id,
feature__id=resource.feature.id,
)

def perform_update(self, serializer): # type: ignore[no-untyped-def]
external_resource_id = int(self.kwargs["pk"])
serializer.save(id=external_resource_id)
28 changes: 15 additions & 13 deletions api/integrations/gitlab/views/browse_gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from rest_framework.generics import ListAPIView
from rest_framework.request import Request
from rest_framework.response import Response
from structlog.typing import FilteringBoundLogger

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

return self._paginated_response(page_data, request)

def _log_for(self, config: GitLabConfiguration) -> FilteringBoundLogger:
return logger.bind( # type: ignore[no-any-return]
organisation__id=config.project.organisation_id,
project__id=config.project_id,
)

def _get_gitlab_config(self) -> GitLabConfiguration:
return GitLabConfiguration.objects.get( # type: ignore[no-any-return]
project_id=self.kwargs["project_pk"],
Expand Down Expand Up @@ -105,10 +112,7 @@ def fetch_page(
page_size=validated_data["page_size"],
)

logger.info(
"projects-fetched",
project__id=self.kwargs["project_pk"],
)
self._log_for(config).info("projects.fetched")
return page_data


Expand All @@ -130,10 +134,9 @@ def fetch_page(
state=validated_data.get("state", "opened"),
)

logger.info(
"issues-fetched",
project__id=self.kwargs["project_pk"],
gitlab_project_id=validated_data["gitlab_project_id"],
self._log_for(config).info(
"issues.fetched",
gitlab_project__id=validated_data["gitlab_project_id"],
)
return page_data

Expand All @@ -156,9 +159,8 @@ def fetch_page(
state=validated_data.get("state", "opened"),
)

logger.info(
"merge-requests-fetched",
project__id=self.kwargs["project_pk"],
gitlab_project_id=validated_data["gitlab_project_id"],
self._log_for(config).info(
"merge_requests.fetched",
gitlab_project__id=validated_data["gitlab_project_id"],
)
return page_data
15 changes: 7 additions & 8 deletions api/integrations/gitlab/views/configuration.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import structlog
from structlog.typing import FilteringBoundLogger

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

def _log_for(
self, instance: GitLabConfiguration
) -> structlog.typing.FilteringBoundLogger:
def _log_for(self, config: GitLabConfiguration) -> FilteringBoundLogger:
return logger.bind( # type: ignore[no-any-return]
project__id=instance.project.id,
organisation__id=instance.project.organisation_id,
project__id=config.project.id,
organisation__id=config.project.organisation_id,
)

def perform_create(self, serializer: GitLabConfigurationSerializer) -> None: # type: ignore[override]
super().perform_create(serializer)
instance: GitLabConfiguration = serializer.instance # type: ignore[assignment]
self._log_for(instance).info(
"configuration-created",
"configuration.created",
gitlab_instance_url=instance.gitlab_instance_url,
)

def perform_update(self, serializer: GitLabConfigurationSerializer) -> None: # type: ignore[override]
super().perform_update(serializer)
instance: GitLabConfiguration = serializer.instance # type: ignore[assignment]
self._log_for(instance).info(
"configuration-updated",
"configuration.updated",
gitlab_instance_url=instance.gitlab_instance_url,
)

def perform_destroy(self, instance: GitLabConfiguration) -> None:
log = self._log_for(instance)
super().perform_destroy(instance)
log.info("configuration-deleted")
log.info("configuration.deleted")
143 changes: 143 additions & 0 deletions api/tests/integration/features/test_gitlab_external_resources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import pytest
from pytest_structlog import StructuredLogCapture
from rest_framework import status
from rest_framework.test import APIClient

from integrations.github.models import GithubConfiguration
from integrations.gitlab.models import GitLabConfiguration
from projects.models import Project


@pytest.mark.django_db()
def test_create_external_resource__gitlab_issue__returns_201_and_persists_metadata(
admin_client: APIClient,
project: int,
feature: int,
log: StructuredLogCapture,
) -> None:
# Given
metadata = {"title": "Fix login bug", "state": "opened", "iid": 42}
organisation_id = Project.objects.get(id=project).organisation_id

# When
response = admin_client.post(
f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/",
data={
"type": "GITLAB_ISSUE",
"url": "https://gitlab.com/testorg/testrepo/-/issues/42",
"feature": feature,
"metadata": metadata,
},
format="json",
)

# Then
assert response.status_code == status.HTTP_201_CREATED
created = response.json()
assert created["type"] == "GITLAB_ISSUE"
assert created["url"] == "https://gitlab.com/testorg/testrepo/-/issues/42"
assert created["feature"] == feature
assert created["metadata"] == metadata

listed = admin_client.get(
f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/",
).json()["results"]
assert len(listed) == 1
assert listed[0]["metadata"] == metadata

assert log.events == [
{
"level": "info",
"event": "issue.linked",
"organisation__id": organisation_id,
"project__id": project,
"feature__id": feature,
},
]


@pytest.mark.django_db()
def test_create_external_resource__gitlab_merge_request__returns_201(
admin_client: APIClient,
project: int,
feature: int,
log: StructuredLogCapture,
) -> None:
# Given
organisation_id = Project.objects.get(id=project).organisation_id

# When
response = admin_client.post(
f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/",
data={
"type": "GITLAB_MR",
"url": "https://gitlab.com/testorg/testrepo/-/merge_requests/7",
"feature": feature,
},
format="json",
)

# Then
assert response.status_code == status.HTTP_201_CREATED
response_json = response.json()
assert response_json["type"] == "GITLAB_MR"
assert (
response_json["url"] == "https://gitlab.com/testorg/testrepo/-/merge_requests/7"
)

assert log.events == [
{
"level": "info",
"event": "merge_request.linked",
"organisation__id": organisation_id,
"project__id": project,
"feature__id": feature,
},
]


@pytest.mark.django_db()
def test_create_external_resource__gitlab_issue_with_github_also_configured__returns_201(
admin_client: APIClient,
project: int,
feature: int,
log: StructuredLogCapture,
) -> None:
# Given
project_instance = Project.objects.get(id=project)
organisation = project_instance.organisation

GithubConfiguration.objects.create(
organisation=organisation,
installation_id="9999999",
)
GitLabConfiguration.objects.create(
project=project_instance,
gitlab_instance_url="https://gitlab.com",
access_token="glpat-test-token",
)

# When
response = admin_client.post(
f"/api/v1/projects/{project}/features/{feature}/feature-external-resources/",
data={
"type": "GITLAB_ISSUE",
"url": "https://gitlab.com/testorg/testrepo/-/issues/99",
"feature": feature,
},
format="json",
)

# Then
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["type"] == "GITLAB_ISSUE"

assert log.events == [
{
"level": "info",
"event": "issue.linked",
"organisation__id": organisation.id,
"project__id": project,
"feature__id": feature,
},
]
Loading
Loading