Skip to content

Commit cb864c6

Browse files
add sync handlers for internal webhook events (#34)
* add sync handlers for internal webhook events * allow event method to take sync or async callbacks * add tests for sync events * add switch for if async or sync webhook view is used * remove asyncio mark * add seq number to full name field * add tests for ready method * oops, forgot to remove this * update changelog and readme * update readme
1 parent 2f0fd9b commit cb864c6

File tree

11 files changed

+352
-9
lines changed

11 files changed

+352
-9
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
2222

2323
- Added `SyncWebhookView`, a synchronous counterpart to `AsyncWebhookView` for Django applications running under WSGI. Works with `SyncGitHubAPI` and synchronous event handlers to provide a fully synchronous workflow for processing GitHub webhooks.
2424
- Added system check to prevent mixing async and sync webhook views in the same project (`django_github_app.E001`).
25+
- Added sync versions of internal event handlers for installation and repository webhooks. The library automatically selects async or sync handlers based on the webhook view type configured in your URLs.
2526

2627
### Changed
2728

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,22 @@ issues = await repo.aget_issues(params={"state": "open"})
438438
- `owner`: Repository owner from full name
439439
- `repo`: Repository name from full name
440440
441+
### Built-in Event Handlers
442+
443+
The library includes event handlers for managing GitHub App installations and repositories. These handlers automatically update your `Installation` and `Repository` models in response to GitHub webhooks:
444+
445+
- Installation events:
446+
- `installation.created`: Creates new `Installation` record
447+
- `installation.deleted`: Removes `Installation` record
448+
- `installation.suspend`/`installation.unsuspend`: Updates `Installation` status
449+
- `installation.new_permissions_accepted`: Updates `Installation` data
450+
- `installation_repositories`: Creates and/or removes the `Repository` models associated with `Installation`
451+
452+
- Repository events:
453+
- `repository.renamed`: Updates repository details
454+
455+
The library automatically detects whether you're using `AsyncWebhookView` or `SyncWebhookView` in your URL configuration and loads the corresponding async or sync versions of these handlers.
456+
441457
### System Checks
442458
443459
The library includes Django system checks to validate your webhook configuration:

src/django_github_app/apps.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,22 @@ class GitHubAppConfig(AppConfig):
1313
@override
1414
def ready(self):
1515
from . import checks # noqa: F401
16-
from .events import ahandlers # noqa: F401
16+
17+
try:
18+
webhook_type = self.detect_webhook_type()
19+
if webhook_type == "async":
20+
from .events import ahandlers # noqa: F401
21+
elif webhook_type == "sync":
22+
from .events import handlers # noqa: F401
23+
except (ImportError, ValueError):
24+
pass
25+
26+
@classmethod
27+
def detect_webhook_type(cls):
28+
from .views import AsyncWebhookView
29+
from .views import get_webhook_views
30+
31+
views = get_webhook_views()
32+
if views:
33+
return "async" if issubclass(views[0], AsyncWebhookView) else "sync"
34+
return None
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from __future__ import annotations
2+
3+
from . import installation
4+
from . import repository
5+
6+
__all__ = [
7+
"installation",
8+
"repository",
9+
]
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from __future__ import annotations
2+
3+
from gidgethub import sansio
4+
from gidgethub.abc import GitHubAPI
5+
6+
from django_github_app.models import Installation
7+
from django_github_app.models import InstallationStatus
8+
from django_github_app.models import Repository
9+
from django_github_app.routing import GitHubRouter
10+
11+
gh = GitHubRouter()
12+
13+
14+
@gh.event("installation", action="created")
15+
def create_installation(event: sansio.Event, gh: GitHubAPI, *args, **kwargs):
16+
Installation.objects.create_from_event(event)
17+
18+
19+
@gh.event("installation", action="deleted")
20+
def delete_installation(event: sansio.Event, gh: GitHubAPI, *args, **kwargs):
21+
installation = Installation.objects.get_from_event(event)
22+
installation.delete()
23+
24+
25+
@gh.event("installation", action="suspend")
26+
@gh.event("installation", action="unsuspend")
27+
def toggle_installation_status(event: sansio.Event, gh: GitHubAPI, *args, **kwargs):
28+
installation = Installation.objects.get_from_event(event)
29+
installation.status = InstallationStatus.from_event(event)
30+
installation.save()
31+
32+
33+
@gh.event("installation", action="new_permissions_accepted")
34+
def sync_installation_data(event: sansio.Event, gh: GitHubAPI, *args, **kwargs):
35+
installation = Installation.objects.get_from_event(event)
36+
installation.data = event.data["installation"]
37+
installation.save()
38+
39+
40+
@gh.event("installation_repositories")
41+
def sync_installation_repositories(event: sansio.Event, gh: GitHubAPI, *args, **kwargs):
42+
removed = [repo["id"] for repo in event.data["repositories_removed"]]
43+
added = [
44+
Repository(
45+
installation=Installation.objects.get_from_event(event),
46+
repository_id=repo["id"],
47+
repository_node_id=repo["node_id"],
48+
full_name=repo["full_name"],
49+
)
50+
for repo in event.data["repositories_added"]
51+
if not Repository.objects.filter(repository_id=repo["id"]).exists()
52+
]
53+
54+
Repository.objects.filter(repository_id__in=removed).delete()
55+
Repository.objects.bulk_create(added)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from __future__ import annotations
2+
3+
from gidgethub import sansio
4+
from gidgethub.abc import GitHubAPI
5+
6+
from django_github_app.models import Repository
7+
from django_github_app.routing import GitHubRouter
8+
9+
gh = GitHubRouter()
10+
11+
12+
@gh.event("repository", action="renamed")
13+
def rename_repository(event: sansio.Event, gh: GitHubAPI, *args, **kwargs):
14+
repo = Repository.objects.get_from_event(event)
15+
repo.full_name = event.data["repository"]["full_name"]
16+
repo.save()

src/django_github_app/routing.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
from __future__ import annotations
22

3+
from collections.abc import Awaitable
34
from collections.abc import Callable
45
from typing import Any
6+
from typing import TypeVar
57

68
from django.utils.functional import classproperty
79
from gidgethub import sansio
8-
from gidgethub.routing import AsyncCallback
910
from gidgethub.routing import Router as GidgetHubRouter
1011

1112
from ._typing import override
1213

14+
AsyncCallback = Callable[..., Awaitable[None]]
15+
SyncCallback = Callable[..., None]
16+
17+
CB = TypeVar("CB", AsyncCallback, SyncCallback)
18+
1319

1420
class GitHubRouter(GidgetHubRouter):
1521
_routers: list[GidgetHubRouter] = []
@@ -22,11 +28,9 @@ def __init__(self, *args) -> None:
2228
def routers(cls):
2329
return list(cls._routers)
2430

25-
def event(
26-
self, event_type: str, **kwargs: Any
27-
) -> Callable[[AsyncCallback], AsyncCallback]:
28-
def decorator(func: AsyncCallback) -> AsyncCallback:
29-
self.add(func, event_type, **kwargs)
31+
def event(self, event_type: str, **kwargs: Any) -> Callable[[CB], CB]:
32+
def decorator(func: CB) -> CB:
33+
self.add(func, event_type, **kwargs) # type: ignore[arg-type]
3034
return func
3135

3236
return decorator

tests/events/test_arepository.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from django_github_app.events.arepository import arename_repository
99
from django_github_app.models import Repository
10+
from tests.utils import seq
1011

1112
pytestmark = [pytest.mark.asyncio, pytest.mark.django_db]
1213

@@ -17,13 +18,13 @@ async def test_arename_repository(ainstallation, repository_id):
1718
"django_github_app.Repository",
1819
installation=installation,
1920
repository_id=repository_id,
20-
full_name="owner/old_name",
21+
full_name=f"owner/old_name_{seq.next()}",
2122
)
2223

2324
data = {
2425
"repository": {
2526
"id": repository.repository_id,
26-
"full_name": "owner/new_name",
27+
"full_name": f"owner/new_name_{seq.next()}",
2728
},
2829
}
2930
event = sansio.Event(data, event="repository", delivery_id="1234")

tests/events/test_installation.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
from gidgethub.abc import sansio
5+
from model_bakery import baker
6+
7+
from django_github_app.events.installation import create_installation
8+
from django_github_app.events.installation import delete_installation
9+
from django_github_app.events.installation import sync_installation_data
10+
from django_github_app.events.installation import sync_installation_repositories
11+
from django_github_app.events.installation import toggle_installation_status
12+
from django_github_app.models import Installation
13+
from django_github_app.models import InstallationStatus
14+
from django_github_app.models import Repository
15+
from tests.utils import seq
16+
17+
pytestmark = [pytest.mark.django_db]
18+
19+
20+
def test_create_installation(installation_id, repository_id, override_app_settings):
21+
data = {
22+
"installation": {
23+
"id": installation_id,
24+
"app_id": seq.next(),
25+
},
26+
"repositories": [
27+
{"id": repository_id, "node_id": "node1234", "full_name": "owner/repo"}
28+
],
29+
}
30+
event = sansio.Event(data, event="installation", delivery_id="1234")
31+
32+
with override_app_settings(APP_ID=str(data["installation"]["app_id"])):
33+
create_installation(event, None)
34+
35+
installation = Installation.objects.get(installation_id=data["installation"]["id"])
36+
37+
assert installation.data == data["installation"]
38+
39+
40+
def test_delete_installation(installation):
41+
data = {
42+
"installation": {
43+
"id": installation.installation_id,
44+
}
45+
}
46+
event = sansio.Event(data, event="installation", delivery_id="1234")
47+
48+
delete_installation(event, None)
49+
50+
assert not Installation.objects.filter(
51+
installation_id=data["installation"]["id"]
52+
).exists()
53+
54+
55+
@pytest.mark.parametrize(
56+
"status,action,expected",
57+
[
58+
(InstallationStatus.ACTIVE, "suspend", InstallationStatus.INACTIVE),
59+
(InstallationStatus.INACTIVE, "unsuspend", InstallationStatus.ACTIVE),
60+
],
61+
)
62+
def test_toggle_installation_status_suspend(status, action, expected, installation):
63+
installation.status = status
64+
installation.save()
65+
66+
data = {
67+
"action": action,
68+
"installation": {
69+
"id": installation.installation_id,
70+
},
71+
}
72+
event = sansio.Event(data, event="installation", delivery_id="1234")
73+
74+
assert installation.status != expected
75+
76+
toggle_installation_status(event, None)
77+
78+
installation.refresh_from_db()
79+
assert installation.status == expected
80+
81+
82+
def test_sync_installation_data(installation):
83+
data = {
84+
"installation": {
85+
"id": installation.installation_id,
86+
},
87+
}
88+
event = sansio.Event(data, event="installation", delivery_id="1234")
89+
90+
assert installation.data != data
91+
92+
sync_installation_data(event, None)
93+
94+
installation.refresh_from_db()
95+
assert installation.data == data["installation"]
96+
97+
98+
def test_sync_installation_repositories(installation):
99+
existing_repo = baker.make(
100+
"django_github_app.Repository",
101+
installation=installation,
102+
repository_id=seq.next(),
103+
)
104+
105+
data = {
106+
"installation": {
107+
"id": installation.installation_id,
108+
},
109+
"repositories_removed": [
110+
{
111+
"id": existing_repo.repository_id,
112+
},
113+
],
114+
"repositories_added": [
115+
{
116+
"id": seq.next(),
117+
"node_id": "repo1234",
118+
"full_name": "owner/repo",
119+
}
120+
],
121+
}
122+
event = sansio.Event(data, event="installation", delivery_id="1234")
123+
124+
assert Repository.objects.filter(
125+
repository_id=data["repositories_removed"][0]["id"]
126+
).exists()
127+
assert not Repository.objects.filter(
128+
repository_id=data["repositories_added"][0]["id"]
129+
).exists()
130+
131+
sync_installation_repositories(event, None)
132+
133+
assert not Repository.objects.filter(
134+
repository_id=data["repositories_removed"][0]["id"]
135+
).exists()
136+
assert Repository.objects.filter(
137+
repository_id=data["repositories_added"][0]["id"]
138+
).exists()

tests/events/test_repository.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
from gidgethub import sansio
5+
from model_bakery import baker
6+
7+
from django_github_app.events.repository import rename_repository
8+
from django_github_app.models import Repository
9+
from tests.utils import seq
10+
11+
pytestmark = [pytest.mark.django_db]
12+
13+
14+
def test_rename_repository(installation, repository_id):
15+
repository = baker.make(
16+
"django_github_app.Repository",
17+
installation=installation,
18+
repository_id=repository_id,
19+
full_name=f"owner/old_name_{seq.next()}",
20+
)
21+
22+
data = {
23+
"repository": {
24+
"id": repository.repository_id,
25+
"full_name": f"owner/new_name_{seq.next()}",
26+
},
27+
}
28+
event = sansio.Event(data, event="repository", delivery_id="1234")
29+
30+
assert not Repository.objects.filter(
31+
full_name=data["repository"]["full_name"]
32+
).exists()
33+
34+
rename_repository(event, None)
35+
36+
assert Repository.objects.filter(full_name=data["repository"]["full_name"]).exists()

0 commit comments

Comments
 (0)