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

fix: Organisation can't have a new Github integration when had a prior one deleted #3874

Merged
Show file tree
Hide file tree
Changes from 2 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
15 changes: 9 additions & 6 deletions api/features/feature_external_resources/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,12 @@ class Meta:
@hook(AFTER_SAVE)
def exectute_after_save_actions(self):
# Add a comment to GitHub Issue/PR when feature is linked to the GH external resource
if hasattr(self.feature.project.organisation, "github_config"):
github_configuration = self.feature.project.organisation.github_config

feature_states = FeatureState.objects.filter(feature_id=self.feature_id)
if github_configuration := self.feature.project.organisation.github_config.filter(
deleted_at__isnull=True
).first():
feature_states = FeatureState.objects.filter(
feature_id=self.feature_id, identity_id__isnull=True
)
feature_data: GithubData = generate_data(
github_configuration,
self.feature_id,
Expand All @@ -68,8 +70,9 @@ def exectute_after_save_actions(self):
@hook(BEFORE_DELETE)
def execute_before_save_actions(self) -> None:
# Add a comment to GitHub Issue/PR when feature is unlinked to the GH external resource
if hasattr(self.feature.project.organisation, "github_config"):
github_configuration = self.feature.project.organisation.github_config
if github_configuration := self.feature.project.organisation.github_config.filter(
deleted_at__isnull=True
).first():
feature_data: GithubData = generate_data(
github_configuration=github_configuration,
feature_id=self.feature_id,
Expand Down
6 changes: 3 additions & 3 deletions api/features/feature_external_resources/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ def create(self, request, *args, **kwargs):
),
)

if not hasattr(feature.project.organisation, "github_config") or not hasattr(
feature.project, "github_project"
):
if not feature.project.organisation.github_config.filter(
deleted_at__isnull=True
).exists() or not hasattr(feature.project, "github_project"):
return Response(
data={
"detail": "This Project doesn't have a valid GitHub integration configuration"
Expand Down
11 changes: 7 additions & 4 deletions api/features/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,11 @@ def trigger_feature_state_change_webhooks(
and instance.environment.project.github_project.exists()
and hasattr(instance.environment.project.organisation, "github_config")
):
github_configuration = instance.environment.project.organisation.github_config

github_configuration = (
instance.environment.project.organisation.github_config.get(
deleted_at__isnull=True
)
)
feature_state = {
"environment_name": new_state["environment"]["name"],
"feature_value": new_state["enabled"],
Expand All @@ -78,11 +81,11 @@ def trigger_feature_state_change_webhooks(
github_configuration=github_configuration,
feature_id=history_instance.feature.id,
feature_name=history_instance.feature.name,
type=WebhookEventType.FLAG_UPDATED,
type=WebhookEventType.FLAG_UPDATED.value,
feature_states=feature_states,
)

feature_data["feature_states"].append(feature_state)
feature_data.feature_states.append(feature_state)

call_github_app_webhook_for_feature_state.delay(
args=(asdict(feature_data),),
Expand Down
6 changes: 6 additions & 0 deletions api/integrations/github/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from rest_framework.exceptions import APIException


class DuplicateGitHubIntegration(APIException):
status_code = 400
default_detail = "Duplication error. The GitHub integration already created"
24 changes: 24 additions & 0 deletions api/integrations/github/migrations/0002_auto_20240501_2137.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 3.2.25 on 2024-05-01 21:37

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('organisations', '0052_create_hubspot_organisation'),
('github', '0001_initial'),
]

operations = [
migrations.AlterField(
model_name='githubconfiguration',
name='organisation',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='github_config', to='organisations.organisation'),
),
migrations.AddConstraint(
model_name='githubconfiguration',
constraint=models.UniqueConstraint(fields=('organisation', 'installation_id'), name='unique_github_config_data'),
),
]
13 changes: 12 additions & 1 deletion api/integrations/github/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@


class GithubConfiguration(SoftDeleteExportableModel):
organisation = models.OneToOneField(
organisation = models.ForeignKey(
Organisation, on_delete=models.CASCADE, related_name="github_config"
)
installation_id = models.CharField(max_length=100, blank=False, null=False)
Expand All @@ -21,6 +21,17 @@ def has_github_configuration(organisation_id: int) -> bool:
organisation_id=organisation_id
).exists()

class Meta:
constraints = [
models.UniqueConstraint(
fields=[
"organisation",
"installation_id",
],
name="unique_github_config_data",
)
]


class GithubRepository(LifecycleModelMixin, SoftDeleteExportableModel):
github_configuration = models.ForeignKey(
Expand Down
25 changes: 21 additions & 4 deletions api/integrations/github/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from integrations.github.client import generate_token
from integrations.github.constants import GITHUB_API_URL, GITHUB_API_VERSION
from integrations.github.exceptions import DuplicateGitHubIntegration
from integrations.github.models import GithubConfiguration, GithubRepository
from integrations.github.permissions import HasPermissionToGithubConfiguration
from integrations.github.serializers import (
Expand Down Expand Up @@ -61,6 +62,17 @@ def get_queryset(self):
organisation_id=self.kwargs["organisation_pk"]
)

def create(self, request, *args, **kwargs):

organisation_id = self.kwargs["organisation_pk"]
if GithubConfiguration.has_github_configuration(organisation_id):
raise DuplicateGitHubIntegration

try:
return super().create(request, *args, **kwargs)
except IntegrityError:
raise DuplicateGitHubIntegration
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a way of looking at the IntegrityError message in order to raise DuplicateGitHubIntegration only when the preconditions have been met?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done



class GithubRepositoryViewSet(viewsets.ModelViewSet):
permission_classes = (
Expand All @@ -86,7 +98,7 @@ def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
except IntegrityError:
raise ValidationError(
detail="Duplication error. The Github repository already linked"
detail="Duplication error. The GitHub repository already linked"
)


Expand All @@ -95,9 +107,11 @@ def create(self, request, *args, **kwargs):
@github_auth_required
def fetch_pull_requests(request, organisation_pk):
organisation = Organisation.objects.get(id=organisation_pk)

github_configuration = GithubConfiguration.objects.get(
organisation=organisation, deleted_at__isnull=True
)
token = generate_token(
organisation.github_config.installation_id,
github_configuration.installation_id,
settings.GITHUB_APP_ID,
)

Expand Down Expand Up @@ -130,8 +144,11 @@ def fetch_pull_requests(request, organisation_pk):
@github_auth_required
def fetch_issues(request, organisation_pk):
organisation = Organisation.objects.get(id=organisation_pk)
github_configuration = GithubConfiguration.objects.get(
organisation=organisation, deleted_at__isnull=True
)
token = generate_token(
organisation.github_config.installation_id,
github_configuration.installation_id,
settings.GITHUB_APP_ID,
)

Expand Down
58 changes: 57 additions & 1 deletion api/tests/unit/integrations/github/test_unit_github_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,62 @@ def test_create_github_configuration(
assert response.status_code == status.HTTP_201_CREATED


@pytest.mark.parametrize(
"client",
[lazy_fixture("admin_master_api_key_client"), lazy_fixture("admin_client")],
)
def test_cannot_create_github_configuration_due_to_unique_constraint(
client: APIClient,
organisation: Organisation,
github_configuration: GithubConfiguration,
) -> None:
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
@pytest.mark.parametrize(
"client",
[lazy_fixture("admin_master_api_key_client"), lazy_fixture("admin_client")],
)
def test_cannot_create_github_configuration_due_to_unique_constraint(
client: APIClient,
organisation: Organisation,
github_configuration: GithubConfiguration,
) -> None:
def test_cannot_create_github_configuration_due_to_unique_constraint(
admin_client_new: APIClient,
organisation: Organisation,
github_configuration: GithubConfiguration,
) -> None:

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

# Given
data = {
"installation_id": 1234567,
}
url = reverse(
"api-v1:organisations:integrations-github-list",
kwargs={"organisation_pk": organisation.id},
)
# When
response = client.post(url, data)
# Then
assert response.status_code == status.HTTP_400_BAD_REQUEST

assert (
"Duplication error. The GitHub integration already created"
in response.json()["detail"]
)


@pytest.mark.parametrize(
"client",
[lazy_fixture("admin_master_api_key_client"), lazy_fixture("admin_client")],
)
def test_cannot_create_github_configuration_when_the_organization_already_has_an_integration(
client: APIClient,
organisation: Organisation,
github_configuration: GithubConfiguration,
) -> None:
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
@pytest.mark.parametrize(
"client",
[lazy_fixture("admin_master_api_key_client"), lazy_fixture("admin_client")],
)
def test_cannot_create_github_configuration_when_the_organization_already_has_an_integration(
client: APIClient,
organisation: Organisation,
github_configuration: GithubConfiguration,
) -> None:
def test_cannot_create_github_configuration_when_the_organization_already_has_an_integration(
admin_client_new: APIClient,
organisation: Organisation,
github_configuration: GithubConfiguration,
) -> None:

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

# Given
data = {
"installation_id": 7654321,
}
url = reverse(
"api-v1:organisations:integrations-github-list",
kwargs={"organisation_pk": organisation.id},
)
# When
response = client.post(url, data)
# Then
assert response.status_code == status.HTTP_400_BAD_REQUEST

assert (
"Duplication error. The GitHub integration already created"
in response.json()["detail"]
)


@pytest.mark.parametrize(
"client",
[lazy_fixture("admin_master_api_key_client"), lazy_fixture("admin_client")],
Expand Down Expand Up @@ -182,7 +238,7 @@ def test_cannot_create_github_repository_due_to_unique_constraint(
assert response.status_code == status.HTTP_400_BAD_REQUEST

assert (
"Duplication error. The Github repository already linked" in response.json()[0]
"Duplication error. The GitHub repository already linked" in response.json()[0]
)


Expand Down