diff --git a/CHANGELOG.md b/CHANGELOG.md index accef0f..39727e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/ ## [Unreleased] +### Changed + +- Changed `installation_repositories` internal event handlers to automatically create missing `Installation` models using new `(a)get_or_create_from_event` method, eliminating the need for manual import when connecting to pre-existing GitHub App installations. + ## [0.8.0] ### Added diff --git a/README.md b/README.md index bc675c8..f65bd0c 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ Choose the appropriate setup method based on your situation: > django-github-app needs to create `Installation` and `Repository` models in your database to track where your GitHub App is installed. How this happens depends on your setup method: > > - **New GitHub App**: When you install the app for the first time, GitHub sends an `installation.created` webhook event. django-github-app automatically creates the necessary models when it receives this event. -> - **Existing GitHub App**: If the app is already installed, no `installation.created` webhook event is sent. You must use the `github import-app` management command to manually create the models. +> - **Existing GitHub App**: If the app is already installed, no `installation.created` webhook event is sent. django-github-app will automatically create the models when it receives the first `installation_repositories` event (e.g., when repositories are added/removed). Alternatively, you can use the `github import-app` management command to import the installation immediately. #### Create a New GitHub App @@ -188,7 +188,9 @@ Choose the appropriate setup method based on your situation: #### Use an Existing GitHub App and Installation -If your GitHub App is already installed on organizations/repositories, the `installation.created` webhook event won't be sent when you connect django-github-app to your existing app. In this case, you need to manually import the installation data. +If your GitHub App is already installed on organizations/repositories, the `installation.created` webhook event won't be sent when you connect django-github-app to your existing app. django-github-app will automatically create the necessary models when it receives the first webhook event that includes installation data (such as `installation_repositories`). + +However, if you want to import the installation immediately without waiting for a webhook event, you can use the management command below. 1. Collect your existing app and installation's information. diff --git a/src/django_github_app/models.py b/src/django_github_app/models.py index 44f8067..f56cfde 100644 --- a/src/django_github_app/models.py +++ b/src/django_github_app/models.py @@ -86,9 +86,20 @@ async def aget_from_event(self, event: sansio.Event): except (Installation.DoesNotExist, KeyError): return None + async def aget_or_create_from_event(self, event: sansio.Event): + installation = await self.aget_from_event(event) + if installation is None and "installation" in event.data: + app_id = event.data["installation"]["app_id"] + if app_id == int(app_settings.APP_ID): + installation = await self.acreate_from_gh_data( + event.data["installation"] + ) + return installation + create_from_event = async_to_sync_method(acreate_from_event) create_from_gh_data = async_to_sync_method(acreate_from_gh_data) get_from_event = async_to_sync_method(aget_from_event) + get_or_create_from_event = async_to_sync_method(aget_or_create_from_event) class InstallationStatus(models.IntegerChoices): @@ -219,7 +230,7 @@ def sync_repositories_from_event(self, event: sansio.Event): f"Expected 'installation_repositories' event, got '{event.event}'" ) - installation = Installation.objects.get_from_event(event) + installation = Installation.objects.get_or_create_from_event(event) repositories_added = event.data["repositories_added"] repositories_removed = event.data["repositories_removed"] diff --git a/tests/events/test_ainstallation.py b/tests/events/test_ainstallation.py index 73414f5..effaf63 100644 --- a/tests/events/test_ainstallation.py +++ b/tests/events/test_ainstallation.py @@ -151,3 +151,41 @@ async def test_async_installation_repositories(ainstallation, create_event): assert await Repository.objects.filter( repository_id=data["repositories_added"][0]["id"] ).aexists() + + +async def test_async_installation_repositories_creates_installation( + create_event, override_app_settings +): + app_id = seq.next() + installation_id = seq.next() + + data = { + "installation": { + "id": installation_id, + "app_id": app_id, + "account": {"login": "testorg", "type": "Organization"}, + }, + "repositories_removed": [], + "repositories_added": [ + { + "id": seq.next(), + "node_id": "repo1234", + "full_name": "owner/repo", + } + ], + } + event = create_event("installation_repositories", delivery_id="1234", **data) + + assert not await Installation.objects.filter( + installation_id=installation_id + ).aexists() + + with override_app_settings(APP_ID=str(app_id)): + await async_installation_repositories(event, None) + + installation = await Installation.objects.aget(installation_id=installation_id) + + assert installation.data == data["installation"] + assert await Repository.objects.filter( + repository_id=data["repositories_added"][0]["id"], installation=installation + ).aexists() diff --git a/tests/events/test_installation.py b/tests/events/test_installation.py index b27d446..119f1a1 100644 --- a/tests/events/test_installation.py +++ b/tests/events/test_installation.py @@ -148,3 +148,39 @@ def test_sync_installation_repositories(installation, create_event): assert Repository.objects.filter( repository_id=data["repositories_added"][0]["id"] ).exists() + + +def test_sync_installation_repositories_creates_installation( + create_event, override_app_settings +): + app_id = seq.next() + installation_id = seq.next() + + data = { + "installation": { + "id": installation_id, + "app_id": app_id, + "account": {"login": "testorg", "type": "Organization"}, + }, + "repositories_removed": [], + "repositories_added": [ + { + "id": seq.next(), + "node_id": "repo1234", + "full_name": "owner/repo", + } + ], + } + event = create_event("installation_repositories", delivery_id="1234", **data) + + assert not Installation.objects.filter(installation_id=installation_id).exists() + + with override_app_settings(APP_ID=str(app_id)): + sync_installation_repositories(event, None) + + installation = Installation.objects.get(installation_id=installation_id) + + assert installation.data == data["installation"] + assert Repository.objects.filter( + repository_id=data["repositories_added"][0]["id"], installation=installation + ).exists() diff --git a/tests/test_models.py b/tests/test_models.py index 3c24652..dc96ff8 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -234,6 +234,117 @@ def test_get_from_event(self, installation, create_event): assert result == installation + @pytest.mark.asyncio + async def test_aget_or_create_from_event_existing( + self, ainstallation, create_event + ): + event = create_event( + "installation_repositories", + installation={"id": ainstallation.installation_id, "app_id": seq.next()}, + ) + + result = await Installation.objects.aget_or_create_from_event(event) + + assert result == ainstallation + + @pytest.mark.asyncio + async def test_aget_or_create_from_event_new( + self, create_event, override_app_settings + ): + installation_id = seq.next() + app_id = seq.next() + installation_data = { + "id": installation_id, + "app_id": app_id, + "account": {"login": "testorg", "type": "Organization"}, + } + event = create_event( + "installation_repositories", + installation=installation_data, + repositories_added=[], + repositories_removed=[], + ) + + assert not await Installation.objects.filter( + installation_id=installation_id + ).aexists() + + with override_app_settings(APP_ID=str(app_id)): + result = await Installation.objects.aget_or_create_from_event(event) + + assert result is not None + assert result.installation_id == installation_id + assert result.data == installation_data + + @pytest.mark.asyncio + async def test_aget_or_create_from_event_wrong_app_id( + self, create_event, override_app_settings + ): + installation_data = { + "id": seq.next(), + "app_id": seq.next(), + } + event = create_event( + "installation_repositories", + installation=installation_data, + ) + + with override_app_settings(APP_ID="999999"): + result = await Installation.objects.aget_or_create_from_event(event) + + assert result is None + + def test_get_or_create_from_event_existing(self, installation, create_event): + event = create_event( + "installation_repositories", + installation={"id": installation.installation_id, "app_id": seq.next()}, + ) + + result = Installation.objects.get_or_create_from_event(event) + + assert result == installation + + def test_get_or_create_from_event_new(self, create_event, override_app_settings): + installation_id = seq.next() + app_id = seq.next() + installation_data = { + "id": installation_id, + "app_id": app_id, + "account": {"login": "testorg", "type": "Organization"}, + } + event = create_event( + "installation_repositories", + installation=installation_data, + repositories_added=[], + repositories_removed=[], + ) + + assert not Installation.objects.filter(installation_id=installation_id).exists() + + with override_app_settings(APP_ID=str(app_id)): + result = Installation.objects.get_or_create_from_event(event) + + assert result is not None + assert result.installation_id == installation_id + assert result.data == installation_data + + def test_get_or_create_from_event_wrong_app_id( + self, create_event, override_app_settings + ): + installation_data = { + "id": seq.next(), + "app_id": seq.next(), + } + event = create_event( + "installation_repositories", + installation=installation_data, + ) + + with override_app_settings(APP_ID="999999"): + result = Installation.objects.get_or_create_from_event(event) + + assert result is None + class TestInstallationStatus: @pytest.mark.parametrize(