Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
13 changes: 12 additions & 1 deletion src/django_github_app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"]
Expand Down
38 changes: 38 additions & 0 deletions tests/events/test_ainstallation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
36 changes: 36 additions & 0 deletions tests/events/test_installation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
111 changes: 111 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down