Skip to content

Commit

Permalink
feat: Add tags for github integration
Browse files Browse the repository at this point in the history
  • Loading branch information
novakzaballa committed May 31, 2024
1 parent 1963e03 commit 856e670
Show file tree
Hide file tree
Showing 9 changed files with 212 additions and 13 deletions.
29 changes: 29 additions & 0 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,8 +12,10 @@

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

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,6 +66,18 @@ 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")
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)

# Add a comment to GitHub Issue/PR when feature is linked to the GH external resource
if (
Organisation.objects.prefetch_related("github_config")
Expand Down
3 changes: 3 additions & 0 deletions api/integrations/github/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ def fetch_search_github_resource(
"id": i["id"],
"title": i["title"],
"number": i["number"],
"state": i["state"],
"merged": i.get("merged", False),
"draft": i.get("draft", False),
}
for i in json_response["items"]
]
Expand Down
23 changes: 23 additions & 0 deletions api/integrations/github/constants.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from enum import Enum

GITHUB_API_URL = "https://api.github.com/"
GITHUB_API_VERSION = "2022-11-28"

Expand All @@ -15,3 +17,24 @@
)
FEATURE_ENVIRONMENT_URL = "%s/project/%s/environment/%s/features?feature=%s&tab=%s"
GITHUB_API_CALLS_TIMEOUT = 10

GITHUB_TAG_COLOR = "#838992"


class GitHubTag(Enum):
PR_OPEN = "PR Open"
PR_MERGED = "PR Merged"
PR_CLOSED = "PR Closed"
PR_DRAFT = "PR Draft"
ISSUE_OPEN = "Issue Open"
ISSUE_CLOSED = "Issue Closed"


github_tag_description = {
GitHubTag.PR_OPEN.value: "This feature has a linked PR open",
GitHubTag.PR_MERGED.value: "This feature has a linked PR merged",
GitHubTag.PR_CLOSED.value: "This feature has a linked PR closed",
GitHubTag.PR_DRAFT.value: "This feature has a linked PR draft",
GitHubTag.ISSUE_OPEN.value: "This feature has a linked issue open",
GitHubTag.ISSUE_CLOSED.value: "This feature has a linked issue closed",
}
59 changes: 59 additions & 0 deletions api/integrations/github/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Any

from core.helpers import get_current_site_url
from django.db.models import Q
from django.utils.formats import get_format

from features.models import Feature, FeatureState, FeatureStateValue
Expand All @@ -17,14 +18,68 @@
LINK_SEGMENT_TITLE,
UNLINKED_FEATURE_TEXT,
UPDATED_FEATURE_TEXT,
GitHubTag,
)
from integrations.github.dataclasses import GithubData
from integrations.github.models import GithubConfiguration
from integrations.github.tasks import call_github_app_webhook_for_feature_state
from projects.tags.models import Tag, TagType
from webhooks.webhooks import WebhookEventType

logger = logging.getLogger(__name__)

tag_by_event_type = {
"pull_request": {
"closed": GitHubTag.PR_CLOSED.value,
"converted_to_draft": GitHubTag.PR_DRAFT.value,
"opened": GitHubTag.PR_OPEN.value,
"reopened": GitHubTag.PR_OPEN.value,
"ready_for_review": GitHubTag.PR_OPEN.value,
"merged": GitHubTag.PR_MERGED.value,
},
"issues": {
"closed": GitHubTag.ISSUE_CLOSED.value,
"opened": GitHubTag.ISSUE_OPEN.value,
"reopened": GitHubTag.ISSUE_OPEN.value,
},
}


def tag_feature_per_github_event(
event_type: str, action: str, metadata: dict[str, Any]
) -> None:

# Get Feature with external resource of type GITHUB and url matching the resource URL
feature = Feature.objects.filter(
Q(external_resources__type="GITHUB_PR")
| Q(external_resources__type="GITHUB_ISSUE"),
external_resources__url=metadata.get("html_url"),
).first()

if feature:
if (
event_type == "pull_request"
and action == "closed"
and metadata.get("merged")
):
action = "merged"
# Get corresponding project Tag to tag the feature
github_tag = Tag.objects.get(
label=tag_by_event_type[event_type][action],
project=feature.project_id,
is_system_tag=True,
type=TagType.GITHUB.value,
)
tag_label_pattern = "Issue" if event_type == "issues" else "PR"
# Remove all GITHUB tags from the feature which label starts with issue or pr depending on event_type
feature.tags.remove(
*feature.tags.filter(
Q(type=TagType.GITHUB.value) & Q(label__startswith=tag_label_pattern)
)
)

feature.tags.add(github_tag)


def handle_installation_deleted(payload: dict[str, Any]) -> None:
installation_id = payload.get("installation", {}).get("id")
Expand All @@ -42,6 +97,10 @@ def handle_installation_deleted(payload: dict[str, Any]) -> None:
def handle_github_webhook_event(event_type: str, payload: dict[str, Any]) -> None:
if event_type == "installation" and payload.get("action") == "deleted":
handle_installation_deleted(payload)
elif event_type in tag_by_event_type:
action = str(payload.get("action"))
metadata = payload.get("issue", {}) or payload.get("pull_request", {})
tag_feature_per_github_event(event_type, action, metadata)


def generate_body_comment(
Expand Down
28 changes: 27 additions & 1 deletion api/integrations/github/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@

from core.models import SoftDeleteExportableModel
from django.db import models
from django_lifecycle import BEFORE_DELETE, LifecycleModelMixin, hook
from django_lifecycle import (
AFTER_CREATE,
BEFORE_DELETE,
LifecycleModelMixin,
hook,
)

from integrations.github.constants import GITHUB_TAG_COLOR
from organisations.models import Organisation

logger: logging.Logger = logging.getLogger(name=__name__)
Expand Down Expand Up @@ -84,3 +90,23 @@ def delete_feature_external_resources(
# Filter by url containing the repository owner and name
url__regex=pattern,
).delete()

@hook(AFTER_CREATE)
def create_github_tags(
self,
) -> None:
from integrations.github.constants import (
GitHubTag,
github_tag_description,
)
from projects.tags.models import Tag, TagType

for tag_label in GitHubTag:
tag, created = Tag.objects.get_or_create(
color=GITHUB_TAG_COLOR,
description=github_tag_description[tag_label.value],
label=tag_label.value,
project=self.project,
is_system_tag=True,
type=TagType.GITHUB.value,
)
7 changes: 5 additions & 2 deletions api/integrations/github/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
fetch_search_github_resource,
)
from integrations.github.exceptions import DuplicateGitHubIntegration
from integrations.github.github import handle_github_webhook_event
from integrations.github.github import (
handle_github_webhook_event,
tag_by_event_type,
)
from integrations.github.helpers import github_webhook_payload_is_valid
from integrations.github.models import GithubConfiguration, GithubRepository
from integrations.github.permissions import HasPermissionToGithubConfiguration
Expand Down Expand Up @@ -250,7 +253,7 @@ def github_webhook(request) -> Response:
payload_body=payload, secret_token=secret, signature_header=signature
):
data = json.loads(payload.decode("utf-8"))
if github_event == "installation":
if github_event == "installation" or github_event in tag_by_event_type:
handle_github_webhook_event(event_type=github_event, payload=data)
return Response({"detail": "Event processed"}, status=200)
else:
Expand Down
18 changes: 18 additions & 0 deletions api/projects/tags/migrations/0006_alter_tag_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.2.25 on 2024-05-27 15:03

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('tags', '0005_add_tag_fields_for_stale_flags_logic'),
]

operations = [
migrations.AlterField(
model_name='tag',
name='type',
field=models.CharField(choices=[('NONE', 'None'), ('STALE', 'Stale'), ('GITHUB', 'Github')], default='NONE', help_text='Field used to provide a consistent identifier for the FE and API to use for business logic.', max_length=100),
),
]
1 change: 1 addition & 0 deletions api/projects/tags/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
class TagType(models.Choices):
NONE = "NONE"
STALE = "STALE"
GITHUB = "GITHUB"


class Tag(AbstractBaseExportableModel):
Expand Down
57 changes: 47 additions & 10 deletions api/tests/unit/integrations/github/test_unit_github_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from rest_framework.test import APIClient

from features.feature_external_resources.models import FeatureExternalResource
from features.models import Feature
from integrations.github.constants import GITHUB_API_URL
from integrations.github.models import GithubConfiguration, GithubRepository
from integrations.github.views import (
Expand All @@ -29,13 +30,25 @@
WEBHOOK_PAYLOAD_WITHOUT_INSTALLATION_ID = json.dumps(
{"installation": {"test": 765432}, "action": "deleted"}
)
WEBHOOK_PAYLOAD_MERGED = json.dumps(
{
"pull_request": {
"id": 1234567,
"html_url": "https://github.com/repositoryownertest/repositorynametest/issues/11",
"merged": True,
},
"action": "closed",
}
)

WEBHOOK_SIGNATURE = "sha1=57a1426e19cdab55dd6d0c191743e2958e50ccaa"
WEBHOOK_SIGNATURE_WITH_AN_INVALID_INSTALLATION_ID = (
"sha1=081eef49d04df27552587d5df1c6b76e0fe20d21"
)
WEBHOOK_SIGNATURE_WITHOUT_INSTALLATION_ID = (
"sha1=f99796bd3cebb902864e87ed960c5cca8772ff67"
)
WEBHOOK_MERGED_ACTION_SIGNATURE = "sha1=712ec7a5db14aad99d900da40738ebb9508ecad2"
WEBHOOK_SECRET = "secret-key"


Expand Down Expand Up @@ -656,37 +669,61 @@ def test_github_webhook_delete_installation(
assert not GithubConfiguration.objects.filter(installation_id=1234567).exists()


def test_github_webhook_with_non_existing_installation(
def test_github_webhook_merged_a_pull_request(
api_client: APIClient,
feature: Feature,
github_configuration: GithubConfiguration,
mocker: MockerFixture,
github_repository: GithubRepository,
feature_external_resource: FeatureExternalResource,
) -> None:
# Given
settings.GITHUB_WEBHOOK_SECRET = WEBHOOK_SECRET
url = reverse("api-v1:github-webhook")

# When
response = api_client.post(
path=url,
data=WEBHOOK_PAYLOAD_MERGED,
content_type="application/json",
HTTP_X_HUB_SIGNATURE=WEBHOOK_MERGED_ACTION_SIGNATURE,
HTTP_X_GITHUB_EVENT="pull_request",
)

# Then
feature.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert feature.tags.first().label == "PR Merged"

def test_github_webhook_without_installation_id(
api_client: APIClient,
mocker: MockerFixture,
)-> None:
# Given
settings.GITHUB_WEBHOOK_SECRET = WEBHOOK_SECRET
url = reverse("api-v1:github-webhook")
mocker_logger = mocker.patch("integrations.github.github.logger")

# When
response = api_client.post(
path=url,
data=WEBHOOK_PAYLOAD_WITH_AN_INVALID_INSTALLATION_ID,
data=WEBHOOK_PAYLOAD_WITHOUT_INSTALLATION_ID,
content_type="application/json",
HTTP_X_HUB_SIGNATURE=WEBHOOK_SIGNATURE_WITH_AN_INVALID_INSTALLATION_ID,
HTTP_X_HUB_SIGNATURE=WEBHOOK_SIGNATURE_WITHOUT_INSTALLATION_ID,
HTTP_X_GITHUB_EVENT="installation",
)

# Then
mocker_logger.error.assert_called_once_with(
"GitHub Configuration with installation_id 765432 does not exist"
"The installation_id is not present in the payload: {'installation': {'test': 765432}, 'action': 'deleted'}"
)
assert response.status_code == status.HTTP_200_OK


def test_github_webhook_without_installation_id(
def test_github_webhook_with_non_existing_installation(
api_client: APIClient,
github_configuration: GithubConfiguration,
mocker: MockerFixture,
) -> None:
)-> None:
# Given
settings.GITHUB_WEBHOOK_SECRET = WEBHOOK_SECRET
url = reverse("api-v1:github-webhook")
Expand All @@ -695,15 +732,15 @@ def test_github_webhook_without_installation_id(
# When
response = api_client.post(
path=url,
data=WEBHOOK_PAYLOAD_WITHOUT_INSTALLATION_ID,
data=WEBHOOK_PAYLOAD_WITH_AN_INVALID_INSTALLATION_ID,
content_type="application/json",
HTTP_X_HUB_SIGNATURE=WEBHOOK_SIGNATURE_WITHOUT_INSTALLATION_ID,
HTTP_X_HUB_SIGNATURE=WEBHOOK_SIGNATURE_WITH_AN_INVALID_INSTALLATION_ID,
HTTP_X_GITHUB_EVENT="installation",
)

# Then
mocker_logger.error.assert_called_once_with(
"The installation_id is not present in the payload: {'installation': {'test': 765432}, 'action': 'deleted'}"
"GitHub Configuration with installation_id 765432 does not exist"
)
assert response.status_code == status.HTTP_200_OK

Expand Down

0 comments on commit 856e670

Please sign in to comment.