diff --git a/readthedocs/core/templatetags/readthedocs/socialaccounts.py b/readthedocs/core/templatetags/readthedocs/socialaccounts.py
index fd952d6766f..9f767271a81 100644
--- a/readthedocs/core/templatetags/readthedocs/socialaccounts.py
+++ b/readthedocs/core/templatetags/readthedocs/socialaccounts.py
@@ -1,7 +1,11 @@
from allauth.socialaccount.adapter import get_adapter
+from allauth.socialaccount.providers.github.provider import GitHubProvider
from django import forms
from django import template
+from readthedocs.allauth.providers.githubapp.provider import GitHubAppProvider
+from readthedocs.oauth.utils import is_access_revoked as utils_is_access_revoked
+
register = template.Library()
@@ -19,3 +23,21 @@ def can_be_disconnected(account):
return True
except forms.ValidationError:
return False
+
+
+@register.filter
+def is_access_revoked(account):
+ """Check if access to the account is revoked."""
+ return utils_is_access_revoked(account)
+
+
+@register.filter
+def has_github_app_account(account):
+ """Check if there is a GitHub App account matching this account."""
+ if account.provider != GitHubProvider.id:
+ return False
+
+ return account.user.socialaccount_set.filter(
+ provider=GitHubAppProvider.id,
+ uid=account.uid,
+ ).exists()
diff --git a/readthedocs/oauth/migrate.py b/readthedocs/oauth/migrate.py
new file mode 100644
index 00000000000..dc543311c39
--- /dev/null
+++ b/readthedocs/oauth/migrate.py
@@ -0,0 +1,371 @@
+"""This module contains the logic to help users migrate from the GitHub OAuth App to the GitHub App."""
+
+from dataclasses import dataclass
+from typing import Iterator
+from urllib.parse import urlencode
+
+from allauth.socialaccount.models import SocialAccount
+from allauth.socialaccount.providers.github.provider import GitHubProvider
+from django.conf import settings
+
+from readthedocs.allauth.providers.githubapp.provider import GitHubAppProvider
+from readthedocs.core.permissions import AdminPermission
+from readthedocs.integrations.models import Integration
+from readthedocs.oauth.constants import GITHUB
+from readthedocs.oauth.constants import GITHUB_APP
+from readthedocs.oauth.models import GitHubAccountType
+from readthedocs.oauth.models import RemoteRepository
+from readthedocs.oauth.services import GitHubAppService
+from readthedocs.oauth.services import GitHubService
+from readthedocs.projects.models import Project
+
+
+@dataclass
+class GitHubAccountTarget:
+ """Information about a GitHub account that is used as a target where to install the GitHub App."""
+
+ id: int
+ login: str
+ type: GitHubAccountType
+ avatar_url: str
+ profile_url: str
+
+
+@dataclass
+class InstallationTargetGroup:
+ """Group of repositories that should be installed in the same target (user or organization)."""
+
+ target: GitHubAccountTarget
+ repository_ids: set[int]
+
+ @property
+ def link(self):
+ """
+ Create a link to install the GitHub App on the target with the required repositories pre-selected.
+
+ 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.
+ """
+ base_url = (
+ f"https://github.com/apps/{settings.GITHUB_APP_NAME}/installations/new/permissions"
+ )
+ query_params = urlencode(
+ {
+ "suggested_target_id": self.target.id,
+ "repository_ids[]": self.repository_ids,
+ },
+ doseq=True,
+ )
+ return f"{base_url}?{query_params}"
+
+ @property
+ def installed(self):
+ """
+ Check if the app has been installed in all required repositories.
+
+ If the the list of `repository_ids` is not empty, it means that the app still needs to be installed in some repositories,
+ or that the app hasn't been installed at all in the target account.
+ """
+ return not bool(self.repository_ids)
+
+
+@dataclass
+class MigrationTarget:
+ """Information about an individual project that needs to be migrated."""
+
+ project: Project
+ has_installation: bool
+ is_admin: bool
+ target_id: int
+
+ @property
+ def installation_link(self):
+ """
+ Create a link to install the GitHub App on the target repository.
+
+ See https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/migrating-oauth-apps-to-github-apps
+ """
+ base_url = (
+ f"https://github.com/apps/{settings.GITHUB_APP_NAME}/installations/new/permissions"
+ )
+ query_params = urlencode(
+ {
+ "suggested_target_id": self.target_id,
+ "repository_ids[]": self.project.remote_repository.remote_id,
+ }
+ )
+ return f"{base_url}?{query_params}"
+
+ @property
+ def can_be_migrated(self):
+ """
+ Check if the project can be migrated.
+
+ The project can be migrated if the user is an admin on the repository and the GitHub App is installed.
+ """
+ return self.is_admin and self.has_installation
+
+
+@dataclass
+class MigrationResult:
+ """Result of a migration operation."""
+
+ webhook_removed: bool
+ ssh_key_removed: bool
+
+
+class MigrationError(Exception):
+ """Error raised when a migration operation fails."""
+
+ pass
+
+
+def get_installation_target_groups_for_user(user) -> list[InstallationTargetGroup]:
+ """Get all targets (accounts and organizations) that the user needs to install the GitHub App on."""
+ # Since we don't save the ID of the owner of each repository, we group all repositories
+ # that we aren't able to identify the owner into the user's account.
+ # GitHub will ignore the repositories that the user doesn't own.
+ default_target_account = _get_default_github_account_target(user)
+
+ targets = {}
+ for project, has_intallation, _ in _get_projects_missing_migration(user):
+ remote_repository = project.remote_repository
+ target_account = _get_github_account_target(remote_repository) or default_target_account
+ if target_account.id not in targets:
+ targets[target_account.id] = InstallationTargetGroup(
+ target=target_account,
+ repository_ids=set(),
+ )
+ if not has_intallation:
+ targets[target_account.id].repository_ids.add(int(remote_repository.remote_id))
+
+ # Include accounts that have already migrated projects,
+ # so they are shown as "Installed" in the UI.
+ for project in get_migrated_projects(user):
+ remote_repository = project.remote_repository
+ target_account = _get_github_account_target(remote_repository) or default_target_account
+ if target_account.id not in targets:
+ targets[target_account.id] = InstallationTargetGroup(
+ target=target_account,
+ repository_ids=set(),
+ )
+
+ return list(targets.values())
+
+
+def _get_default_github_account_target(user) -> GitHubAccountTarget:
+ """
+ Get the GitHub account from the user.
+
+ We first try to get the account from our old GitHub OAuth App,
+ and fallback to the new GitHub App if we can't find it.
+
+ .. note::
+
+ There are some users that have more than one GH account connected.
+ They will need to migrate each account at a time.
+ """
+ # This needs to support multiple accounts....
+ account = user.socialaccount_set.filter(provider=GitHubProvider.id).first()
+ if not account:
+ account = user.socialaccount_set.filter(provider=GitHubAppProvider.id).first()
+
+ return GitHubAccountTarget(
+ # We shouldn't have users without a login, but just in case.
+ login=account.extra_data.get("login", "ghost"),
+ id=int(account.uid),
+ type=GitHubAccountType.USER,
+ avatar_url=account.get_avatar_url(),
+ profile_url=account.get_profile_url(),
+ )
+
+
+def _get_github_account_target(remote_repository) -> GitHubAccountTarget | None:
+ """
+ Get the GitHub account target for a repository.
+
+ This will return the account that owns the repository, if we can identify it.
+ For repositories owned by organizations, we return the organization account,
+ for repositories owned by users, we try to guess the account based on the repository owner
+ (as we don't save the owner ID in the remote repository object).
+ """
+ remote_organization = remote_repository.organization
+ if remote_organization:
+ return GitHubAccountTarget(
+ login=remote_organization.slug,
+ id=int(remote_organization.remote_id),
+ type=GitHubAccountType.ORGANIZATION,
+ avatar_url=remote_organization.avatar_url,
+ profile_url=remote_organization.url,
+ )
+ login = remote_repository.full_name.split("/", 1)[0]
+ account = SocialAccount.objects.filter(
+ provider__in=[GitHubProvider.id, GitHubAppProvider.id], extra_data__login=login
+ ).first()
+ if account:
+ return GitHubAccountTarget(
+ login=login,
+ id=int(account.uid),
+ type=GitHubAccountType.USER,
+ avatar_url=account.get_avatar_url(),
+ profile_url=account.get_profile_url(),
+ )
+ return None
+
+
+def _get_projects_missing_migration(user) -> Iterator[tuple[Project, bool, bool]]:
+ """
+ Get all projects where the user has admin permissions that are still connected to the old GitHub OAuth App.
+
+ Returns an iterator with the project, a boolean indicating if the GitHub App is installed on the repository,
+ and a boolean indicating if the user has admin permissions on the repository.
+ """
+ projects = (
+ AdminPermission.projects(user, admin=True)
+ .filter(remote_repository__vcs_provider=GITHUB)
+ .select_related(
+ "remote_repository",
+ "remote_repository__organization",
+ )
+ )
+ for project in projects:
+ remote_repository = project.remote_repository
+ has_installation = RemoteRepository.objects.filter(
+ remote_id=remote_repository.remote_id,
+ vcs_provider=GITHUB_APP,
+ github_app_installation__isnull=False,
+ ).exists()
+ is_admin = (
+ RemoteRepository.objects.for_project_linking(user)
+ .filter(
+ remote_id=project.remote_repository.remote_id,
+ vcs_provider=GITHUB_APP,
+ github_app_installation__isnull=False,
+ )
+ .exists()
+ )
+ yield project, has_installation, is_admin
+
+
+def get_migrated_projects(user):
+ """
+ Get all projects from the user that are already migrated to the GitHub App.
+
+ This is basically all projects that are connected to a remote repository from the GitHub App.
+ """
+ return (
+ AdminPermission.projects(user, admin=True)
+ .filter(remote_repository__vcs_provider=GITHUB_APP)
+ .select_related(
+ "remote_repository",
+ )
+ )
+
+
+def get_valid_projects_missing_migration(user):
+ """
+ Get all projects that the user can migrate to the GitHub App.
+
+ This includes all projects that are connected to the old GitHub OAuth App,
+ where the user has admin permissions and the GitHub App is installed.
+ """
+ for project, has_installation, is_admin in _get_projects_missing_migration(user):
+ if has_installation and is_admin:
+ yield project
+
+
+def get_migration_targets(user) -> list[MigrationTarget]:
+ """
+ Get all projects that the user needs to migrate to the GitHub App.
+
+ This includes all projects that are connected to the old GitHub OAuth App,
+ doesn't matter if the user has admin permissions or the GitHub App is installed.
+ """
+ targets = []
+ default_target_account = _get_default_github_account_target(user)
+ for project, has_installation, is_admin in _get_projects_missing_migration(user):
+ remote_repository = project.remote_repository
+ target_account = _get_github_account_target(remote_repository) or default_target_account
+ targets.append(
+ MigrationTarget(
+ project=project,
+ has_installation=has_installation,
+ is_admin=is_admin,
+ target_id=target_account.id,
+ )
+ )
+ return targets
+
+
+def get_old_app_link() -> str:
+ """
+ Get the link to the old GitHub OAuth App settings page.
+
+ Useful so users can revoke the old app.
+ """
+ client_id = settings.SOCIALACCOUNT_PROVIDERS["github"]["APPS"][0]["client_id"]
+ return f"https://github.com/settings/connections/applications/{client_id}"
+
+
+def migrate_project_to_github_app(project, user) -> MigrationResult:
+ """
+ Migrate a project to the new GitHub App.
+
+ This will remove the webhook and SSH key from the old GitHub OAuth App and
+ connect the project to the new GitHub App.
+
+ Returns a MigrationResult with the status of the migration.
+ Raises a MigrationError if the project can't be migrated,
+ this should never happen as we don't allow migrating projects
+ that can't be migrated from the UI.
+ """
+ # No remote repository, nothing to migrate.
+ if not project.remote_repository:
+ raise MigrationError("Project isn't connected to a repository")
+
+ service_class = project.get_git_service_class()
+
+ # Already migrated, nothing to do.
+ if service_class == GitHubAppService:
+ return MigrationResult(webhook_removed=True, ssh_key_removed=True)
+
+ # Not a GitHub project, nothing to migrate.
+ if service_class != GitHubService:
+ raise MigrationError("Project isn't connected to a GitHub repository")
+
+ new_remote_repository = RemoteRepository.objects.filter(
+ remote_id=project.remote_repository.remote_id,
+ vcs_provider=GITHUB_APP,
+ github_app_installation__isnull=False,
+ ).first()
+
+ if not new_remote_repository:
+ raise MigrationError("You need to install the GitHub App on the repository")
+
+ new_remote_repository = (
+ RemoteRepository.objects.for_project_linking(user)
+ .filter(
+ remote_id=project.remote_repository.remote_id,
+ vcs_provider=GITHUB_APP,
+ github_app_installation__isnull=False,
+ )
+ .first()
+ )
+ if not new_remote_repository:
+ raise MigrationError("You must have admin permissions on the repository to migrate it")
+
+ webhook_removed = False
+ ssh_key_removed = False
+ for service in service_class.for_project(project):
+ if not webhook_removed and service.remove_webhook(project):
+ webhook_removed = True
+
+ if not ssh_key_removed and service.remove_ssh_key(project):
+ ssh_key_removed = True
+
+ project.integrations.filter(integration_type=Integration.GITHUB_WEBHOOK).delete()
+ project.remote_repository = new_remote_repository
+ project.save()
+ return MigrationResult(
+ webhook_removed=webhook_removed,
+ ssh_key_removed=ssh_key_removed,
+ )
diff --git a/readthedocs/oauth/notifications.py b/readthedocs/oauth/notifications.py
index 968c1be8ebd..496a7c83c9b 100644
--- a/readthedocs/oauth/notifications.py
+++ b/readthedocs/oauth/notifications.py
@@ -5,6 +5,7 @@
from django.utils.translation import gettext_lazy as _
from readthedocs.notifications.constants import ERROR
+from readthedocs.notifications.constants import WARNING
from readthedocs.notifications.messages import Message
from readthedocs.notifications.messages import registry
@@ -14,6 +15,8 @@
MESSAGE_OAUTH_WEBHOOK_INVALID = "oauth:webhook:invalid"
MESSAGE_OAUTH_BUILD_STATUS_FAILURE = "oauth:status:send-failed"
MESSAGE_OAUTH_DEPLOY_KEY_ATTACHED_FAILED = "oauth:deploy-key:attached-failed"
+MESSAGE_OAUTH_WEBHOOK_NOT_REMOVED = "oauth:migration:webhook-not-removed"
+MESSAGE_OAUTH_DEPLOY_KEY_NOT_REMOVED = "oauth:migration:ssh-key-not-removed"
messages = [
Message(
@@ -83,5 +86,31 @@
),
type=ERROR,
),
+ Message(
+ id=MESSAGE_OAUTH_WEBHOOK_NOT_REMOVED,
+ header=_("Failed to remove webhook"),
+ body=_(
+ textwrap.dedent(
+ """
+ Failed to remove webhook from the {{ repo_full_name }} repository, please remove it manually
+ from the repository settings (search for a webhook containing "{{ project_slug }}" in the URL).
+ """
+ ).strip(),
+ ),
+ type=WARNING,
+ ),
+ Message(
+ id=MESSAGE_OAUTH_DEPLOY_KEY_NOT_REMOVED,
+ header=_("Failed to remove deploy key"),
+ body=_(
+ textwrap.dedent(
+ """
+ Failed to remove deploy key from the {{ repo_full_name }} repository, please remove it manually
+ from the repository settings (search for a deploy key containing "{{ project_slug }}" in the title).
+ """
+ )
+ ),
+ type=WARNING,
+ ),
]
registry.add(messages)
diff --git a/readthedocs/oauth/services/github.py b/readthedocs/oauth/services/github.py
index 8863afacc13..c12cc3be51b 100644
--- a/readthedocs/oauth/services/github.py
+++ b/readthedocs/oauth/services/github.py
@@ -8,6 +8,7 @@
from django.conf import settings
from oauthlib.oauth2.rfc6749.errors import InvalidGrantError
from oauthlib.oauth2.rfc6749.errors import TokenExpiredError
+from requests.exceptions import HTTPError
from requests.exceptions import RequestException
from readthedocs.builds import utils as build_utils
@@ -406,6 +407,61 @@ def update_webhook(self, project, integration):
return (False, resp)
+ def remove_webhook(self, project):
+ """
+ Remove GitHub webhook for the repository associated with the project.
+
+ We delete all webhooks that match the URL of the webhook we set up.
+ The URLs can be in several formats, so we check for all of them:
+
+ - https://app.readthedocs.org/api/v2/webhook/github//
+ - https://app.readthedocs.org/api/v2/webhook//
+ - https://readthedocs.org/api/v2/webhook/github//
+ - https://readthedocs.org/api/v2/webhook//
+
+ If a webhook fails to be removed, we log the error and cancel the operation,
+ as if we weren't able to delete one webhook, we won't be able to delete the others either.
+
+ If we didn't find any webhook to delete, we return True.
+ """
+ owner, repo = build_utils.get_github_username_repo(url=project.repo)
+
+ try:
+ resp = self.session.get(f"{self.base_api_url}/repos/{owner}/{repo}/hooks")
+ resp.raise_for_status()
+ data = resp.json()
+ except HTTPError:
+ log.info("Failed to get GitHub webhooks for project.")
+ return False
+
+ hook_targets = [
+ f"{settings.PUBLIC_API_URL}/api/v2/webhook/{project.slug}/",
+ f"{settings.PUBLIC_API_URL}/api/v2/webhook/github/{project.slug}/",
+ ]
+ hook_targets.append(hook_targets[0].replace("app.", "", 1))
+ hook_targets.append(hook_targets[1].replace("app.", "", 1))
+
+ for hook in data:
+ hook_url = hook["config"]["url"]
+ for hook_target in hook_targets:
+ if hook_url.startswith(hook_target):
+ try:
+ self.session.delete(
+ f"{self.base_api_url}/repos/{owner}/{repo}/hooks/{hook['id']}"
+ ).raise_for_status()
+ except HTTPError:
+ log.info("Failed to remove GitHub webhook for project.")
+ return False
+ return True
+
+ def remove_ssh_key(self, project):
+ """
+ Remove the SSH key from the GitHub repository associated with the project.
+
+ This is overridden in .com, as we don't make use of the SSH keys in .org.
+ """
+ return True
+
def send_build_status(self, *, build, commit, status):
"""
Create GitHub commit status for project.
diff --git a/readthedocs/oauth/utils.py b/readthedocs/oauth/utils.py
index 776fdd20f1d..013f7c93b93 100644
--- a/readthedocs/oauth/utils.py
+++ b/readthedocs/oauth/utils.py
@@ -5,6 +5,7 @@
from django.utils.translation import gettext_lazy as _
from readthedocs.integrations.models import Integration
+from readthedocs.oauth.clients import get_oauth2_client
from readthedocs.oauth.services import BitbucketService
from readthedocs.oauth.services import GitHubService
from readthedocs.oauth.services import GitLabService
@@ -61,3 +62,20 @@ def update_webhook(project, integration, request=None):
project.has_valid_webhook = False
project.save()
return False
+
+
+def is_access_revoked(account):
+ """Check if the access token for the given account is revoked."""
+ client = get_oauth2_client(account)
+ if client is None:
+ return True
+
+ provider = account.get_provider()
+ oauth2_adapter = provider.get_oauth2_adapter(request=provider.request)
+ test_url = oauth2_adapter.profile_url
+ resp = client.get(test_url)
+ # NOTE: This has only been tested for GitHub,
+ # check if Gitlab and Bitbucket return 401.
+ if resp.status_code == 401:
+ return True
+ return False
diff --git a/readthedocs/profiles/tests/__init__.py b/readthedocs/profiles/tests/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/readthedocs/profiles/tests/test_views.py b/readthedocs/profiles/tests/test_views.py
new file mode 100644
index 00000000000..1b2597273f3
--- /dev/null
+++ b/readthedocs/profiles/tests/test_views.py
@@ -0,0 +1,739 @@
+from unittest import mock
+
+import pytest
+import requests_mock
+from allauth.socialaccount.models import SocialAccount, SocialToken
+from allauth.socialaccount.providers.github.provider import GitHubProvider
+from django.conf import settings
+from django.contrib.auth.models import User
+from django.test import TestCase, override_settings
+from django.urls import reverse
+from django_dynamic_fixture import get
+
+from readthedocs.allauth.providers.githubapp.provider import GitHubAppProvider
+from readthedocs.notifications.models import Notification
+from readthedocs.oauth.constants import GITHUB, GITHUB_APP
+from readthedocs.oauth.migrate import InstallationTargetGroup, MigrationTarget, GitHubAccountTarget
+from readthedocs.oauth.models import (
+ GitHubAccountType,
+ GitHubAppInstallation,
+ RemoteOrganization,
+ RemoteOrganizationRelation,
+ RemoteRepository,
+ RemoteRepositoryRelation,
+)
+from readthedocs.oauth.notifications import (
+ MESSAGE_OAUTH_DEPLOY_KEY_NOT_REMOVED,
+ MESSAGE_OAUTH_WEBHOOK_NOT_REMOVED,
+)
+from readthedocs.oauth.services.github import GitHubService
+from readthedocs.projects.models import Project
+
+
+@pytest.mark.skipif(
+ not settings.RTD_EXT_THEME_ENABLED, reason="Not applicable for the old theme"
+)
+@override_settings(GITHUB_APP_NAME="readthedocs")
+class TestMigrateToGitHubAppView(TestCase):
+ def setUp(self):
+ self.user = get(User)
+ self.social_account_github = get(
+ SocialAccount,
+ provider=GitHubProvider.id,
+ user=self.user,
+ uid="1234",
+ extra_data={"login": "user"},
+ )
+ get(
+ SocialToken,
+ account=self.social_account_github,
+ )
+ self.social_account_github_app = get(
+ SocialAccount,
+ provider=GitHubAppProvider.id,
+ user=self.user,
+ uid="1234",
+ extra_data={"login": "user"},
+ )
+ self.github_app_installation = get(
+ GitHubAppInstallation,
+ installation_id=1111,
+ target_id=int(self.social_account_github_app.uid),
+ target_type=GitHubAccountType.USER,
+ )
+
+ # Project with remote repository where the user is admin.
+ self.remote_repository_a = get(
+ RemoteRepository,
+ name="repo-a",
+ full_name="user/repo-a",
+ html_url="https://github.com/user/repo-a",
+ remote_id="1111",
+ vcs_provider=GITHUB,
+ )
+ get(
+ RemoteRepositoryRelation,
+ user=self.user,
+ account=self.social_account_github,
+ remote_repository=self.remote_repository_a,
+ admin=True,
+ )
+ self.project_with_remote_repository = get(
+ Project,
+ users=[self.user],
+ remote_repository=self.remote_repository_a,
+ )
+
+ # Project with remote repository where the user is not admin.
+ self.remote_repository_b = get(
+ RemoteRepository,
+ name="repo-b",
+ full_name="user/repo-b",
+ html_url="https://github.com/user/repo-b",
+ remote_id="2222",
+ vcs_provider=GITHUB,
+ )
+ get(
+ RemoteRepositoryRelation,
+ user=self.user,
+ account=self.social_account_github,
+ remote_repository=self.remote_repository_b,
+ admin=False,
+ )
+ self.project_with_remote_repository_no_admin = get(
+ Project,
+ users=[self.user],
+ remote_repository=self.remote_repository_b,
+ )
+
+ # Project with remote repository where the user doesn't have permissions at all.
+ self.remote_repository_c = get(
+ RemoteRepository,
+ name="repo-c",
+ full_name="user2/repo-c",
+ html_url="https://github.com/user2/repo-c",
+ remote_id="3333",
+ vcs_provider=GITHUB,
+ )
+ get(
+ RemoteRepositoryRelation,
+ user=self.user,
+ account=self.social_account_github,
+ remote_repository=self.remote_repository_c,
+ admin=False,
+ )
+ self.project_with_remote_repository_no_member = get(
+ Project,
+ users=[self.user],
+ remote_repository=self.remote_repository_c,
+ )
+
+ # Project connected to a remote repository that belongs to an organization.
+ self.remote_organization = get(
+ RemoteOrganization,
+ slug="org",
+ name="Organization",
+ remote_id="9999",
+ vcs_provider=GITHUB,
+ )
+ get(
+ RemoteOrganizationRelation,
+ user=self.user,
+ account=self.social_account_github,
+ )
+ self.remote_repository_d = get(
+ RemoteRepository,
+ name="repo-d",
+ full_name="org/repo-d",
+ html_url="https://github.com/org/repo-d",
+ remote_id="4444",
+ organization=self.remote_organization,
+ )
+ get(
+ RemoteRepositoryRelation,
+ user=self.user,
+ account=self.social_account_github,
+ remote_repository=self.remote_repository_d,
+ admin=True,
+ )
+ self.project_with_remote_organization = get(
+ Project,
+ users=[self.user],
+ remote_repository=self.remote_repository_d,
+ )
+ self.github_app_organization_installation = get(
+ GitHubAppInstallation,
+ installation_id=2222,
+ target_id=int(self.remote_organization.remote_id),
+ target_type=GitHubAccountType.ORGANIZATION,
+ )
+
+ # Project without a remote repository.
+ self.project_without_remote_repository = get(
+ Project,
+ users=[self.user],
+ repo="https://github.com/user/repo-e",
+ )
+
+ self.url = reverse("migrate_to_github_app")
+ self.client.force_login(self.user)
+
+ def _create_github_app_remote_repository(self, remote_repository):
+ new_remote_repository = get(
+ RemoteRepository,
+ name=remote_repository.name,
+ full_name=remote_repository.full_name,
+ html_url=remote_repository.html_url,
+ remote_id=remote_repository.remote_id,
+ vcs_provider=GITHUB_APP,
+ github_app_installation=self.github_app_installation,
+ )
+ if remote_repository.organization:
+ new_remote_repository.organization = get(
+ RemoteOrganization,
+ slug=remote_repository.organization.slug,
+ name=remote_repository.organization.name,
+ remote_id=remote_repository.organization.remote_id,
+ vcs_provider=GITHUB_APP,
+ )
+ new_remote_repository.github_app_installation = (
+ self.github_app_organization_installation
+ )
+ new_remote_repository.save()
+ for relation in remote_repository.remote_repository_relations.all():
+ github_app_account = relation.user.socialaccount_set.get(
+ provider=GitHubAppProvider.id
+ )
+ get(
+ RemoteRepositoryRelation,
+ user=relation.user,
+ account=github_app_account,
+ remote_repository=new_remote_repository,
+ admin=relation.admin,
+ )
+ return new_remote_repository
+
+ def test_user_without_github_account(self):
+ self.user.socialaccount_set.all().delete()
+ response = self.client.get(self.url)
+ assert response.status_code == 302
+ response = self.client.get(reverse("projects_dashboard"))
+ content = response.content.decode()
+ print(content)
+ assert (
+ "You don\\u0026#x27\\u003Bt have any GitHub account connected." in content
+ )
+
+ def test_user_without_github_account_but_with_github_app_account(self):
+ self.user.socialaccount_set.exclude(provider=GitHubAppProvider.id).delete()
+ response = self.client.get(self.url)
+ assert response.status_code == 302
+ response = self.client.get(reverse("projects_dashboard"))
+ assert (
+ "You have already migrated your account to the new GitHub App"
+ in response.content.decode()
+ )
+
+ @requests_mock.Mocker(kw="request")
+ def test_migration_page_initial_state(self, request):
+ request.get("https://api.github.com/user", status_code=200)
+
+ self.user.socialaccount_set.filter(provider=GitHubAppProvider.id).delete()
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ context = response.context
+
+ assert context["step"] == "overview"
+ assert context["step_connect_completed"] is False
+ assert context["installation_target_groups"] == [
+ InstallationTargetGroup(
+ target=GitHubAccountTarget(
+ id=int(self.social_account_github.uid),
+ login="user",
+ type=GitHubAccountType.USER,
+ avatar_url=self.social_account_github.get_avatar_url(),
+ profile_url=self.social_account_github.get_profile_url(),
+ ),
+ repository_ids={1111, 2222, 3333},
+ ),
+ InstallationTargetGroup(
+ target=GitHubAccountTarget(
+ id=int(self.remote_organization.remote_id),
+ login="org",
+ type=GitHubAccountType.ORGANIZATION,
+ avatar_url=self.remote_organization.avatar_url,
+ profile_url=self.remote_organization.url,
+ ),
+ repository_ids={4444},
+ ),
+ ]
+ assert context["github_app_name"] == "readthedocs"
+ assert context["migration_targets"] == [
+ MigrationTarget(
+ project=self.project_with_remote_repository,
+ has_installation=False,
+ is_admin=False,
+ target_id=int(self.social_account_github.uid),
+ ),
+ MigrationTarget(
+ project=self.project_with_remote_repository_no_admin,
+ has_installation=False,
+ is_admin=False,
+ target_id=int(self.social_account_github.uid),
+ ),
+ MigrationTarget(
+ project=self.project_with_remote_repository_no_member,
+ has_installation=False,
+ is_admin=False,
+ target_id=int(self.social_account_github.uid),
+ ),
+ MigrationTarget(
+ project=self.project_with_remote_organization,
+ has_installation=False,
+ is_admin=False,
+ target_id=int(self.remote_organization.remote_id),
+ ),
+ ]
+ assert list(context["migrated_projects"]) == []
+ assert (
+ context["old_application_link"]
+ == "https://github.com/settings/connections/applications/123"
+ )
+ assert context["step_revoke_completed"] is False
+ assert list(context["old_github_accounts"]) == [self.social_account_github]
+
+ @requests_mock.Mocker(kw="request")
+ def test_migration_page_step_connect_done(self, request):
+ request.get("https://api.github.com/user", status_code=200)
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ context = response.context
+
+ assert context["step"] == "overview"
+ assert context["step_connect_completed"] is True
+ assert context["installation_target_groups"] == [
+ InstallationTargetGroup(
+ target=GitHubAccountTarget(
+ id=int(self.social_account_github.uid),
+ login="user",
+ type=GitHubAccountType.USER,
+ avatar_url=self.social_account_github.get_avatar_url(),
+ profile_url=self.social_account_github.get_profile_url(),
+ ),
+ repository_ids={1111, 2222, 3333},
+ ),
+ InstallationTargetGroup(
+ target=GitHubAccountTarget(
+ id=int(self.remote_organization.remote_id),
+ login="org",
+ type=GitHubAccountType.ORGANIZATION,
+ avatar_url=self.remote_organization.avatar_url,
+ profile_url=self.remote_organization.url,
+ ),
+ repository_ids={4444},
+ ),
+ ]
+ assert context["github_app_name"] == "readthedocs"
+ assert context["migration_targets"] == [
+ MigrationTarget(
+ project=self.project_with_remote_repository,
+ has_installation=False,
+ is_admin=False,
+ target_id=int(self.social_account_github.uid),
+ ),
+ MigrationTarget(
+ project=self.project_with_remote_repository_no_admin,
+ has_installation=False,
+ is_admin=False,
+ target_id=int(self.social_account_github.uid),
+ ),
+ MigrationTarget(
+ project=self.project_with_remote_repository_no_member,
+ has_installation=False,
+ is_admin=False,
+ target_id=int(self.social_account_github.uid),
+ ),
+ MigrationTarget(
+ project=self.project_with_remote_organization,
+ has_installation=False,
+ is_admin=False,
+ target_id=int(self.remote_organization.remote_id),
+ ),
+ ]
+ assert list(context["migrated_projects"]) == []
+ assert (
+ context["old_application_link"]
+ == "https://github.com/settings/connections/applications/123"
+ )
+ assert context["step_revoke_completed"] is False
+ assert list(context["old_github_accounts"]) == [self.social_account_github]
+
+ @requests_mock.Mocker(kw="request")
+ def test_migration_page_step_install_done(self, request):
+ request.get("https://api.github.com/user", status_code=200)
+
+ self._create_github_app_remote_repository(self.remote_repository_a)
+ self._create_github_app_remote_repository(self.remote_repository_b)
+ self._create_github_app_remote_repository(self.remote_repository_d)
+
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ context = response.context
+
+ assert context["step"] == "overview"
+ assert context["step_connect_completed"] is True
+ assert context["installation_target_groups"] == [
+ InstallationTargetGroup(
+ target=GitHubAccountTarget(
+ id=int(self.social_account_github.uid),
+ login="user",
+ type=GitHubAccountType.USER,
+ avatar_url=self.social_account_github.get_avatar_url(),
+ profile_url=self.social_account_github.get_profile_url(),
+ ),
+ repository_ids={3333},
+ ),
+ InstallationTargetGroup(
+ target=GitHubAccountTarget(
+ id=int(self.remote_organization.remote_id),
+ login="org",
+ type=GitHubAccountType.ORGANIZATION,
+ avatar_url=self.remote_organization.avatar_url,
+ profile_url=self.remote_organization.url,
+ ),
+ repository_ids=set(),
+ ),
+ ]
+ assert context["github_app_name"] == "readthedocs"
+ assert context["migration_targets"] == [
+ MigrationTarget(
+ project=self.project_with_remote_repository,
+ has_installation=True,
+ is_admin=True,
+ target_id=int(self.social_account_github.uid),
+ ),
+ MigrationTarget(
+ project=self.project_with_remote_repository_no_admin,
+ has_installation=True,
+ is_admin=False,
+ target_id=int(self.social_account_github.uid),
+ ),
+ MigrationTarget(
+ project=self.project_with_remote_repository_no_member,
+ has_installation=False,
+ is_admin=False,
+ target_id=int(self.social_account_github.uid),
+ ),
+ MigrationTarget(
+ project=self.project_with_remote_organization,
+ has_installation=True,
+ is_admin=True,
+ target_id=int(self.remote_organization.remote_id),
+ ),
+ ]
+ assert list(context["migrated_projects"]) == []
+ assert (
+ context["old_application_link"]
+ == "https://github.com/settings/connections/applications/123"
+ )
+ assert context["step_revoke_completed"] is False
+ assert list(context["old_github_accounts"]) == [self.social_account_github]
+
+ @requests_mock.Mocker(kw="request")
+ @mock.patch.object(GitHubService, "remove_webhook")
+ @mock.patch.object(GitHubService, "remove_ssh_key")
+ def test_migration_page_step_migrate_one_project(
+ self, remove_ssh_key, remove_webhook, request
+ ):
+ request.get("https://api.github.com/user", status_code=200)
+
+ remove_ssh_key.return_value = True
+ remove_webhook.return_value = True
+
+ self._create_github_app_remote_repository(self.remote_repository_a)
+ self._create_github_app_remote_repository(self.remote_repository_b)
+ self._create_github_app_remote_repository(self.remote_repository_d)
+
+ response = self.client.post(
+ self.url, data={"project": self.project_with_remote_repository.slug}
+ )
+ assert response.status_code == 302
+ response = self.client.get(self.url)
+ context = response.context
+
+ assert context["step"] == "overview"
+ assert context["step_connect_completed"] is True
+ assert context["installation_target_groups"] == [
+ InstallationTargetGroup(
+ target=GitHubAccountTarget(
+ id=int(self.social_account_github.uid),
+ login="user",
+ type=GitHubAccountType.USER,
+ avatar_url=self.social_account_github.get_avatar_url(),
+ profile_url=self.social_account_github.get_profile_url(),
+ ),
+ repository_ids={3333},
+ ),
+ InstallationTargetGroup(
+ target=GitHubAccountTarget(
+ id=int(self.remote_organization.remote_id),
+ login="org",
+ type=GitHubAccountType.ORGANIZATION,
+ avatar_url=self.remote_organization.avatar_url,
+ profile_url=self.remote_organization.url,
+ ),
+ repository_ids=set(),
+ ),
+ ]
+ assert context["github_app_name"] == "readthedocs"
+ assert context["migration_targets"] == [
+ MigrationTarget(
+ project=self.project_with_remote_repository_no_admin,
+ has_installation=True,
+ is_admin=False,
+ target_id=int(self.social_account_github.uid),
+ ),
+ MigrationTarget(
+ project=self.project_with_remote_repository_no_member,
+ has_installation=False,
+ is_admin=False,
+ target_id=int(self.social_account_github.uid),
+ ),
+ MigrationTarget(
+ project=self.project_with_remote_organization,
+ has_installation=True,
+ is_admin=True,
+ target_id=int(self.remote_organization.remote_id),
+ ),
+ ]
+ assert list(context["migrated_projects"]) == [
+ self.project_with_remote_repository,
+ ]
+ assert (
+ context["old_application_link"]
+ == "https://github.com/settings/connections/applications/123"
+ )
+ assert context["step_revoke_completed"] is False
+ assert list(context["old_github_accounts"]) == [self.social_account_github]
+
+ @requests_mock.Mocker(kw="request")
+ @mock.patch.object(GitHubService, "remove_webhook")
+ @mock.patch.object(GitHubService, "remove_ssh_key")
+ def test_migration_page_step_migrate_all_projects(
+ self, remove_ssh_key, remove_webhook, request
+ ):
+ request.get("https://api.github.com/user", status_code=200)
+
+ remove_ssh_key.return_value = True
+ remove_webhook.return_value = True
+
+ self._create_github_app_remote_repository(self.remote_repository_a)
+ self._create_github_app_remote_repository(self.remote_repository_b)
+ self._create_github_app_remote_repository(self.remote_repository_d)
+
+ response = self.client.post(self.url)
+ assert response.status_code == 302
+ response = self.client.get(self.url)
+ context = response.context
+
+ assert context["step"] == "overview"
+ assert context["step_connect_completed"] is True
+ assert context["installation_target_groups"] == [
+ InstallationTargetGroup(
+ target=GitHubAccountTarget(
+ id=int(self.social_account_github.uid),
+ login="user",
+ type=GitHubAccountType.USER,
+ avatar_url=self.social_account_github.get_avatar_url(),
+ profile_url=self.social_account_github.get_profile_url(),
+ ),
+ repository_ids={3333},
+ ),
+ InstallationTargetGroup(
+ target=GitHubAccountTarget(
+ id=int(self.remote_organization.remote_id),
+ login="org",
+ type=GitHubAccountType.ORGANIZATION,
+ avatar_url=self.remote_organization.avatar_url,
+ profile_url=self.remote_organization.url,
+ ),
+ repository_ids=set(),
+ ),
+ ]
+ assert context["github_app_name"] == "readthedocs"
+ assert context["migration_targets"] == [
+ MigrationTarget(
+ project=self.project_with_remote_repository_no_admin,
+ has_installation=True,
+ is_admin=False,
+ target_id=int(self.social_account_github.uid),
+ ),
+ MigrationTarget(
+ project=self.project_with_remote_repository_no_member,
+ has_installation=False,
+ is_admin=False,
+ target_id=int(self.social_account_github.uid),
+ ),
+ ]
+ assert list(context["migrated_projects"]) == [
+ self.project_with_remote_repository,
+ self.project_with_remote_organization,
+ ]
+ assert (
+ context["old_application_link"]
+ == "https://github.com/settings/connections/applications/123"
+ )
+ assert context["step_revoke_completed"] is False
+ assert list(context["old_github_accounts"]) == [self.social_account_github]
+
+ @requests_mock.Mocker(kw="request")
+ @mock.patch.object(GitHubService, "remove_webhook")
+ @mock.patch.object(GitHubService, "remove_ssh_key")
+ def test_migration_page_step_migrate_one_project_with_errors(
+ self, remove_ssh_key, remove_webhook, request
+ ):
+ request.get("https://api.github.com/user", status_code=200)
+
+ remove_ssh_key.return_value = False
+ remove_webhook.return_value = False
+
+ self._create_github_app_remote_repository(self.remote_repository_a)
+ self._create_github_app_remote_repository(self.remote_repository_b)
+ self._create_github_app_remote_repository(self.remote_repository_d)
+
+ response = self.client.post(
+ self.url, data={"project": self.project_with_remote_repository.slug}
+ )
+ assert response.status_code == 302
+ response = self.client.get(self.url)
+ context = response.context
+
+ assert context["step"] == "overview"
+ assert context["step_connect_completed"] is True
+ assert context["installation_target_groups"] == [
+ InstallationTargetGroup(
+ target=GitHubAccountTarget(
+ id=int(self.social_account_github.uid),
+ login="user",
+ type=GitHubAccountType.USER,
+ avatar_url=self.social_account_github.get_avatar_url(),
+ profile_url=self.social_account_github.get_profile_url(),
+ ),
+ repository_ids={3333},
+ ),
+ InstallationTargetGroup(
+ target=GitHubAccountTarget(
+ id=int(self.remote_organization.remote_id),
+ login="org",
+ type=GitHubAccountType.ORGANIZATION,
+ avatar_url=self.remote_organization.avatar_url,
+ profile_url=self.remote_organization.url,
+ ),
+ repository_ids=set(),
+ ),
+ ]
+ assert context["github_app_name"] == "readthedocs"
+ assert context["migration_targets"] == [
+ MigrationTarget(
+ project=self.project_with_remote_repository_no_admin,
+ has_installation=True,
+ is_admin=False,
+ target_id=int(self.social_account_github.uid),
+ ),
+ MigrationTarget(
+ project=self.project_with_remote_repository_no_member,
+ has_installation=False,
+ is_admin=False,
+ target_id=int(self.social_account_github.uid),
+ ),
+ MigrationTarget(
+ project=self.project_with_remote_organization,
+ has_installation=True,
+ is_admin=True,
+ target_id=int(self.remote_organization.remote_id),
+ ),
+ ]
+ assert list(context["migrated_projects"]) == [
+ self.project_with_remote_repository,
+ ]
+ assert (
+ context["old_application_link"]
+ == "https://github.com/settings/connections/applications/123"
+ )
+ assert context["step_revoke_completed"] is False
+ assert list(context["old_github_accounts"]) == [self.social_account_github]
+
+ notifications = Notification.objects.for_user(self.user, self.user)
+ assert notifications.count() == 2
+ assert notifications.filter(
+ message_id=MESSAGE_OAUTH_WEBHOOK_NOT_REMOVED
+ ).exists()
+ assert notifications.filter(
+ message_id=MESSAGE_OAUTH_DEPLOY_KEY_NOT_REMOVED
+ ).exists()
+
+ @requests_mock.Mocker(kw="request")
+ def test_migration_page_step_revoke_done(self, request):
+ request.get("https://api.github.com/user", status_code=401)
+ response = self.client.get(self.url)
+ assert response.status_code == 200
+ context = response.context
+
+ assert context["step"] == "overview"
+ assert context["step_connect_completed"] is True
+ assert context["installation_target_groups"] == [
+ InstallationTargetGroup(
+ target=GitHubAccountTarget(
+ id=int(self.social_account_github.uid),
+ login="user",
+ type=GitHubAccountType.USER,
+ avatar_url=self.social_account_github.get_avatar_url(),
+ profile_url=self.social_account_github.get_profile_url(),
+ ),
+ repository_ids={1111, 2222, 3333},
+ ),
+ InstallationTargetGroup(
+ target=GitHubAccountTarget(
+ id=int(self.remote_organization.remote_id),
+ login="org",
+ type=GitHubAccountType.ORGANIZATION,
+ avatar_url=self.remote_organization.avatar_url,
+ profile_url=self.remote_organization.url,
+ ),
+ repository_ids={4444},
+ ),
+ ]
+ assert context["github_app_name"] == "readthedocs"
+ assert context["migration_targets"] == [
+ MigrationTarget(
+ project=self.project_with_remote_repository,
+ has_installation=False,
+ is_admin=False,
+ target_id=int(self.social_account_github.uid),
+ ),
+ MigrationTarget(
+ project=self.project_with_remote_repository_no_admin,
+ has_installation=False,
+ is_admin=False,
+ target_id=int(self.social_account_github.uid),
+ ),
+ MigrationTarget(
+ project=self.project_with_remote_repository_no_member,
+ has_installation=False,
+ is_admin=False,
+ target_id=int(self.social_account_github.uid),
+ ),
+ MigrationTarget(
+ project=self.project_with_remote_organization,
+ has_installation=False,
+ is_admin=False,
+ target_id=int(self.remote_organization.remote_id),
+ ),
+ ]
+ assert list(context["migrated_projects"]) == []
+ assert (
+ context["old_application_link"]
+ == "https://github.com/settings/connections/applications/123"
+ )
+ assert context["step_revoke_completed"] is True
+ assert list(context["old_github_accounts"]) == [self.social_account_github]
diff --git a/readthedocs/profiles/urls/private.py b/readthedocs/profiles/urls/private.py
index 597019549fc..f65651a1421 100644
--- a/readthedocs/profiles/urls/private.py
+++ b/readthedocs/profiles/urls/private.py
@@ -41,6 +41,11 @@
views.AccountAdvertisingEdit.as_view(),
name="account_advertising",
),
+ path(
+ "migrate-to-github-app/",
+ views.MigrateToGitHubAppView.as_view(),
+ name="migrate_to_github_app",
+ ),
]
urlpatterns += account_urls
diff --git a/readthedocs/profiles/views.py b/readthedocs/profiles/views.py
index ef51566a028..4a12718bcd5 100644
--- a/readthedocs/profiles/views.py
+++ b/readthedocs/profiles/views.py
@@ -1,7 +1,11 @@
"""Views for creating, editing and viewing site-specific user profiles."""
+from enum import StrEnum
+from enum import auto
+
from allauth.account.views import LoginView as AllAuthLoginView
from allauth.account.views import LogoutView as AllAuthLogoutView
+from allauth.socialaccount.providers.github.provider import GitHubProvider
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import logout
@@ -18,8 +22,10 @@
from vanilla import DetailView
from vanilla import FormView
from vanilla import ListView
+from vanilla import TemplateView
from vanilla import UpdateView
+from readthedocs.allauth.providers.githubapp.provider import GitHubAppProvider
from readthedocs.audit.filters import UserSecurityLogFilter
from readthedocs.audit.models import AuditLog
from readthedocs.core.forms import UserAdvertisingForm
@@ -30,6 +36,16 @@
from readthedocs.core.models import UserProfile
from readthedocs.core.permissions import AdminPermission
from readthedocs.core.utils.extend import SettingsOverrideObject
+from readthedocs.notifications.models import Notification
+from readthedocs.oauth.migrate import get_installation_target_groups_for_user
+from readthedocs.oauth.migrate import get_migrated_projects
+from readthedocs.oauth.migrate import get_migration_targets
+from readthedocs.oauth.migrate import get_old_app_link
+from readthedocs.oauth.migrate import get_valid_projects_missing_migration
+from readthedocs.oauth.migrate import migrate_project_to_github_app
+from readthedocs.oauth.notifications import MESSAGE_OAUTH_DEPLOY_KEY_NOT_REMOVED
+from readthedocs.oauth.notifications import MESSAGE_OAUTH_WEBHOOK_NOT_REMOVED
+from readthedocs.oauth.utils import is_access_revoked
from readthedocs.organizations.models import Organization
from readthedocs.projects.models import Project
from readthedocs.projects.utils import get_csv_file
@@ -278,3 +294,124 @@ def get_queryset(self):
queryset=queryset,
)
return self.filter.qs
+
+
+class MigrationSteps(StrEnum):
+ overview = auto()
+ connect = auto()
+ install = auto()
+ migrate = auto()
+ revoke = auto()
+ disconnect = auto()
+
+
+class MigrateToGitHubAppView(PrivateViewMixin, TemplateView):
+ """
+ View to help users migrate their account to the new GitHub App.
+
+ This view will guide the user through the process of migrating their account
+ and projects to the new GitHub App.
+
+ A get request will show the overview of the migration process,
+ and each step to follow. A post request will migrate a single project
+ if the project slug is provided in the request, otherwise, it will migrate
+ all projects that can be migrated.
+
+ In case we weren't able to remove the webhook or SSH key from the old GitHub App,
+ we create a notification for the user, so they can manually remove it.
+ """
+
+ template_name = "profiles/private/migrate_to_gh_app.html"
+
+ def get(self, request, *args, **kwargs):
+ if not self._get_old_github_accounts().exists():
+ if self._get_new_github_account():
+ # NOTE: TBD what to do when the user has already migrated his account,
+ # but still has projects connected to the old integration.
+ msg = _("You have already migrated your account to the new GitHub App.")
+ else:
+ msg = _("You don't have any GitHub account connected.")
+ messages.info(request, msg)
+ return HttpResponseRedirect(reverse("homepage"))
+
+ return super().get(request, *args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ step = self.request.GET.get("step", MigrationSteps.overview)
+ if step not in MigrationSteps:
+ step = MigrationSteps.overview
+ context["step"] = step
+
+ user = self.request.user
+
+ context["step_connect_completed"] = self._has_new_accounts_for_old_accounts()
+ context["installation_target_groups"] = get_installation_target_groups_for_user(user)
+ context["github_app_name"] = settings.GITHUB_APP_NAME
+ context["migration_targets"] = get_migration_targets(user)
+ context["migrated_projects"] = get_migrated_projects(user)
+ context["old_application_link"] = get_old_app_link()
+ context["step_revoke_completed"] = self._is_access_to_old_github_accounts_revoked()
+ context["old_github_accounts"] = self._get_old_github_accounts()
+ return context
+
+ def _is_access_to_old_github_accounts_revoked(self):
+ for old_account in self._get_old_github_accounts():
+ if not is_access_revoked(old_account):
+ return False
+ return True
+
+ def _has_new_accounts_for_old_accounts(self):
+ """
+ Check if the user has connected his account to the new GitHub App.
+
+ The new connected account must the same as the old one.
+ """
+ old_accounts_uid = self._get_old_github_accounts().values_list("uid", flat=True)
+ number_of_new_accounts_for_old_accounts = (
+ self.request.user.socialaccount_set.filter(
+ provider=GitHubAppProvider.id,
+ )
+ .filter(uid__in=old_accounts_uid)
+ .count()
+ )
+ return number_of_new_accounts_for_old_accounts == len(old_accounts_uid)
+
+ def _get_new_github_account(self):
+ return self.request.user.socialaccount_set.filter(provider=GitHubAppProvider.id).first()
+
+ def _get_old_github_accounts(self):
+ return self.request.user.socialaccount_set.filter(provider=GitHubProvider.id)
+
+ def post(self, request, *args, **kwargs):
+ project_slug = request.POST.get("project")
+ if project_slug:
+ projects = AdminPermission.projects(request.user, admin=True).filter(slug=project_slug)
+ else:
+ projects = get_valid_projects_missing_migration(request.user)
+
+ for project in projects:
+ result = migrate_project_to_github_app(project=project, user=request.user)
+ if not result.webhook_removed:
+ Notification.objects.add(
+ message_id=MESSAGE_OAUTH_WEBHOOK_NOT_REMOVED,
+ attached_to=request.user,
+ dismissable=True,
+ format_values={
+ "repo_full_name": project.remote_repository.full_name,
+ "project_slug": project.slug,
+ },
+ )
+ if not result.ssh_key_removed:
+ Notification.objects.add(
+ message_id=MESSAGE_OAUTH_DEPLOY_KEY_NOT_REMOVED,
+ attached_to=request.user,
+ dismissable=True,
+ format_values={
+ "repo_full_name": project.remote_repository.full_name,
+ "project_slug": project.slug,
+ },
+ )
+
+ return HttpResponseRedirect(reverse("migrate_to_github_app") + "?step=migrate")
diff --git a/readthedocs/rtd_tests/tests/test_oauth.py b/readthedocs/rtd_tests/tests/test_oauth.py
index bc741aaaee0..466f24aef7b 100644
--- a/readthedocs/rtd_tests/tests/test_oauth.py
+++ b/readthedocs/rtd_tests/tests/test_oauth.py
@@ -1,6 +1,7 @@
import copy
from unittest import mock
+from allauth.socialaccount.providers.github.provider import GitHubProvider
import requests_mock
from allauth.socialaccount.models import SocialAccount, SocialToken
from django.conf import settings
@@ -869,6 +870,9 @@ def test_get_clone_token(self, request):
assert clone_token == f"x-access-token:{token}"
+@override_settings(
+ PUBLIC_API_URL="https://app.readthedocs.org",
+)
class GitHubOAuthTests(TestCase):
fixtures = ["eric", "test_data"]
@@ -882,8 +886,17 @@ def setUp(self):
vcs_provider=GITHUB,
)
self.privacy = settings.DEFAULT_PRIVACY_LEVEL
+ self.social_github_account = get(
+ SocialAccount,
+ user=self.user,
+ provider=GitHubProvider.id,
+ )
+ get(
+ SocialToken,
+ account=self.social_github_account,
+ )
self.service = GitHubService(
- user=self.user, account=get(SocialAccount, user=self.user)
+ user=self.user, account=self.social_github_account
)
self.external_version = get(Version, project=self.project, type=EXTERNAL)
self.external_build = get(
@@ -936,6 +949,7 @@ def setUp(self):
"updated_at": "2020-08-12T14:26:39Z",
"type": "Organization",
}
+ self.api_url = "https://api.github.com"
def test_create_remote_repository(self):
repo = self.service.create_repository(
@@ -1318,8 +1332,8 @@ def test_get_provider_data_successful(self, session, mock_logger):
self.integration.save()
webhook_data = self.provider_data
- rtd_webhook_url = "https://{domain}{path}".format(
- domain=settings.PRODUCTION_DOMAIN,
+ rtd_webhook_url = "{domain}{path}".format(
+ domain=settings.PUBLIC_API_URL,
path=reverse(
"api_webhook",
kwargs={
@@ -1387,6 +1401,143 @@ def test_get_provider_data_attribute_error(self, session, mock_logger):
"GitHub webhook Listing failed for project.",
)
+ @requests_mock.Mocker(kw="request")
+ def test_remove_webhook_match_found(self, request):
+ assert self.project.repo == "https://github.com/pypa/pip"
+ assert self.project.slug == "pip"
+ request.get(
+ f"{self.api_url}/repos/pypa/pip/hooks",
+ json=[
+ {
+ "id": 1,
+ "config": {
+ "url": "https://readthedocs.org/api/v2/webhook/github/pip/1111/",
+ },
+ },
+ {
+ "id": 2,
+ "config": {
+ "url": "https://readthedocs.org/api/v2/webhook/pip/1111/",
+ },
+ },
+ {
+ "id": 3,
+ "config": {
+ "url": "https://app.readthedocs.org/api/v2/webhook/github/pip/1111/",
+ },
+ },
+ {
+ "id": 4,
+ "config": {
+ "url": "https://app.readthedocs.org/api/v2/webhook/pip/1111/",
+ },
+ },
+ {
+ "id": 5,
+ "config": {
+ "url": "https://readthedocs.org/api/v2/webhook/github/another-project/1111/",
+ },
+ },
+ {
+ "id": 6,
+ "config": {
+ "url": "https://example.com/dont-delete-me/",
+ },
+ },
+ ]
+ )
+ mock_request_deletions = [
+ request.delete(
+ f"{self.api_url}/repos/pypa/pip/hooks/1",
+ ),
+ request.delete(
+ f"{self.api_url}/repos/pypa/pip/hooks/2",
+ ),
+ request.delete(
+ f"{self.api_url}/repos/pypa/pip/hooks/3",
+ ),
+ request.delete(
+ f"{self.api_url}/repos/pypa/pip/hooks/4",
+ ),
+ ]
+ assert self.service.remove_webhook(self.project) is True
+ for mock_request_deletion in mock_request_deletions:
+ assert mock_request_deletion.called_once
+
+ @requests_mock.Mocker(kw="request")
+ def test_remove_webhook_match_found_error_to_delete(self, request):
+ assert self.project.repo == "https://github.com/pypa/pip"
+ assert self.project.slug == "pip"
+ request.get(
+ f"{self.api_url}/repos/pypa/pip/hooks",
+ json=[
+ {
+ "id": 1,
+ "config": {
+ "url": "https://readthedocs.org/api/v2/webhook/github/pip/1111/",
+ },
+ },
+ {
+ "id": 2,
+ "config": {
+ "url": "https://readthedocs.org/api/v2/webhook/pip/1111/",
+ },
+ },
+ {
+ "id": 3,
+ "config": {
+ "url": "https://app.readthedocs.org/api/v2/webhook/github/pip/1111/",
+ },
+ },
+ {
+ "id": 4,
+ "config": {
+ "url": "https://app.readthedocs.org/api/v2/webhook/pip/1111/",
+ },
+ },
+ {
+ "id": 5,
+ "config": {
+ "url": "https://readthedocs.org/api/v2/webhook/github/another-project/1111/",
+ },
+ },
+ {
+ "id": 6,
+ "config": {
+ "url": "https://example.com/dont-delete-me/",
+ },
+ },
+ ]
+ )
+ mock_request_deletion = request.delete(
+ f"{self.api_url}/repos/pypa/pip/hooks/1",
+ status_code=401,
+ )
+ assert self.service.remove_webhook(self.project) is False
+ assert mock_request_deletion.called_once
+
+ @requests_mock.Mocker(kw="request")
+ def test_remove_webhook_match_not_found(self, request):
+ assert self.project.repo == "https://github.com/pypa/pip"
+ assert self.project.slug == "pip"
+ request.get(
+ f"{self.api_url}/repos/pypa/pip/hooks",
+ json=[
+ {
+ "id": 1,
+ "config": {
+ "url": "https://readthedocs.org/api/v2/webhook/github/another-project/1111/",
+ },
+ },
+ {
+ "id": 2,
+ "config": {
+ "url": "https://example.com/dont-delete-me/",
+ },
+ },
+ ]
+ )
+ assert self.service.remove_webhook(self.project) is True
class BitbucketOAuthTests(TestCase):
fixtures = ["eric", "test_data"]
diff --git a/readthedocs/rtd_tests/tests/test_privacy_urls.py b/readthedocs/rtd_tests/tests/test_privacy_urls.py
index 54319deb9c8..c84e9613cac 100644
--- a/readthedocs/rtd_tests/tests/test_privacy_urls.py
+++ b/readthedocs/rtd_tests/tests/test_privacy_urls.py
@@ -546,6 +546,8 @@ def setUp(self):
self.response_data.update(
{
"/accounts/login/": {"status_code": 302},
+ # The test user doesn't have a GitHub account, so it's redirected to the home page.
+ "/accounts/migrate-to-github-app/": {"status_code": 302},
}
)
@@ -562,6 +564,8 @@ def setUp(self):
self.response_data.update(
{
"/accounts/login/": {"status_code": 302},
+ # The test user doesn't have a GitHub account, so it's redirected to the home page.
+ "/accounts/migrate-to-github-app/": {"status_code": 302},
}
)