Skip to content

Commit 1360853

Browse files
committed
Add migration for GitHub App
Extracted from #11942
1 parent bfb15ca commit 1360853

File tree

7 files changed

+1229
-0
lines changed

7 files changed

+1229
-0
lines changed

readthedocs/oauth/migrate.py

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
"""This module contains the logic to help users migrate from the GitHub OAuth App to the GitHub App."""
2+
3+
from dataclasses import dataclass
4+
5+
from allauth.socialaccount.models import SocialAccount
6+
from allauth.socialaccount.providers.github.provider import GitHubProvider
7+
from django.conf import settings
8+
9+
from readthedocs.allauth.providers.githubapp.provider import GitHubAppProvider
10+
from readthedocs.core.permissions import AdminPermission
11+
from readthedocs.integrations.models import Integration
12+
from readthedocs.oauth.constants import GITHUB
13+
from readthedocs.oauth.constants import GITHUB_APP
14+
from readthedocs.oauth.models import GitHubAccountType
15+
from readthedocs.oauth.models import RemoteRepository
16+
from readthedocs.oauth.services import GitHubAppService
17+
from readthedocs.oauth.services import GitHubService
18+
from readthedocs.projects.models import Project
19+
20+
21+
@dataclass
22+
class GitHubAccountTarget:
23+
login: str
24+
id: int
25+
type: GitHubAccountType
26+
27+
28+
@dataclass
29+
class InstallationTargetGroup:
30+
"""Group of repositories that should be installed in the same target (user or organization)."""
31+
32+
target_id: int
33+
target_type: GitHubAccountType
34+
target_name: str
35+
repository_ids: set[int]
36+
37+
@property
38+
def link(self):
39+
"""
40+
Create a link to install the GitHub App on the target with the required repositories pre-selected.
41+
42+
See https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/migrating-oauth-apps-to-github-apps#prompt-users-to-install-your-github-app.
43+
"""
44+
repository_ids = []
45+
for repository_id in self.repository_ids:
46+
repository_ids.append(f"&repository_ids[]={repository_id}")
47+
repository_ids = "".join(repository_ids)
48+
49+
base_url = (
50+
f"https://github.com/apps/{settings.GITHUB_APP_NAME}/installations/new/permissions"
51+
)
52+
return f"{base_url}?suggested_target_id={self.target_id}{repository_ids}"
53+
54+
@property
55+
def installed(self):
56+
"""
57+
Check if the app was already installed on the target.
58+
59+
If we don't have any repositories left to install, the app was already installed,
60+
or we don't have any repositories to install the app on.
61+
"""
62+
return not bool(self.repository_ids)
63+
64+
65+
@dataclass
66+
class MigrationTarget:
67+
"""Information about an individual project that needs to be migrated."""
68+
69+
project: Project
70+
has_installation: bool
71+
is_admin: bool
72+
target_id: int
73+
74+
@property
75+
def installation_link(self):
76+
"""
77+
Create a link to install the GitHub App on the target repository.
78+
79+
See https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/migrating-oauth-apps-to-github-apps
80+
"""
81+
base_url = (
82+
f"https://github.com/apps/{settings.GITHUB_APP_NAME}/installations/new/permissions"
83+
)
84+
return f"{base_url}?suggested_target_id={self.target_id}&repository_ids[]={self.project.remote_repository.remote_id}"
85+
86+
@property
87+
def can_be_migrated(self):
88+
"""
89+
Check if the project can be migrated.
90+
91+
The project can be migrated if the user is an admin on the repository and the GitHub App is installed.
92+
"""
93+
return self.is_admin and self.has_installation
94+
95+
96+
@dataclass
97+
class MigrationResult:
98+
"""Result of a migration operation."""
99+
100+
webhook_removed: bool
101+
ssh_key_removed: bool
102+
103+
104+
class MigrationError(Exception):
105+
"""Error raised when a migration operation fails."""
106+
107+
pass
108+
109+
110+
def get_installation_target_groups_for_user(user) -> list[InstallationTargetGroup]:
111+
"""Get all targets (accounts and organizations) that the user needs to install the GitHub App on."""
112+
# Since we don't save the ID of the owner of each repository, we group all repositories
113+
# that we aren't able to identify the owner into the user's account.
114+
# GitHub will ignore the repositories that the user doesn't own.
115+
default_target_account = _get_default_github_account_target(user)
116+
117+
targets = {}
118+
for project, has_intallation, _ in _get_projects_missing_migration(user):
119+
remote_repository = project.remote_repository
120+
target_account = _get_github_account_target(remote_repository) or default_target_account
121+
if target_account.id not in targets:
122+
targets[target_account.id] = InstallationTargetGroup(
123+
target_id=target_account.id,
124+
target_name=target_account.login,
125+
target_type=target_account.type,
126+
repository_ids=set(),
127+
)
128+
if not has_intallation:
129+
targets[target_account.id].repository_ids.add(int(remote_repository.remote_id))
130+
131+
# Include accounts that have already migrated projects,
132+
# so they are shown as "Installed" in the UI.
133+
for project in get_migrated_projects(user):
134+
remote_repository = project.remote_repository
135+
target_account = _get_github_account_target(remote_repository) or default_target_account
136+
if target_account.id not in targets:
137+
targets[target_account.id] = InstallationTargetGroup(
138+
target_id=target_account.id,
139+
target_name=target_account.login,
140+
target_type=GitHubAccountType.USER,
141+
repository_ids=set(),
142+
)
143+
144+
return list(targets.values())
145+
146+
147+
def _get_default_github_account_target(user):
148+
# NOTE: there are some users that have more than one GH account connected.
149+
# They will need to migrate each account at a time.
150+
account = user.socialaccount_set.filter(provider=GitHubProvider.id).first()
151+
if not account:
152+
account = user.socialaccount_set.filter(provider=GitHubAppProvider.id).first()
153+
154+
return GitHubAccountTarget(
155+
login=account.extra_data.get("login", "ghost"),
156+
id=int(account.uid),
157+
type=GitHubAccountType.USER,
158+
)
159+
160+
161+
def _get_github_account_target(remote_repository):
162+
"""
163+
Get the GitHub account target for a repository.
164+
165+
This will return the account that owns the repository, if we can identify it.
166+
For repositories owned by organizations, we return the organization account,
167+
for repositories owned by users, we try to guess the account based on the repository owner
168+
(as we don't save the owner ID in the repository).
169+
"""
170+
if remote_repository.organization:
171+
return GitHubAccountTarget(
172+
login=remote_repository.organization.slug,
173+
id=int(remote_repository.organization.remote_id),
174+
type=GitHubAccountType.ORGANIZATION,
175+
)
176+
login = remote_repository.full_name.split("/", 1)[0]
177+
account = SocialAccount.objects.filter(
178+
provider__in=[GitHubProvider.id, GitHubAppProvider.id], extra_data__login=login
179+
).first()
180+
if account:
181+
return GitHubAccountTarget(
182+
login=login,
183+
id=int(account.uid),
184+
type=GitHubAccountType.USER,
185+
)
186+
return None
187+
188+
189+
def _get_projects_missing_migration(user):
190+
"""
191+
Get all projects where the user has admin permissions that are still connected to the old GitHub OAuth App.
192+
193+
Returns a generator with the project, a boolean indicating if the GitHub App is installed on the repository,
194+
and a boolean indicating if the user has admin permissions on the repository.
195+
"""
196+
projects = (
197+
AdminPermission.projects(user, admin=True)
198+
.filter(remote_repository__vcs_provider=GITHUB)
199+
.select_related(
200+
"remote_repository",
201+
"remote_repository__organization",
202+
)
203+
)
204+
for project in projects:
205+
remote_repository = project.remote_repository
206+
has_installation = RemoteRepository.objects.filter(
207+
remote_id=remote_repository.remote_id,
208+
vcs_provider=GITHUB_APP,
209+
github_app_installation__isnull=False,
210+
).exists()
211+
is_admin = (
212+
RemoteRepository.objects.for_project_linking(user)
213+
.filter(
214+
remote_id=project.remote_repository.remote_id,
215+
vcs_provider=GITHUB_APP,
216+
github_app_installation__isnull=False,
217+
)
218+
.exists()
219+
)
220+
yield project, has_installation, is_admin
221+
222+
223+
def get_migrated_projects(user):
224+
return (
225+
AdminPermission.projects(user, admin=True)
226+
.filter(remote_repository__vcs_provider=GITHUB_APP)
227+
.select_related(
228+
"remote_repository",
229+
)
230+
)
231+
232+
233+
def get_valid_projects_missing_migration(user):
234+
for project, has_installation, is_admin in _get_projects_missing_migration(user):
235+
if has_installation and is_admin:
236+
yield project
237+
238+
239+
def get_migration_targets(user) -> list[MigrationTarget]:
240+
"""Get all projects that the user needs to migrate to the GitHub App."""
241+
targets = []
242+
default_target_account = _get_default_github_account_target(user)
243+
for project, has_installation, is_admin in _get_projects_missing_migration(user):
244+
remote_repository = project.remote_repository
245+
target_account = _get_github_account_target(remote_repository) or default_target_account
246+
targets.append(
247+
MigrationTarget(
248+
project=project,
249+
has_installation=has_installation,
250+
is_admin=is_admin,
251+
target_id=target_account.id,
252+
)
253+
)
254+
return targets
255+
256+
257+
def get_old_app_link():
258+
"""
259+
Get the link to the old GitHub OAuth App settings page.
260+
261+
Useful so users can revoke the old app.
262+
"""
263+
client_id = settings.SOCIALACCOUNT_PROVIDERS["github"]["APPS"][0]["client_id"]
264+
return f"https://github.com/settings/connections/applications/{client_id}"
265+
266+
267+
def migrate_project_to_github_app(project, user) -> MigrationResult:
268+
"""
269+
Migrate a project to the new GitHub App.
270+
271+
This will remove the webhook and SSH key from the old GitHub OAuth App and
272+
connect the project to the new GitHub App.
273+
274+
Returns a MigrationResult with the status of the migration.
275+
Raises a MigrationError if the project can't be migrated,
276+
this should never happen as we don't allow migrating projects
277+
that can't be migrated from the UI.
278+
"""
279+
# No remote repository, nothing to migrate.
280+
if not project.remote_repository:
281+
raise MigrationError("Project isn't connected to a repository")
282+
283+
service_class = project.get_git_service_class()
284+
285+
# Already migrated, nothing to do.
286+
if service_class == GitHubAppService:
287+
return MigrationResult(webhook_removed=True, ssh_key_removed=True)
288+
289+
# Not a GitHub project, nothing to migrate.
290+
if service_class != GitHubService:
291+
raise MigrationError("Project isn't connected to a GitHub repository")
292+
293+
new_remote_repository = RemoteRepository.objects.filter(
294+
remote_id=project.remote_repository.remote_id,
295+
vcs_provider=GITHUB_APP,
296+
github_app_installation__isnull=False,
297+
).first()
298+
299+
if not new_remote_repository:
300+
raise MigrationError("You need to install the GitHub App on the repository")
301+
302+
new_remote_repository = (
303+
RemoteRepository.objects.for_project_linking(user)
304+
.filter(
305+
remote_id=project.remote_repository.remote_id,
306+
vcs_provider=GITHUB_APP,
307+
github_app_installation__isnull=False,
308+
)
309+
.first()
310+
)
311+
if not new_remote_repository:
312+
raise MigrationError("You must have admin permissions on the repository to migrate it")
313+
314+
webhook_removed = False
315+
ssh_key_removed = False
316+
for service in service_class.for_project(project):
317+
if not webhook_removed and service.remove_webhook(project):
318+
webhook_removed = True
319+
320+
if not ssh_key_removed and service.remove_ssh_key(project):
321+
ssh_key_removed = True
322+
323+
project.integrations.filter(integration_type=Integration.GITHUB_WEBHOOK).delete()
324+
project.remote_repository = new_remote_repository
325+
project.save()
326+
return MigrationResult(
327+
webhook_removed=webhook_removed,
328+
ssh_key_removed=ssh_key_removed,
329+
)

readthedocs/oauth/notifications.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.utils.translation import gettext_lazy as _
66

77
from readthedocs.notifications.constants import ERROR
8+
from readthedocs.notifications.constants import WARNING
89
from readthedocs.notifications.messages import Message
910
from readthedocs.notifications.messages import registry
1011

@@ -14,6 +15,8 @@
1415
MESSAGE_OAUTH_WEBHOOK_INVALID = "oauth:webhook:invalid"
1516
MESSAGE_OAUTH_BUILD_STATUS_FAILURE = "oauth:status:send-failed"
1617
MESSAGE_OAUTH_DEPLOY_KEY_ATTACHED_FAILED = "oauth:deploy-key:attached-failed"
18+
MESSAGE_OAUTH_WEBHOOK_NOT_REMOVED = "oauth:migration:webhook-not-removed"
19+
MESSAGE_OAUTH_DEPLOY_KEY_NOT_REMOVED = "oauth:migration:ssh-key-not-removed"
1720

1821
messages = [
1922
Message(
@@ -83,5 +86,31 @@
8386
),
8487
type=ERROR,
8588
),
89+
Message(
90+
id=MESSAGE_OAUTH_WEBHOOK_NOT_REMOVED,
91+
header=_("Failed to remove webhook"),
92+
body=_(
93+
textwrap.dedent(
94+
"""
95+
Failed to remove webhook from the <a href="https://github.com/{{ repo_full_name }}">{{ repo_full_name }}</a> repository, please remove it manually
96+
from the <a href="https://github.com/{{ repo_full_name }}/settings/hooks">repository settings</a> (search for a webhook containing "{{ project_slug }}" in the URL).
97+
"""
98+
).strip(),
99+
),
100+
type=WARNING,
101+
),
102+
Message(
103+
id=MESSAGE_OAUTH_DEPLOY_KEY_NOT_REMOVED,
104+
header=_("Failed to remove deploy key"),
105+
body=_(
106+
textwrap.dedent(
107+
"""
108+
Failed to remove deploy key from the <a href="https://github.com/{{ repo_full_name }}">{{ repo_full_name }}</a> repository, please remove it manually
109+
from the <a href="https://github.com/{{ repo_full_name }}/settings/keys">repository settings</a> (search for a deploy key containing "{{ project_slug }}" in the title).
110+
"""
111+
)
112+
),
113+
type=WARNING,
114+
),
86115
]
87116
registry.add(messages)

0 commit comments

Comments
 (0)