Skip to content

Commit 11fbf97

Browse files
committed
OAuth: sync collaborators of repositories with projects only on org events
Listing collaborators costs at least one API request per repository, which is the dominant cost of a full installation sync, and makes large installations exceed GitHub's per-installation rate limit. Syncs triggered by organization events now list collaborators only for repositories linked to a project, which is what SSO access and maintainer listings need fresh. Collaborators of other repositories only feed the list of repositories available to import, and keep being refreshed when each user signs in or manually re-syncs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjkPjH3EDEQmtywJvDVau
1 parent 66fb41f commit 11fbf97

4 files changed

Lines changed: 94 additions & 14 deletions

File tree

readthedocs/oauth/services/githubapp.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ def sync_user_access(cls, user):
173173
if has_error:
174174
raise SyncServiceError()
175175

176-
def sync(self):
176+
def sync(self, *, sync_all_collaborators=True):
177177
"""
178178
Sync all repositories and organizations that are accessible to the installation.
179179
@@ -182,6 +182,13 @@ def sync(self):
182182
183183
If a remote organization doesn't have any repositories after removing the repositories,
184184
we remove the organization from the database.
185+
186+
:param sync_all_collaborators: If ``False``, collaborators are synced only for
187+
repositories that are linked to a project. Listing collaborators costs at least
188+
one API request per repository, which makes a full sync of a large installation
189+
exceed the GitHub API rate limit. Collaborators of repositories without a project
190+
are only used to build the list of repositories available to import, which is
191+
refreshed when each user signs in or manually re-syncs their repositories.
185192
"""
186193
try:
187194
app_installation = self.get_app_installation()
@@ -207,9 +214,22 @@ def sync(self):
207214
self.installation.delete()
208215
raise SyncServiceError()
209216

217+
repos_with_projects = set()
218+
if not sync_all_collaborators:
219+
repos_with_projects = set(
220+
self.installation.repositories.filter(projects__isnull=False).values_list(
221+
"remote_id", flat=True
222+
)
223+
)
224+
210225
remote_repositories = []
211226
for gh_repo in app_installation.get_repos():
212-
remote_repo = self._create_or_update_repository_from_gh(gh_repo)
227+
remote_repo = self._create_or_update_repository_from_gh(
228+
gh_repo,
229+
resync_collaborators=(
230+
sync_all_collaborators or str(gh_repo.id) in repos_with_projects
231+
),
232+
)
213233
if remote_repo:
214234
remote_repositories.append(remote_repo)
215235

@@ -265,12 +285,13 @@ def update_or_create_repositories(self, repository_ids: list[int]):
265285
self.installation.delete_repositories(repositories_to_delete)
266286

267287
def _create_or_update_repository_from_gh(
268-
self, gh_repo: GHRepository
288+
self, gh_repo: GHRepository, *, resync_collaborators: bool = True
269289
) -> RemoteRepository | None:
270290
"""
271291
Create or update a remote repository from a GitHub repository object.
272292
273-
We also sync the collaborators of the repository with the database,
293+
We also sync the collaborators of the repository with the database
294+
(unless ``resync_collaborators`` is ``False``),
274295
and create or update the organization of the repository.
275296
"""
276297
target_id = self.installation.target_id
@@ -323,7 +344,8 @@ def _create_or_update_repository_from_gh(
323344
remote_repo.organization = self.update_or_create_organization(gh_repo.owner.login)
324345

325346
remote_repo.save()
326-
self._resync_collaborators(gh_repo, remote_repo)
347+
if resync_collaborators:
348+
self._resync_collaborators(gh_repo, remote_repo)
327349
return remote_repo
328350

329351
# NOTE: normally, this should cache only one organization at a time, but just in case...

readthedocs/oauth/tasks.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -756,32 +756,35 @@ def _handle_organization_event(self):
756756
if created:
757757
return
758758

759-
# We need to do a full sync of the repositories if members were added or removed,
759+
# We need to re-sync the repositories if members were added or removed,
760760
# this is since we don't know to which repositories the members have access.
761761
# GH doesn't send a member event for this.
762762
if action in ("member_added", "member_removed"):
763763
if action == "member_removed":
764764
# We know which member was removed, so we can revoke their access
765-
# without querying the GH API. The sync below covers members whose
766-
# access changed in other ways, but it may be skipped completely
765+
# without querying the GH API. The sync below only covers
766+
# repositories linked to a project, and may be skipped completely
767767
# if the installation was synced recently.
768768
self._remove_member_repository_relations(installation)
769769

770770
# Bulk membership changes send one event per member, and each sync costs
771771
# at least one API request per repository in the installation, so we
772772
# debounce syncs to avoid exceeding the GitHub API rate limit.
773+
# Collaborators of repositories without a project are refreshed when
774+
# each user signs in or manually re-syncs their repositories.
773775
if self._installation_sync_recently_triggered(installation):
774776
log.info("Installation was synced recently, skipping sync.")
775777
return
776-
installation.service.sync()
778+
installation.service.sync(sync_all_collaborators=False)
777779
return
778780

779781
# NOTE: installation_target should handle this instead?
780782
# But I wasn't able to trigger neither of those events when renaming an organization.
781783
# Maybe a bug?
782784
# If the organization is renamed, we need to sync the repositories, so they use the new name.
785+
# Renaming doesn't change permissions, so we don't need to sync all collaborators.
783786
if action == "renamed":
784-
installation.service.sync()
787+
installation.service.sync(sync_all_collaborators=False)
785788
return
786789

787790
if action == "deleted":

readthedocs/oauth/tests/test_githubapp_webhook.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -917,7 +917,7 @@ def test_organization_member_added(self, sync):
917917
}
918918
r = self.post_webhook("organization", payload)
919919
assert r.status_code == 200
920-
sync.assert_called_once()
920+
sync.assert_called_once_with(sync_all_collaborators=False)
921921

922922
@mock.patch.object(GitHubAppService, "sync")
923923
def test_organization_member_events_are_debounced(self, sync):
@@ -935,7 +935,7 @@ def test_organization_member_events_are_debounced(self, sync):
935935
assert r.status_code == 200
936936
# Bulk membership changes send one event per member,
937937
# only the first one triggers a sync.
938-
sync.assert_called_once()
938+
sync.assert_called_once_with(sync_all_collaborators=False)
939939

940940
@mock.patch.object(GitHubAppService, "sync")
941941
def test_organization_member_removed(self, sync):
@@ -974,7 +974,7 @@ def test_organization_member_removed(self, sync):
974974
}
975975
r = self.post_webhook("organization", payload)
976976
assert r.status_code == 200
977-
sync.assert_called_once()
977+
sync.assert_called_once_with(sync_all_collaborators=False)
978978
# The relations from the removed member are deleted without querying the API,
979979
# relations from other users are kept.
980980
assert not member.remote_repository_relations.exists()
@@ -992,7 +992,7 @@ def test_organization_renamed(self, sync):
992992
}
993993
r = self.post_webhook("organization", payload)
994994
assert r.status_code == 200
995-
sync.assert_called_once()
995+
sync.assert_called_once_with(sync_all_collaborators=False)
996996

997997
def test_organization_deleted(self):
998998
organization_id = 1234

readthedocs/rtd_tests/tests/test_oauth.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,61 @@ def test_sync(self, request):
674674
assert relation.account == self.account
675675
assert relation.admin
676676

677+
@requests_mock.Mocker(kw="request")
678+
def test_sync_collaborators_from_repositories_with_projects_only(self, request):
679+
# ``self.remote_repository`` is linked to a project, ``repo2`` is not.
680+
repo2 = get(
681+
RemoteRepository,
682+
remote_id="7777",
683+
name="repo2",
684+
full_name="user/repo2",
685+
vcs_provider=GITHUB_APP,
686+
github_app_installation=self.installation,
687+
)
688+
get(
689+
RemoteRepositoryRelation,
690+
remote_repository=repo2,
691+
user=self.user,
692+
account=self.account,
693+
admin=True,
694+
)
695+
request.get(
696+
f"{self.api_url}/app/installations/1111",
697+
json=self._get_installation_json(id=1111),
698+
)
699+
request.post(
700+
f"{self.api_url}/app/installations/1111/access_tokens",
701+
json=self._get_access_token_json(),
702+
)
703+
request.get(
704+
f"{self.api_url}/installation/repositories",
705+
json={
706+
"repositories": [
707+
self._get_repository_json(
708+
full_name="user/repo", id=int(self.remote_repository.remote_id)
709+
),
710+
self._get_repository_json(
711+
full_name="user/repo2", id=7777, description="New description"
712+
),
713+
]
714+
},
715+
)
716+
# Only the repository linked to a project has its collaborators listed,
717+
# requesting the collaborators of user/repo2 would fail the test (not mocked).
718+
request.get(
719+
f"{self.api_url}/repos/user/repo/collaborators",
720+
json=[self._get_collaborator_json()],
721+
)
722+
723+
service = self.installation.service
724+
service.sync(sync_all_collaborators=False)
725+
726+
# The metadata of the repository without projects is still updated,
727+
# and its existing relations are kept.
728+
repo2.refresh_from_db()
729+
assert repo2.description == "New description"
730+
assert repo2.remote_repository_relations.count() == 1
731+
677732
@requests_mock.Mocker(kw="request")
678733
def test_sync_delete_remote_repositories(self, request):
679734
assert self.installation.repositories.count() == 1

0 commit comments

Comments
 (0)