Skip to content

Commit ea14521

Browse files
committed
OAuth: sync collaborators of repositories with projects only on full syncs
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 renames 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 80962a8 commit ea14521

4 files changed

Lines changed: 85 additions & 7 deletions

File tree

readthedocs/oauth/services/githubapp.py

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

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

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

@@ -266,12 +286,13 @@ def update_or_create_repositories(self, repository_ids: list[int]):
266286
self.installation.delete_repositories(repositories_to_delete)
267287

268288
def _create_or_update_repository_from_gh(
269-
self, gh_repo: GHRepository
289+
self, gh_repo: GHRepository, *, resync_collaborators: bool = True
270290
) -> RemoteRepository | None:
271291
"""
272292
Create or update a remote repository from a GitHub repository object.
273293
274-
We also sync the collaborators of the repository with the database,
294+
We also sync the collaborators of the repository with the database
295+
(unless ``resync_collaborators`` is ``False``),
275296
and create or update the organization of the repository.
276297
"""
277298
target_id = self.installation.target_id
@@ -324,7 +345,8 @@ def _create_or_update_repository_from_gh(
324345
remote_repo.organization = self.update_or_create_organization(gh_repo.owner.login)
325346

326347
remote_repo.save()
327-
self._resync_collaborators(gh_repo, remote_repo)
348+
if resync_collaborators:
349+
self._resync_collaborators(gh_repo, remote_repo)
328350
return remote_repo
329351

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

readthedocs/oauth/tasks.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -768,8 +768,9 @@ def _handle_organization_event(self):
768768
# But I wasn't able to trigger neither of those events when renaming an organization.
769769
# Maybe a bug?
770770
# If the organization is renamed, we need to sync the repositories, so they use the new name.
771+
# Renaming doesn't change permissions, so we don't need to sync all collaborators.
771772
if action == "renamed":
772-
installation.service.sync()
773+
installation.service.sync(sync_all_collaborators=False)
773774
return
774775

775776
if action == "deleted":

readthedocs/oauth/tests/test_githubapp_webhook.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1010,7 +1010,7 @@ def test_organization_renamed(self, sync):
10101010
}
10111011
r = self.post_webhook("organization", payload)
10121012
assert r.status_code == 200
1013-
sync.assert_called_once()
1013+
sync.assert_called_once_with(sync_all_collaborators=False)
10141014

10151015
def test_organization_deleted(self):
10161016
organization_id = 1234

readthedocs/rtd_tests/tests/test_oauth.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,61 @@ def test_sync(self, request):
740740
assert relation.account == self.account
741741
assert relation.admin
742742

743+
@requests_mock.Mocker(kw="request")
744+
def test_sync_collaborators_from_repositories_with_projects_only(self, request):
745+
# ``self.remote_repository`` is linked to a project, ``repo2`` is not.
746+
repo2 = get(
747+
RemoteRepository,
748+
remote_id="7777",
749+
name="repo2",
750+
full_name="user/repo2",
751+
vcs_provider=GITHUB_APP,
752+
github_app_installation=self.installation,
753+
)
754+
get(
755+
RemoteRepositoryRelation,
756+
remote_repository=repo2,
757+
user=self.user,
758+
account=self.account,
759+
admin=True,
760+
)
761+
request.get(
762+
f"{self.api_url}/app/installations/1111",
763+
json=self._get_installation_json(id=1111),
764+
)
765+
request.post(
766+
f"{self.api_url}/app/installations/1111/access_tokens",
767+
json=self._get_access_token_json(),
768+
)
769+
request.get(
770+
f"{self.api_url}/installation/repositories",
771+
json={
772+
"repositories": [
773+
self._get_repository_json(
774+
full_name="user/repo", id=int(self.remote_repository.remote_id)
775+
),
776+
self._get_repository_json(
777+
full_name="user/repo2", id=7777, description="New description"
778+
),
779+
]
780+
},
781+
)
782+
# Only the repository linked to a project has its collaborators listed,
783+
# requesting the collaborators of user/repo2 would fail the test (not mocked).
784+
request.get(
785+
f"{self.api_url}/repos/user/repo/collaborators",
786+
json=[self._get_collaborator_json()],
787+
)
788+
789+
service = self.installation.service
790+
service.sync(sync_all_collaborators=False)
791+
792+
# The metadata of the repository without projects is still updated,
793+
# and its existing relations are kept.
794+
repo2.refresh_from_db()
795+
assert repo2.description == "New description"
796+
assert repo2.remote_repository_relations.count() == 1
797+
743798
@requests_mock.Mocker(kw="request")
744799
def test_sync_delete_remote_repositories(self, request):
745800
assert self.installation.repositories.count() == 1

0 commit comments

Comments
 (0)