Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add automatic tagging for github integration #4028

Merged
merged 31 commits into from
Aug 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
856e670
feat: Add tags for github integration
novakzaballa May 29, 2024
16abf71
test: Fix file formatting
novakzaballa May 31, 2024
7b7cd4d
feat: Enable linked feature for external resource
novakzaballa Jun 3, 2024
40f6aa7
chore: Move GitHub Event Types to GitHub constants file and standardi…
novakzaballa Jun 3, 2024
d290f53
fix: Fix GitHub comment logic when flag state is updated from GHA
novakzaballa Jun 3, 2024
2d5ce3e
feat: Create "Flagsmith Flag" label in linked repo
novakzaballa Jun 4, 2024
6c3c76c
feat: Label GitHub Issue/PR with "Flagsmith Flag" when linked to a Fe…
novakzaballa Jun 7, 2024
b484efb
Add tagging_enabled property
novakzaballa Jun 28, 2024
4da34f5
Add unit test
novakzaballa Jul 2, 2024
8221d0a
Add types, and move comments
novakzaballa Jul 3, 2024
4695c02
Remove enable linked feature endpoint
novakzaballa Jul 3, 2024
0a0c086
Merge branch 'main' into feat/add-tags-for-github-integration
novakzaballa Jul 3, 2024
43ef261
Solve urls issue
novakzaballa Jul 3, 2024
be7d483
Update unit test for external resources
novakzaballa Jul 3, 2024
af543a9
Delete unnecessary permission
novakzaballa Jul 3, 2024
8415a2d
Code cov
novakzaballa Jul 3, 2024
47365d1
Code cov
novakzaballa Jul 3, 2024
f867862
Improve test
novakzaballa Jul 4, 2024
7248d73
Solve test issue
novakzaballa Jul 4, 2024
6a2c58f
Solve test issue
novakzaballa Jul 4, 2024
09a8bbb
Use custom exception
novakzaballa Jul 4, 2024
2cba204
change test
novakzaballa Jul 4, 2024
6e39be2
change test
novakzaballa Jul 4, 2024
dd2dd26
Add unit Test for GH Pull request
novakzaballa Jul 4, 2024
03a7b8b
Improve tagging and labeling
novakzaballa Aug 7, 2024
fa8b9ef
Merge branch 'main' into feat/add-tags-for-github-integration
novakzaballa Aug 7, 2024
1811c01
Solve labeling issue
novakzaballa Aug 7, 2024
4cfc478
Solve tests
novakzaballa Aug 7, 2024
76e5f3d
code cov 1
novakzaballa Aug 9, 2024
e9e1e59
Code cov 2
novakzaballa Aug 9, 2024
479c8a0
Make sure to save the changes in the features models
novakzaballa Aug 12, 2024
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
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"):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a little bit odd that there would be no github_configuration but there would be a feature.project.github_project.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, but the opposite can happen. There is a github_configuration but not a 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
Loading