Skip to content

Commit

Permalink
feat: Add automatic tagging for github integration (#4028)
Browse files Browse the repository at this point in the history
  • Loading branch information
novakzaballa authored Aug 12, 2024
1 parent 50fd9a8 commit 7920e8e
Show file tree
Hide file tree
Showing 19 changed files with 726 additions and 170 deletions.
42 changes: 33 additions & 9 deletions api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -1018,14 +1018,20 @@ def flagsmith_environments_v2_table(dynamodb: DynamoDBServiceResource) -> Table:


@pytest.fixture()
def feature_external_resource(
feature: Feature, post_request_mock: MagicMock, mocker: MockerFixture
) -> FeatureExternalResource:
mocker.patch(
def mock_github_client_generate_token(mocker: MockerFixture) -> MagicMock:
return mocker.patch(
"integrations.github.client.generate_token",
return_value="mocked_token",
)


@pytest.fixture()
def feature_external_resource(
feature: Feature,
post_request_mock: MagicMock,
mocker: MockerFixture,
mock_github_client_generate_token: MagicMock,
) -> FeatureExternalResource:
return FeatureExternalResource.objects.create(
url="https://github.com/repositoryownertest/repositorynametest/issues/11",
type="GITHUB_ISSUE",
Expand All @@ -1035,16 +1041,26 @@ def feature_external_resource(


@pytest.fixture()
def feature_with_value_external_resource(
feature_with_value: Feature,
def feature_external_resource_gh_pr(
feature: Feature,
post_request_mock: MagicMock,
mocker: MockerFixture,
mock_github_client_generate_token: MagicMock,
) -> FeatureExternalResource:
mocker.patch(
"integrations.github.client.generate_token",
return_value="mocked_token",
return FeatureExternalResource.objects.create(
url="https://github.com/repositoryownertest/repositorynametest/pull/1",
type="GITHUB_PR",
feature=feature,
metadata='{"status": "open"}',
)


@pytest.fixture()
def feature_with_value_external_resource(
feature_with_value: Feature,
post_request_mock: MagicMock,
mock_github_client_generate_token: MagicMock,
) -> FeatureExternalResource:
return FeatureExternalResource.objects.create(
url="https://github.com/repositoryownertest/repositorynametest/issues/11",
type="GITHUB_ISSUE",
Expand All @@ -1069,6 +1085,7 @@ def github_repository(
repository_owner="repositoryownertest",
repository_name="repositorynametest",
project=project,
tagging_enabled=True,
)


Expand Down Expand Up @@ -1120,3 +1137,10 @@ def handle(self, record: logging.LogRecord) -> None:
self.messages.append(self.format(record))

return InspectingHandler()


@pytest.fixture
def set_github_webhook_secret() -> None:
from django.conf import settings

settings.GITHUB_WEBHOOK_SECRET = "secret-key"
46 changes: 42 additions & 4 deletions api/features/feature_external_resources/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import logging

from django.db import models
Expand All @@ -11,9 +12,11 @@

from environments.models import Environment
from features.models import Feature, FeatureState
from integrations.github.constants import GitHubEventType, GitHubTag
from integrations.github.github import call_github_task
from integrations.github.models import GithubRepository
from organisations.models import Organisation
from webhooks.webhooks import WebhookEventType
from projects.tags.models import Tag, TagType

logger = logging.getLogger(__name__)

Expand All @@ -24,6 +27,20 @@ class ResourceType(models.TextChoices):
GITHUB_PR = "GITHUB_PR", "GitHub PR"


tag_by_type_and_state = {
ResourceType.GITHUB_ISSUE.value: {
"open": GitHubTag.ISSUE_OPEN.value,
"closed": GitHubTag.ISSUE_CLOSED.value,
},
ResourceType.GITHUB_PR.value: {
"open": GitHubTag.PR_OPEN.value,
"closed": GitHubTag.PR_CLOSED.value,
"merged": GitHubTag.PR_MERGED.value,
"draft": GitHubTag.PR_DRAFT.value,
},
}


class FeatureExternalResource(LifecycleModelMixin, models.Model):
url = models.URLField()
type = models.CharField(max_length=20, choices=ResourceType.choices)
Expand All @@ -49,12 +66,33 @@ class Meta:

@hook(AFTER_SAVE)
def execute_after_save_actions(self):
# Tag the feature with the external resource type
metadata = json.loads(self.metadata) if self.metadata else {}
state = metadata.get("state", "open")

# Add a comment to GitHub Issue/PR when feature is linked to the GH external resource
# and tag the feature with the corresponding tag if tagging is enabled
if (
Organisation.objects.prefetch_related("github_config")
github_configuration := Organisation.objects.prefetch_related(
"github_config"
)
.get(id=self.feature.project.organisation_id)
.github_config.first()
):
github_repo = GithubRepository.objects.get(
github_configuration=github_configuration.id,
project=self.feature.project,
)
if github_repo.tagging_enabled:
github_tag = Tag.objects.get(
label=tag_by_type_and_state[self.type][state],
project=self.feature.project,
is_system_tag=True,
type=TagType.GITHUB.value,
)
self.feature.tags.add(github_tag)
self.feature.save()

feature_states: list[FeatureState] = []

environments = Environment.objects.filter(
Expand All @@ -74,7 +112,7 @@ def execute_after_save_actions(self):

call_github_task(
organisation_id=self.feature.project.organisation_id,
type=WebhookEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value,
type=GitHubEventType.FEATURE_EXTERNAL_RESOURCE_ADDED.value,
feature=self.feature,
segment_name=None,
url=None,
Expand All @@ -92,7 +130,7 @@ def execute_before_save_actions(self) -> None:

call_github_task(
organisation_id=self.feature.project.organisation_id,
type=WebhookEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value,
type=GitHubEventType.FEATURE_EXTERNAL_RESOURCE_REMOVED.value,
feature=self.feature,
segment_name=None,
url=self.url,
Expand Down
60 changes: 50 additions & 10 deletions api/features/feature_external_resources/views.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import re

from django.shortcuts import get_object_or_404
from rest_framework import status, viewsets
from rest_framework.response import Response

from features.models import Feature
from features.permissions import FeatureExternalResourcePermissions
from integrations.github.client import get_github_issue_pr_title_and_state
from integrations.github.client import (
get_github_issue_pr_title_and_state,
label_github_issue_pr,
)
from integrations.github.models import GithubRepository
from organisations.models import Organisation

from .models import FeatureExternalResource
Expand Down Expand Up @@ -48,22 +54,56 @@ def create(self, request, *args, **kwargs):
),
)

if not (
(
Organisation.objects.prefetch_related("github_config")
.get(id=feature.project.organisation_id)
.github_config.first()
)
or not hasattr(feature.project, "github_project")
):
github_configuration = (
Organisation.objects.prefetch_related("github_config")
.get(id=feature.project.organisation_id)
.github_config.first()
)

if not github_configuration or not hasattr(feature.project, "github_project"):
return Response(
data={
"detail": "This Project doesn't have a valid GitHub integration configuration"
},
content_type="application/json",
status=status.HTTP_400_BAD_REQUEST,
)
return super().create(request, *args, **kwargs)

# Get repository owner and name, and issue/PR number from the external resource URL
url = request.data.get("url")
if request.data.get("type") == "GITHUB_PR":
pattern = r"github.com/([^/]+)/([^/]+)/pull/(\d+)$"
elif request.data.get("type") == "GITHUB_ISSUE":
pattern = r"github.com/([^/]+)/([^/]+)/issues/(\d+)$"
else:
return Response(
data={"detail": "Incorrect GitHub type"},
content_type="application/json",
status=status.HTTP_400_BAD_REQUEST,
)

match = re.search(pattern, url)
if match:
owner, repo, issue = match.groups()
if GithubRepository.objects.get(
github_configuration=github_configuration,
repository_owner=owner,
repository_name=repo,
).tagging_enabled:
label_github_issue_pr(
installation_id=github_configuration.installation_id,
owner=owner,
repo=repo,
issue=issue,
)
response = super().create(request, *args, **kwargs)
return response
else:
return Response(
data={"detail": "Invalid GitHub Issue/PR URL"},
content_type="application/json",
status=status.HTTP_400_BAD_REQUEST,
)

def perform_update(self, serializer):
external_resource_id = int(self.kwargs["pk"])
Expand Down
7 changes: 3 additions & 4 deletions api/features/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
STRING,
)
from features.versioning.models import EnvironmentFeatureVersion
from integrations.github.constants import GitHubEventType
from metadata.models import Metadata
from projects.models import Project
from projects.tags.models import Tag
Expand Down Expand Up @@ -139,7 +140,6 @@ class Meta:
@hook(AFTER_SAVE)
def create_github_comment(self) -> None:
from integrations.github.github import call_github_task
from webhooks.webhooks import WebhookEventType

if (
self.external_resources.exists()
Expand All @@ -150,7 +150,7 @@ def create_github_comment(self) -> None:

call_github_task(
organisation_id=self.project.organisation_id,
type=WebhookEventType.FLAG_DELETED.value,
type=GitHubEventType.FLAG_DELETED.value,
feature=self,
segment_name=None,
url=None,
Expand Down Expand Up @@ -406,7 +406,6 @@ def _get_environment(self) -> "Environment":
@hook(AFTER_DELETE)
def create_github_comment(self) -> None:
from integrations.github.github import call_github_task
from webhooks.webhooks import WebhookEventType

if (
self.feature.external_resources.exists()
Expand All @@ -416,7 +415,7 @@ def create_github_comment(self) -> None:

call_github_task(
self.feature.project.organisation_id,
WebhookEventType.SEGMENT_OVERRIDE_DELETED.value,
GitHubEventType.SEGMENT_OVERRIDE_DELETED.value,
self.feature,
self.segment.name,
None,
Expand Down
4 changes: 2 additions & 2 deletions api/features/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from environments.sdk.serializers_mixins import (
HideSensitiveFieldsSerializerMixin,
)
from integrations.github.constants import GitHubEventType
from integrations.github.github import call_github_task
from metadata.serializers import MetadataSerializer, SerializerWithMetadata
from projects.models import Project
Expand All @@ -30,7 +31,6 @@
from util.drf_writable_nested.serializers import (
DeleteBeforeUpdateWritableNestedModelSerializer,
)
from webhooks.webhooks import WebhookEventType

from .constants import INTERSECTION, UNION
from .feature_segments.serializers import (
Expand Down Expand Up @@ -478,7 +478,7 @@ def save(self, **kwargs):

call_github_task(
organisation_id=feature_state.feature.project.organisation_id,
type=WebhookEventType.FLAG_UPDATED.value,
type=GitHubEventType.FLAG_UPDATED.value,
feature=feature_state.feature,
segment_name=None,
url=None,
Expand Down
4 changes: 2 additions & 2 deletions api/features/versioning/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
CustomCreateSegmentOverrideFeatureStateSerializer,
)
from features.versioning.models import EnvironmentFeatureVersion
from integrations.github.constants import GitHubEventType
from integrations.github.github import call_github_task
from segments.models import Segment
from users.models import FFAdminUser
from webhooks.webhooks import WebhookEventType


class CustomEnvironmentFeatureVersionFeatureStateSerializer(
Expand All @@ -36,7 +36,7 @@ def save(self, **kwargs):

call_github_task(
organisation_id=feature_state.environment.project.organisation_id,
type=WebhookEventType.FLAG_UPDATED.value,
type=GitHubEventType.FLAG_UPDATED.value,
feature=feature_state.feature,
segment_name=None,
url=None,
Expand Down
Loading

0 comments on commit 7920e8e

Please sign in to comment.