Skip to content

Commit 60fb149

Browse files
committed
OAuth: handle organization member events without a sync per event
Bulk membership changes send one organization event per member, and each event triggered a full sync of the installation, costing at least one API request per repository. Large installations exceed GitHub's per-installation rate limit this way. Removed members are now revoked directly from the database using the event payload, without querying the API. Syncs triggered by member events are debounced per installation, so a bulk change triggers one sync instead of one per member. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjkPjH3EDEQmtywJvDVau
1 parent 8a26911 commit 60fb149

2 files changed

Lines changed: 99 additions & 0 deletions

File tree

readthedocs/oauth/tasks.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55

66
import structlog
77
from django.contrib.auth.models import User
8+
from django.core.cache import cache
89
from django.db.models.functions import ExtractIsoWeekDay
910
from django.urls import reverse
1011
from django.utils import timezone
1112

13+
from readthedocs.allauth.providers.githubapp.provider import GitHubAppProvider
1214
from readthedocs.api.v2.views.integrations import ExternalVersionData
1315
from readthedocs.builds.constants import EXTERNAL
1416
from readthedocs.builds.utils import memcache_lock
@@ -25,6 +27,7 @@
2527
from readthedocs.oauth.constants import GITHUB_APP
2628
from readthedocs.oauth.models import GitHubAppInstallation
2729
from readthedocs.oauth.models import RemoteRepository
30+
from readthedocs.oauth.models import RemoteRepositoryRelation
2831
from readthedocs.oauth.notifications import MESSAGE_OAUTH_SYNCING_REMOTE_REPOSITORIES
2932
from readthedocs.oauth.notifications import MESSAGE_OAUTH_UNSUPPORTED_GIT_PROVIDER
3033
from readthedocs.oauth.notifications import MESSAGE_OAUTH_WEBHOOK_INTEGRATION_MISMATCH
@@ -757,6 +760,12 @@ def _handle_organization_event(self):
757760
# this is since we don't know to which repositories the members have access.
758761
# GH doesn't send a member event for this.
759762
if action in ("member_added", "member_removed"):
763+
if action == "member_removed":
764+
self._remove_member_repository_relations(installation)
765+
766+
if self._installation_sync_recently_triggered(installation):
767+
log.info("Installation was synced recently, skipping sync.")
768+
return
760769
installation.service.sync()
761770
return
762771

@@ -781,6 +790,43 @@ def _handle_organization_event(self):
781790
# - member_invited: We don't do anything with invited members.
782791
return
783792

793+
def _remove_member_repository_relations(self, installation):
794+
"""
795+
Remove all repository relations from the member the event was triggered for.
796+
797+
A member removed from the organization lost access to all its repositories,
798+
so we can revoke their access without querying the GH API.
799+
"""
800+
member_id = self.data.get("membership", {}).get("user", {}).get("id")
801+
if not member_id:
802+
log.info("Organization event doesn't include the affected member.")
803+
return
804+
count, _ = RemoteRepositoryRelation.objects.filter(
805+
account__provider=GitHubAppProvider.id,
806+
account__uid=str(member_id),
807+
remote_repository__github_app_installation=installation,
808+
).delete()
809+
log.info(
810+
"Removed repository relations from member removed from the organization.",
811+
member_id=member_id,
812+
count=count,
813+
)
814+
815+
def _installation_sync_recently_triggered(self, installation) -> bool:
816+
"""
817+
Check if a sync was triggered recently for the installation, and mark it as synced.
818+
819+
Bulk membership changes send one organization event per member, and each sync
820+
costs at least one API request per repository in the installation, so we
821+
debounce syncs to avoid exceeding the GitHub API rate limit.
822+
823+
``cache.add`` is atomic, so concurrent events trigger only one sync.
824+
The key isn't removed if the sync fails, otherwise a burst of events would
825+
keep triggering expensive syncs while we are rate limited.
826+
"""
827+
cache_key = f"githubapp-installation-sync:{installation.installation_id}"
828+
return not cache.add(cache_key, True, timeout=60 * 5)
829+
784830
def _handle_member_event(self):
785831
"""
786832
Handle the member event.

readthedocs/oauth/tests/test_githubapp_webhook.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from allauth.socialaccount.models import SocialAccount
88
from django.conf import settings
99
from django.contrib.auth.models import User
10+
from django.core.cache import cache
1011
from django.test import TestCase
1112
from django.urls import reverse
1213
from django_dynamic_fixture import get
@@ -32,6 +33,7 @@
3233
GitHubAppInstallation,
3334
RemoteOrganization,
3435
RemoteRepository,
36+
RemoteRepositoryRelation,
3537
)
3638
from readthedocs.oauth.services import GitHubAppService
3739
from readthedocs.projects.models import Project
@@ -81,6 +83,8 @@ def setUp(self):
8183
self.project.remote_repository = self.remote_repository
8284
self.project.save()
8385
self.url = reverse("github_app_webhook")
86+
# Syncs triggered by organization events are debounced with the cache.
87+
cache.clear()
8488

8589
def post_webhook(self, event, payload):
8690
headers = {
@@ -915,19 +919,66 @@ def test_organization_member_added(self, sync):
915919
assert r.status_code == 200
916920
sync.assert_called_once()
917921

922+
@mock.patch.object(GitHubAppService, "sync")
923+
def test_organization_member_events_are_debounced(self, sync):
924+
payload = {
925+
"installation": {
926+
"id": self.installation.installation_id,
927+
"target_id": self.installation.target_id,
928+
"target_type": self.installation.target_type,
929+
},
930+
"action": "member_added",
931+
}
932+
r = self.post_webhook("organization", payload)
933+
assert r.status_code == 200
934+
r = self.post_webhook("organization", payload)
935+
assert r.status_code == 200
936+
# Bulk membership changes send one event per member,
937+
# only the first one triggers a sync.
938+
sync.assert_called_once()
939+
918940
@mock.patch.object(GitHubAppService, "sync")
919941
def test_organization_member_removed(self, sync):
942+
member = get(User)
943+
member_account = get(
944+
SocialAccount,
945+
user=member,
946+
provider=GitHubAppProvider.id,
947+
uid="9999",
948+
)
949+
get(
950+
RemoteRepositoryRelation,
951+
remote_repository=self.remote_repository,
952+
user=member,
953+
account=member_account,
954+
)
955+
relation = get(
956+
RemoteRepositoryRelation,
957+
remote_repository=self.remote_repository,
958+
user=self.user,
959+
account=self.socialaccount,
960+
)
920961
payload = {
921962
"installation": {
922963
"id": self.installation.installation_id,
923964
"target_id": self.installation.target_id,
924965
"target_type": self.installation.target_type,
925966
},
926967
"action": "member_removed",
968+
"membership": {
969+
"user": {
970+
"login": "member",
971+
"id": 9999,
972+
},
973+
},
927974
}
928975
r = self.post_webhook("organization", payload)
929976
assert r.status_code == 200
930977
sync.assert_called_once()
978+
# The relations from the removed member are deleted without querying the API,
979+
# relations from other users are kept.
980+
assert not member.remote_repository_relations.exists()
981+
assert list(self.user.remote_repository_relations.all()) == [relation]
931982

932983
@mock.patch.object(GitHubAppService, "sync")
933984
def test_organization_renamed(self, sync):
@@ -1082,6 +1133,8 @@ def setUp(self):
10821133
self.project.remote_repository = self.remote_repository
10831134
self.project.save()
10841135
self.url = reverse("github_app_webhook")
1136+
# Syncs triggered by organization events are debounced with the cache.
1137+
cache.clear()
10851138

10861139
def post_webhook(self, event, payload):
10871140
headers = {

0 commit comments

Comments
 (0)