From 1cd07e18497da25e20776438db55e5106891d715 Mon Sep 17 00:00:00 2001 From: Josh Thomas Date: Mon, 28 Jul 2025 08:59:32 -0500 Subject: [PATCH 1/3] Clarify new/existing GitHub App installation documentation (#99) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.md | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 92e7b0a..bc675c8 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ cog.outl(f"- Django {', '.join([version for version in DJ_VERSIONS if version != > - Use `"async"` with `AsyncWebhookView` > - Use `"sync"` with `SyncWebhookView` -5. Setup your GitHub App, either by registering a new one or importing an existing one, and configure django-github-app using your GitHub App's information. +5. Setup your GitHub App and configure django-github-app using your GitHub App's information. You will need the following information from your GitHub App: @@ -136,7 +136,22 @@ cog.outl(f"- Django {', '.join([version for version in DJ_VERSIONS if version != > > django-github-app will automatically detect if `GITHUB_APP["PRIVATE_KEY"]` is a path and load the file contents. For more information, see the [`PRIVATE_KEY`](#private_key) section in the [Configuration](#configuration) documentation below. -### Create a New GitHub App +### GitHub App Setup + +After completing the [Installation](#installation) steps above, you will need to set up your GitHub App using the information from Step 5 above. + +Choose the appropriate setup method based on your situation: + +- **[Create a New GitHub App](#create-a-new-github-app)**: If you're setting up a fresh GitHub App +- **[Use an Existing GitHub App](#use-an-existing-github-app-and-installation)**: If your GitHub App is already installed on organizations/repositories + +> [!IMPORTANT] +> 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. + +#### Create a New GitHub App 1. Register a new GitHub App, following [these instructions](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) from the GitHub Docs. For a more detailed tutorial, there is also [this page](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/building-a-github-app-that-responds-to-webhook-events) -- in particular the section on [Setup](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/building-a-github-app-that-responds-to-webhook-events#setup). @@ -169,9 +184,11 @@ cog.outl(f"- Django {', '.join([version for version in DJ_VERSIONS if version != - Select the account to install it on - Choose which repositories to give it access to - When you install the app, django-github-app will automatically create the necessary `Installation` and `Repository` models when it receives the `installation.created` webhook event. + When you install the app **for the first time**, django-github-app will automatically create the necessary `Installation` and `Repository` models when it receives the `installation.created` webhook event. + +#### Use an Existing GitHub App and Installation -### 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. 1. Collect your existing app and installation's information. @@ -199,7 +216,7 @@ cog.outl(f"- Django {', '.join([version for version in DJ_VERSIONS if version != } ``` -3. Import your existing GitHub App by using the `github import-app` management command. +3. Import your existing GitHub App by using the `github import-app` management command to create the necessary `Installation` and `Repository` models. ```bash python manage.py github import-app --type user --name --installation-id 123456 @@ -209,6 +226,9 @@ cog.outl(f"- Django {', '.join([version for version in DJ_VERSIONS if version != uv run manage.py github import-app --type user --name --installation-id 123456 ``` +> [!NOTE] +> After importing, django-github-app will handle future webhook events normally, including repository additions/removals via the `installation_repositories` event. + ## Getting Started ### Webhook Events From d7298593b2c48d3332b3625ce55ad439b502f298 Mon Sep 17 00:00:00 2001 From: Josh Thomas Date: Mon, 28 Jul 2025 09:52:01 -0500 Subject: [PATCH 2/3] add `(a)get_or_create_from_event` method to `Installation` manager (#101) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- CHANGELOG.md | 4 ++ README.md | 6 +- src/django_github_app/models.py | 13 +++- tests/events/test_ainstallation.py | 38 ++++++++++ tests/events/test_installation.py | 36 ++++++++++ tests/test_models.py | 111 +++++++++++++++++++++++++++++ 6 files changed, 205 insertions(+), 3 deletions(-) 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( From 1dad6b77a542778b51afc8f08db70b6392d54825 Mon Sep 17 00:00:00 2001 From: Josh Thomas Date: Mon, 28 Jul 2025 09:55:02 -0500 Subject: [PATCH 3/3] :bookmark: bump version 0.8.0 -> 0.9.0 (#103) --- CHANGELOG.md | 5 ++++- pyproject.toml | 2 +- src/django_github_app/__init__.py | 2 +- tests/test_version.py | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39727e5..46bdd68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/ ## [Unreleased] +## [0.9.0] + ### 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. @@ -123,7 +125,7 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/ - Josh Thomas (maintainer) -[unreleased]: https://github.com/joshuadavidthomas/django-github-app/compare/v0.8.0...HEAD +[unreleased]: https://github.com/joshuadavidthomas/django-github-app/compare/v0.9.0...HEAD [0.1.0]: https://github.com/joshuadavidthomas/django-github-app/releases/tag/v0.1.0 [0.2.0]: https://github.com/joshuadavidthomas/django-github-app/releases/tag/v0.2.0 [0.2.1]: https://github.com/joshuadavidthomas/django-github-app/releases/tag/v0.2.1 @@ -134,3 +136,4 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/ [0.6.1]: https://github.com/joshuadavidthomas/django-github-app/releases/tag/v0.6.1 [0.7.0]: https://github.com/joshuadavidthomas/django-github-app/releases/tag/v0.7.0 [0.8.0]: https://github.com/joshuadavidthomas/django-github-app/releases/tag/v0.8.0 +[0.9.0]: https://github.com/joshuadavidthomas/django-github-app/releases/tag/v0.9.0 diff --git a/pyproject.toml b/pyproject.toml index 1e37b7c..5063fa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,7 +102,7 @@ Source = "https://github.com/joshuadavidthomas/django-github-app" [tool.bumpver] commit = true commit_message = ":bookmark: bump version {old_version} -> {new_version}" -current_version = "0.8.0" +current_version = "0.9.0" push = false # set to false for CI tag = false version_pattern = "MAJOR.MINOR.PATCH[PYTAGNUM]" diff --git a/src/django_github_app/__init__.py b/src/django_github_app/__init__.py index eb7f82b..3f0fbb0 100644 --- a/src/django_github_app/__init__.py +++ b/src/django_github_app/__init__.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "0.8.0" +__version__ = "0.9.0" diff --git a/tests/test_version.py b/tests/test_version.py index 46065d6..eb5b69d 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -4,4 +4,4 @@ def test_version(): - assert __version__ == "0.8.0" + assert __version__ == "0.9.0"