Skip to content

Commit d729859

Browse files
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>
1 parent 1cd07e1 commit d729859

File tree

6 files changed

+205
-3
lines changed

6 files changed

+205
-3
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
1818

1919
## [Unreleased]
2020

21+
### Changed
22+
23+
- 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.
24+
2125
## [0.8.0]
2226

2327
### Added

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ Choose the appropriate setup method based on your situation:
149149
> 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:
150150
>
151151
> - **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.
152-
> - **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.
152+
> - **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.
153153
154154
#### Create a New GitHub App
155155
@@ -188,7 +188,9 @@ Choose the appropriate setup method based on your situation:
188188
189189
#### Use an Existing GitHub App and Installation
190190
191-
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.
191+
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`).
192+
193+
However, if you want to import the installation immediately without waiting for a webhook event, you can use the management command below.
192194
193195
1. Collect your existing app and installation's information.
194196

src/django_github_app/models.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,20 @@ async def aget_from_event(self, event: sansio.Event):
8686
except (Installation.DoesNotExist, KeyError):
8787
return None
8888

89+
async def aget_or_create_from_event(self, event: sansio.Event):
90+
installation = await self.aget_from_event(event)
91+
if installation is None and "installation" in event.data:
92+
app_id = event.data["installation"]["app_id"]
93+
if app_id == int(app_settings.APP_ID):
94+
installation = await self.acreate_from_gh_data(
95+
event.data["installation"]
96+
)
97+
return installation
98+
8999
create_from_event = async_to_sync_method(acreate_from_event)
90100
create_from_gh_data = async_to_sync_method(acreate_from_gh_data)
91101
get_from_event = async_to_sync_method(aget_from_event)
102+
get_or_create_from_event = async_to_sync_method(aget_or_create_from_event)
92103

93104

94105
class InstallationStatus(models.IntegerChoices):
@@ -219,7 +230,7 @@ def sync_repositories_from_event(self, event: sansio.Event):
219230
f"Expected 'installation_repositories' event, got '{event.event}'"
220231
)
221232

222-
installation = Installation.objects.get_from_event(event)
233+
installation = Installation.objects.get_or_create_from_event(event)
223234

224235
repositories_added = event.data["repositories_added"]
225236
repositories_removed = event.data["repositories_removed"]

tests/events/test_ainstallation.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,41 @@ async def test_async_installation_repositories(ainstallation, create_event):
151151
assert await Repository.objects.filter(
152152
repository_id=data["repositories_added"][0]["id"]
153153
).aexists()
154+
155+
156+
async def test_async_installation_repositories_creates_installation(
157+
create_event, override_app_settings
158+
):
159+
app_id = seq.next()
160+
installation_id = seq.next()
161+
162+
data = {
163+
"installation": {
164+
"id": installation_id,
165+
"app_id": app_id,
166+
"account": {"login": "testorg", "type": "Organization"},
167+
},
168+
"repositories_removed": [],
169+
"repositories_added": [
170+
{
171+
"id": seq.next(),
172+
"node_id": "repo1234",
173+
"full_name": "owner/repo",
174+
}
175+
],
176+
}
177+
event = create_event("installation_repositories", delivery_id="1234", **data)
178+
179+
assert not await Installation.objects.filter(
180+
installation_id=installation_id
181+
).aexists()
182+
183+
with override_app_settings(APP_ID=str(app_id)):
184+
await async_installation_repositories(event, None)
185+
186+
installation = await Installation.objects.aget(installation_id=installation_id)
187+
188+
assert installation.data == data["installation"]
189+
assert await Repository.objects.filter(
190+
repository_id=data["repositories_added"][0]["id"], installation=installation
191+
).aexists()

tests/events/test_installation.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,39 @@ def test_sync_installation_repositories(installation, create_event):
148148
assert Repository.objects.filter(
149149
repository_id=data["repositories_added"][0]["id"]
150150
).exists()
151+
152+
153+
def test_sync_installation_repositories_creates_installation(
154+
create_event, override_app_settings
155+
):
156+
app_id = seq.next()
157+
installation_id = seq.next()
158+
159+
data = {
160+
"installation": {
161+
"id": installation_id,
162+
"app_id": app_id,
163+
"account": {"login": "testorg", "type": "Organization"},
164+
},
165+
"repositories_removed": [],
166+
"repositories_added": [
167+
{
168+
"id": seq.next(),
169+
"node_id": "repo1234",
170+
"full_name": "owner/repo",
171+
}
172+
],
173+
}
174+
event = create_event("installation_repositories", delivery_id="1234", **data)
175+
176+
assert not Installation.objects.filter(installation_id=installation_id).exists()
177+
178+
with override_app_settings(APP_ID=str(app_id)):
179+
sync_installation_repositories(event, None)
180+
181+
installation = Installation.objects.get(installation_id=installation_id)
182+
183+
assert installation.data == data["installation"]
184+
assert Repository.objects.filter(
185+
repository_id=data["repositories_added"][0]["id"], installation=installation
186+
).exists()

tests/test_models.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,117 @@ def test_get_from_event(self, installation, create_event):
234234

235235
assert result == installation
236236

237+
@pytest.mark.asyncio
238+
async def test_aget_or_create_from_event_existing(
239+
self, ainstallation, create_event
240+
):
241+
event = create_event(
242+
"installation_repositories",
243+
installation={"id": ainstallation.installation_id, "app_id": seq.next()},
244+
)
245+
246+
result = await Installation.objects.aget_or_create_from_event(event)
247+
248+
assert result == ainstallation
249+
250+
@pytest.mark.asyncio
251+
async def test_aget_or_create_from_event_new(
252+
self, create_event, override_app_settings
253+
):
254+
installation_id = seq.next()
255+
app_id = seq.next()
256+
installation_data = {
257+
"id": installation_id,
258+
"app_id": app_id,
259+
"account": {"login": "testorg", "type": "Organization"},
260+
}
261+
event = create_event(
262+
"installation_repositories",
263+
installation=installation_data,
264+
repositories_added=[],
265+
repositories_removed=[],
266+
)
267+
268+
assert not await Installation.objects.filter(
269+
installation_id=installation_id
270+
).aexists()
271+
272+
with override_app_settings(APP_ID=str(app_id)):
273+
result = await Installation.objects.aget_or_create_from_event(event)
274+
275+
assert result is not None
276+
assert result.installation_id == installation_id
277+
assert result.data == installation_data
278+
279+
@pytest.mark.asyncio
280+
async def test_aget_or_create_from_event_wrong_app_id(
281+
self, create_event, override_app_settings
282+
):
283+
installation_data = {
284+
"id": seq.next(),
285+
"app_id": seq.next(),
286+
}
287+
event = create_event(
288+
"installation_repositories",
289+
installation=installation_data,
290+
)
291+
292+
with override_app_settings(APP_ID="999999"):
293+
result = await Installation.objects.aget_or_create_from_event(event)
294+
295+
assert result is None
296+
297+
def test_get_or_create_from_event_existing(self, installation, create_event):
298+
event = create_event(
299+
"installation_repositories",
300+
installation={"id": installation.installation_id, "app_id": seq.next()},
301+
)
302+
303+
result = Installation.objects.get_or_create_from_event(event)
304+
305+
assert result == installation
306+
307+
def test_get_or_create_from_event_new(self, create_event, override_app_settings):
308+
installation_id = seq.next()
309+
app_id = seq.next()
310+
installation_data = {
311+
"id": installation_id,
312+
"app_id": app_id,
313+
"account": {"login": "testorg", "type": "Organization"},
314+
}
315+
event = create_event(
316+
"installation_repositories",
317+
installation=installation_data,
318+
repositories_added=[],
319+
repositories_removed=[],
320+
)
321+
322+
assert not Installation.objects.filter(installation_id=installation_id).exists()
323+
324+
with override_app_settings(APP_ID=str(app_id)):
325+
result = Installation.objects.get_or_create_from_event(event)
326+
327+
assert result is not None
328+
assert result.installation_id == installation_id
329+
assert result.data == installation_data
330+
331+
def test_get_or_create_from_event_wrong_app_id(
332+
self, create_event, override_app_settings
333+
):
334+
installation_data = {
335+
"id": seq.next(),
336+
"app_id": seq.next(),
337+
}
338+
event = create_event(
339+
"installation_repositories",
340+
installation=installation_data,
341+
)
342+
343+
with override_app_settings(APP_ID="999999"):
344+
result = Installation.objects.get_or_create_from_event(event)
345+
346+
assert result is None
347+
237348

238349
class TestInstallationStatus:
239350
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)