Skip to content

Commit 4c7f257

Browse files
committed
Remote repository: Keep in sync when repos are moved to another org/user.
We were doing some checks to avoid removing the remote organization from a remote repo when this is listed from the /users/repos/ endpoint or changing the organization when the project was moved. But, since we are hitting the GitHub API directly, we can always trust the result from there, which includes the type of owner of the repository, we can use that to decide if the repository belongs to an organization or not. Fixes #8715
1 parent eaef4b7 commit 4c7f257

File tree

3 files changed

+191
-84
lines changed

3 files changed

+191
-84
lines changed

readthedocs/oauth/services/github.py

+20-26
Original file line numberDiff line numberDiff line change
@@ -103,34 +103,23 @@ def create_repository(self, fields, privacy=None, organization=None):
103103
remote_id=fields['id'],
104104
vcs_provider=self.vcs_provider_slug
105105
)
106-
remote_repository_relation = repo.get_remote_repository_relation(
107-
self.user, self.account
108-
)
109106

110-
# It's possible that a user has access to a repository from within
111-
# an organization without being member of that organization
112-
# (external contributor). In this case, the repository will be
113-
# listed under the ``/repos`` endpoint but not under ``/orgs``
114-
# endpoint. Then, when calling this method (``create_repository``)
115-
# we will have ``organization=None`` but we don't have to skip the
116-
# creation of the ``RemoteRepositoryRelation``.
117-
if repo.organization and organization and repo.organization != organization:
118-
log.debug(
119-
'Not importing repository because mismatched orgs.',
120-
repository=fields['name'],
121-
)
122-
return None
123-
124-
if any([
125-
# There is an organization associated with this repository:
126-
# attach the organization to the repository
127-
organization is not None,
128-
# There is no organization and the repository belongs to a
129-
# user: removes the organization linked to the repository
130-
not organization and fields['owner']['type'] == 'User',
131-
]):
107+
owner_type = fields["owner"]["type"]
108+
109+
# If there is an organization associated with this repository,
110+
# attach the organization to the repository.
111+
if (
112+
organization
113+
and owner_type == "Organization"
114+
and organization.remote_id == fields["owner"]["id"]
115+
):
132116
repo.organization = organization
133117

118+
# If there is no organization and the repository belongs to a user,
119+
# remove the organization linked to the repository.
120+
if not organization and owner_type == "User":
121+
repo.organization = None
122+
134123
repo.name = fields['name']
135124
repo.full_name = fields['full_name']
136125
repo.description = fields['description']
@@ -151,7 +140,12 @@ def create_repository(self, fields, privacy=None, organization=None):
151140

152141
repo.save()
153142

154-
remote_repository_relation.admin = fields.get('permissions', {}).get('admin', False)
143+
remote_repository_relation = repo.get_remote_repository_relation(
144+
self.user, self.account
145+
)
146+
remote_repository_relation.admin = fields.get("permissions", {}).get(
147+
"admin", False
148+
)
155149
remote_repository_relation.save()
156150

157151
return repo

readthedocs/rtd_tests/tests/test_oauth.py

+167-51
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,7 @@
1313
from readthedocs.integrations.models import GitHubWebhook, GitLabWebhook
1414
from readthedocs.oauth.constants import BITBUCKET, GITHUB, GITLAB
1515
from readthedocs.oauth.models import RemoteOrganization, RemoteRepository
16-
from readthedocs.oauth.services import (
17-
BitbucketService,
18-
GitHubService,
19-
GitLabService,
20-
)
16+
from readthedocs.oauth.services import BitbucketService, GitHubService, GitLabService
2117
from readthedocs.projects import constants
2218
from readthedocs.projects.models import Project
2319

@@ -29,8 +25,8 @@ class GitHubOAuthTests(TestCase):
2925
def setUp(self):
3026
self.client.login(username='eric', password='test')
3127
self.user = User.objects.get(pk=1)
32-
self.project = Project.objects.get(slug='pip')
33-
self.org = RemoteOrganization.objects.create(slug='rtfd')
28+
self.project = Project.objects.get(slug="pip")
29+
self.org = RemoteOrganization.objects.create(slug="rtfd", remote_id=1234)
3430
self.privacy = settings.DEFAULT_PRIVACY_LEVEL
3531
self.service = GitHubService(
3632
user=self.user,
@@ -55,9 +51,7 @@ def setUp(self):
5551
"url": "https://api.github.com/repos/test/Hello-World/hooks/12345678",
5652
}
5753
]
58-
59-
def test_make_project_pass(self):
60-
repo_json = {
54+
self.repo_response_data = {
6155
'name': 'testrepo',
6256
'full_name': 'testuser/testrepo',
6357
'id': '12345678',
@@ -67,9 +61,45 @@ def test_make_project_pass(self):
6761
'ssh_url': 'ssh://[email protected]:testuser/testrepo.git',
6862
'html_url': 'https://github.com/testuser/testrepo',
6963
'clone_url': 'https://github.com/testuser/testrepo.git',
64+
"owner": {
65+
"type": "User",
66+
"id": 1234,
67+
},
7068
}
69+
70+
def test_create_remote_repository(self):
7171
repo = self.service.create_repository(
72-
repo_json, organization=self.org, privacy=self.privacy,
72+
self.repo_response_data,
73+
privacy=self.privacy,
74+
)
75+
self.assertIsInstance(repo, RemoteRepository)
76+
self.assertEqual(repo.name, "testrepo")
77+
self.assertEqual(repo.full_name, "testuser/testrepo")
78+
self.assertEqual(repo.remote_id, "12345678")
79+
self.assertEqual(repo.vcs_provider, GITHUB)
80+
self.assertEqual(repo.description, "Test Repo")
81+
self.assertEqual(
82+
repo.avatar_url,
83+
settings.OAUTH_AVATAR_USER_DEFAULT_URL,
84+
)
85+
self.assertIn(self.user, repo.users.all())
86+
self.assertEqual(repo.organization, None)
87+
self.assertEqual(
88+
repo.clone_url,
89+
"https://github.com/testuser/testrepo.git",
90+
)
91+
self.assertEqual(
92+
repo.ssh_url,
93+
"ssh://[email protected]:testuser/testrepo.git",
94+
)
95+
self.assertEqual(repo.html_url, "https://github.com/testuser/testrepo")
96+
97+
def test_create_remote_repository_with_organization(self):
98+
self.repo_response_data["owner"]["type"] = "Organization"
99+
repo = self.service.create_repository(
100+
self.repo_response_data,
101+
organization=self.org,
102+
privacy=self.privacy,
73103
)
74104
self.assertIsInstance(repo, RemoteRepository)
75105
self.assertEqual(repo.name, 'testrepo')
@@ -91,23 +121,103 @@ def test_make_project_pass(self):
91121
)
92122
self.assertEqual(repo.html_url, 'https://github.com/testuser/testrepo')
93123

94-
def test_make_project_fail(self):
95-
repo_json = {
96-
'name': '',
97-
'full_name': '',
98-
'id': '',
99-
'description': '',
100-
'git_url': '',
101-
'private': True,
102-
'ssh_url': '',
103-
'html_url': '',
104-
'clone_url': '',
105-
}
124+
def test_skip_creation_remote_repository_on_private_repos(self):
125+
self.repo_response_data["private"] = True
106126
github_project = self.service.create_repository(
107-
repo_json, organization=self.org, privacy=self.privacy,
127+
self.repo_response_data,
128+
organization=self.org,
129+
privacy=self.privacy,
108130
)
109131
self.assertIsNone(github_project)
110132

133+
def test_project_was_moved_from_a_personal_account_to_an_organization(self):
134+
github_project = self.service.create_repository(
135+
self.repo_response_data,
136+
privacy=self.privacy,
137+
)
138+
self.assertEqual(github_project.organization, None)
139+
140+
# Project belongs has been moved to an organization.
141+
self.repo_response_data["owner"]["type"] = "Organization"
142+
143+
self.service.create_repository(
144+
self.repo_response_data,
145+
privacy=self.privacy,
146+
)
147+
github_project.refresh_from_db()
148+
self.assertEqual(github_project.organization, None)
149+
150+
self.service.create_repository(
151+
self.repo_response_data,
152+
organization=self.org,
153+
privacy=self.privacy,
154+
)
155+
github_project.refresh_from_db()
156+
self.assertEqual(github_project.organization, self.org)
157+
158+
def test_project_was_moved_from_an_organization_to_a_personal_account(self):
159+
# Project belongs to an organization.
160+
self.repo_response_data["owner"]["type"] = "Organization"
161+
github_project = self.service.create_repository(
162+
self.repo_response_data,
163+
organization=self.org,
164+
privacy=self.privacy,
165+
)
166+
self.assertEqual(github_project.organization, self.org)
167+
168+
# Project belongs has been moved to a personal account.
169+
self.repo_response_data["owner"]["type"] = "User"
170+
171+
# This operation doesn't have any effect.
172+
self.service.create_repository(
173+
self.repo_response_data,
174+
organization=self.org,
175+
privacy=self.privacy,
176+
)
177+
github_project.refresh_from_db()
178+
self.assertEqual(github_project.organization, self.org)
179+
180+
self.service.create_repository(
181+
self.repo_response_data,
182+
privacy=self.privacy,
183+
)
184+
github_project.refresh_from_db()
185+
self.assertEqual(github_project.organization, None)
186+
187+
def test_project_was_moved_to_another_organization(self):
188+
another_remote_organization = RemoteOrganization.objects.create(
189+
slug="another", remote_id=4321
190+
)
191+
192+
# Project belongs to an organization.
193+
self.repo_response_data["owner"]["type"] = "Organization"
194+
github_project = self.service.create_repository(
195+
self.repo_response_data,
196+
organization=self.org,
197+
privacy=self.privacy,
198+
)
199+
self.assertEqual(github_project.organization, self.org)
200+
201+
# Project was moved to another organization.
202+
self.repo_response_data["owner"]["id"] = 4321
203+
204+
self.service.create_repository(
205+
self.repo_response_data,
206+
organization=another_remote_organization,
207+
privacy=self.privacy,
208+
)
209+
github_project.refresh_from_db()
210+
self.assertEqual(github_project.organization, another_remote_organization)
211+
212+
# The remote ID doesn't match, the project isn't moved to the organization.
213+
self.service.create_repository(
214+
self.repo_response_data,
215+
organization=self.org,
216+
privacy=self.privacy,
217+
)
218+
github_project.refresh_from_db()
219+
self.assertEqual(github_project.organization, another_remote_organization)
220+
111221
def test_make_organization(self):
112222
org_json = {
113223
'id': 12345,
@@ -131,20 +241,11 @@ def test_import_with_no_token(self):
131241
self.assertEqual(services, [])
132242

133243
def test_multiple_users_same_repo(self):
134-
repo_json = {
135-
'name': '',
136-
'full_name': 'testrepo/multiple',
137-
'id': '12345678',
138-
'description': '',
139-
'git_url': '',
140-
'private': False,
141-
'ssh_url': '',
142-
'html_url': '',
143-
'clone_url': '',
144-
}
145-
244+
self.repo_response_data["owner"]["type"] = "Organization"
146245
github_project = self.service.create_repository(
147-
repo_json, organization=self.org, privacy=self.privacy,
246+
self.repo_response_data,
247+
organization=self.org,
248+
privacy=self.privacy,
148249
)
149250

150251
user2 = User.objects.get(pk=2)
@@ -153,31 +254,43 @@ def test_multiple_users_same_repo(self):
153254
account=get(SocialAccount, user=self.user)
154255
)
155256
github_project_2 = service.create_repository(
156-
repo_json, organization=self.org, privacy=self.privacy,
257+
self.repo_response_data,
258+
organization=self.org,
259+
privacy=self.privacy,
157260
)
158261
self.assertIsInstance(github_project, RemoteRepository)
159262
self.assertIsInstance(github_project_2, RemoteRepository)
160263
self.assertEqual(github_project_2, github_project)
161264

162265
github_project_3 = self.service.create_repository(
163-
repo_json, organization=self.org, privacy=self.privacy,
266+
self.repo_response_data,
267+
organization=self.org,
268+
privacy=self.privacy,
164269
)
165270
github_project_4 = service.create_repository(
166-
repo_json, organization=self.org, privacy=self.privacy,
271+
self.repo_response_data,
272+
organization=self.org,
273+
privacy=self.privacy,
167274
)
168275
self.assertIsInstance(github_project_3, RemoteRepository)
169276
self.assertIsInstance(github_project_4, RemoteRepository)
170277
self.assertEqual(github_project, github_project_3)
171278
self.assertEqual(github_project_2, github_project_4)
172279

173280
github_project_5 = self.service.create_repository(
174-
repo_json, organization=self.org, privacy=self.privacy,
281+
self.repo_response_data,
282+
organization=self.org,
283+
privacy=self.privacy,
175284
)
176285
github_project_6 = service.create_repository(
177-
repo_json, organization=self.org, privacy=self.privacy,
286+
self.repo_response_data,
287+
organization=self.org,
288+
privacy=self.privacy,
178289
)
179290

291+
self.assertIsNotNone(github_project)
180292
self.assertEqual(github_project, github_project_5)
293+
self.assertIsNotNone(github_project_2)
181294
self.assertEqual(github_project_2, github_project_6)
182295

183296
@mock.patch('readthedocs.oauth.services.github.log')
@@ -237,15 +350,18 @@ def test_make_private_project(self):
237350
Test ability to import ``public`` repositories under ``private`` level.
238351
"""
239352
repo_json = {
240-
'name': 'testrepo',
241-
'full_name': 'testuser/testrepo',
242-
'id': '12345678',
243-
'description': 'Test Repo',
244-
'git_url': 'git://github.com/testuser/testrepo.git',
245-
'private': False,
246-
'ssh_url': 'ssh://[email protected]:testuser/testrepo.git',
247-
'html_url': 'https://github.com/testuser/testrepo',
248-
'clone_url': 'https://github.com/testuser/testrepo.git',
353+
"name": "testrepo",
354+
"full_name": "testuser/testrepo",
355+
"id": "12345678",
356+
"description": "Test Repo",
357+
"git_url": "git://github.com/testuser/testrepo.git",
358+
"private": False,
359+
"ssh_url": "ssh://[email protected]:testuser/testrepo.git",
360+
"html_url": "https://github.com/testuser/testrepo",
361+
"clone_url": "https://github.com/testuser/testrepo.git",
362+
"owner": {
363+
"type": "User",
364+
},
249365
}
250366
repo = self.service.create_repository(repo_json, organization=self.org)
251367
self.assertIsNotNone(repo)

readthedocs/rtd_tests/tests/test_oauth_sync.py

+4-7
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
from allauth.socialaccount.models import SocialAccount, SocialToken
2-
from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter
31
import django_dynamic_fixture as fixture
42
import requests_mock
5-
3+
from allauth.socialaccount.models import SocialAccount, SocialToken
4+
from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter
65
from django.contrib.auth.models import User
76
from django.test import TestCase
87

@@ -13,9 +12,7 @@
1312
RemoteRepository,
1413
RemoteRepositoryRelation,
1514
)
16-
from readthedocs.oauth.services import (
17-
GitHubService,
18-
)
15+
from readthedocs.oauth.services import GitHubService
1916
from readthedocs.projects.models import Project
2017

2118

@@ -173,7 +170,7 @@ def test_sync_repositories_relation_with_organization(self, mock_request):
173170
"""
174171
Sync repositories relations for a user where the RemoteRepository and RemoteOrganization already exist.
175172
176-
Note that ``repository.owner.type == 'Organzation'`` in the GitHub response.
173+
Note that ``repository.owner.type == 'Organization'`` in the GitHub response.
177174
"""
178175
self.payload_user_repos[0]['owner']['type'] = 'Organization'
179176
mock_request.get('https://api.github.com/user/repos', json=self.payload_user_repos)

0 commit comments

Comments
 (0)