diff --git a/readthedocs/integrations/models.py b/readthedocs/integrations/models.py index f193b3e0c8c..2f0d71dc666 100644 --- a/readthedocs/integrations/models.py +++ b/readthedocs/integrations/models.py @@ -3,8 +3,8 @@ import json import re import uuid +from dataclasses import dataclass -from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.models import ContentType @@ -351,7 +351,27 @@ def can_sync(self): return False +@dataclass +class GitHubAppIntegrationProviderData: + installation_id: int + repository_id: int + repository_full_name: str + + class GitHubAppIntegration(Integration): + """ + Dummy integration for GitHub App projects. + + This is a proxy model for the Integration model, which is used to + represent GitHub App integrations in the UI. + + This integration is automatically created when a project is linked to a + remote repository from a GitHub App installation, and it remains + associated with the project even if the remote repository is removed. + + The `provider_data` field is a JSON representation of the `GitHubAppIntegrationProviderData` class. + """ + integration_type_id = Integration.GITHUBAPP has_sync = False is_remote_only = True @@ -372,11 +392,9 @@ def get_absolute_url(self) -> str | None: # using. We might want to store this on the model later so a repository # that is removed from the installation can still link to the # installation the project was _previously_ using. - try: - installation_id = self.project.remote_repository.github_app_installation.installation_id - return f"https://github.com/apps/{settings.GITHUB_APP_NAME}/installations/{installation_id}" - except AttributeError: - return None + if self.project.is_github_app_project: + return self.project.remote_repository.github_app_installation.url + return None @property def is_active(self) -> bool: diff --git a/readthedocs/oauth/models.py b/readthedocs/oauth/models.py index 4753410de64..5cf93bb7d6d 100644 --- a/readthedocs/oauth/models.py +++ b/readthedocs/oauth/models.py @@ -4,6 +4,7 @@ import structlog from allauth.socialaccount.models import SocialAccount +from django.conf import settings from django.contrib.auth.models import User from django.core.validators import URLValidator from django.db import models @@ -94,6 +95,11 @@ def service(self): return GitHubAppService(self) + @property + def url(self): + """Return the URL to the GitHub App installation page.""" + return f"https://github.com/apps/{settings.GITHUB_APP_NAME}/installations/{self.installation_id}" + def delete(self, *args, **kwargs): """Override delete method to remove orphaned remote organizations.""" self.delete_repositories() diff --git a/readthedocs/projects/migrations/0152_create_gh_app_integration.py b/readthedocs/projects/migrations/0152_create_gh_app_integration.py new file mode 100644 index 00000000000..3806b81c286 --- /dev/null +++ b/readthedocs/projects/migrations/0152_create_gh_app_integration.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.3 on 2025-07-14 20:18 +from django.db import migrations +from django_safemigrate import Safe + + +def forwards_func(apps, schema_editor): + """Create GitHub App integration for projects connected to a GitHub App.""" + Project = apps.get_model("projects", "Project") + for project in Project.objects.filter(remote_repository__vcs_provider="githubapp"): + integration, _ = project.integrations.get_or_create( + integration_type="githubapp", + ) + remote_repo = project.remote_repository + installation = project.remote_repository.github_app_installation + integration.provider_data = { + "installation_id": installation.installation_id, + "repository_id": remote_repo.remote_id, + "repository_full_name": remote_repo.full_name, + } + integration.save() + + +class Migration(migrations.Migration): + safe = Safe.after_deploy() + dependencies = [ + ("projects", "0151_addons_linkpreviews_selector"), + ] + + operations = [ + migrations.RunPython(forwards_func), + ] diff --git a/readthedocs/projects/signals.py b/readthedocs/projects/signals.py index e27fc7256f5..cdb8dda1cf5 100644 --- a/readthedocs/projects/signals.py +++ b/readthedocs/projects/signals.py @@ -1,10 +1,14 @@ """Project signals.""" +from dataclasses import asdict + import django.dispatch import structlog from django.db.models.signals import post_save from django.dispatch import receiver +from readthedocs.integrations.models import GitHubAppIntegrationProviderData +from readthedocs.integrations.models import Integration from readthedocs.projects.models import AddonsConfig from readthedocs.projects.models import Project @@ -27,3 +31,27 @@ def create_addons_on_new_projects(instance, *args, **kwargs): """Create ``AddonsConfig`` on new projects.""" AddonsConfig.objects.get_or_create(project=instance) + + +@receiver(post_save, sender=Project) +def create_integration_on_github_app_project(instance, *args, **kwargs): + """Create a GitHub App integration when a project is linked to a GitHub App.""" + project = instance + if not project.is_github_app_project: + return + + integration, _ = project.integrations.get_or_create( + integration_type=Integration.GITHUBAPP, + ) + # Save some metadata about the GitHub App installation and repository, + # so we can know which repository the project was linked to. + remote_repo = project.remote_repository + installation = project.remote_repository.github_app_installation + integration.provider_data = asdict( + GitHubAppIntegrationProviderData( + installation_id=installation.installation_id, + repository_id=int(remote_repo.remote_id), + repository_full_name=remote_repo.full_name, + ) + ) + integration.save() diff --git a/readthedocs/projects/tests/test_signals.py b/readthedocs/projects/tests/test_signals.py new file mode 100644 index 00000000000..5e80cce24e3 --- /dev/null +++ b/readthedocs/projects/tests/test_signals.py @@ -0,0 +1,47 @@ +from django.contrib.auth.models import User +from django.test import TestCase +from django_dynamic_fixture import get +from readthedocs.integrations.models import Integration +from readthedocs.oauth.constants import GITHUB, GITHUB_APP +from readthedocs.oauth.models import GitHubAppInstallation, RemoteRepository +from readthedocs.projects.models import Project + + +class TestProjectSignals(TestCase): + def setUp(self): + self.user = get(User) + self.project = get(Project, users=[self.user]) + + def test_create_github_app_integration(self): + github_repo = get( + RemoteRepository, + vcs_provider=GITHUB, + ) + github_app_repo = get( + RemoteRepository, + vcs_provider=GITHUB_APP, + github_app_installation=get(GitHubAppInstallation) + ) + + assert not self.project.is_github_app_project + assert not self.project.integrations.exists() + + # Not a GitHub App repository, no integration created. + self.project.remote_repository = github_repo + self.project.save() + assert not self.project.is_github_app_project + assert not self.project.integrations.exists() + + # Now set the remote repository to a GitHub App repository. + self.project.remote_repository = github_app_repo + self.project.save() + assert self.project.is_github_app_project + integration = self.project.integrations.first() + assert integration.integration_type == Integration.GITHUBAPP + + # Even if the connection is removed, the integration should still exist. + self.project.remote_repository = None + self.project.save() + assert not self.project.is_github_app_project + integration = self.project.integrations.first() + assert integration.integration_type == Integration.GITHUBAPP diff --git a/readthedocs/projects/tests/test_views.py b/readthedocs/projects/tests/test_views.py index 0bb45b79dca..7611ae99604 100644 --- a/readthedocs/projects/tests/test_views.py +++ b/readthedocs/projects/tests/test_views.py @@ -8,7 +8,7 @@ from readthedocs.integrations.models import Integration from readthedocs.invitations.models import Invitation from readthedocs.oauth.constants import GITHUB_APP -from readthedocs.oauth.models import RemoteRepository +from readthedocs.oauth.models import GitHubAppInstallation, RemoteRepository from readthedocs.organizations.models import Organization from readthedocs.projects.constants import ( DOWNLOADABLE_MEDIA_TYPES, @@ -85,7 +85,14 @@ def test_github_integration(self): def test_github_app_integration(self): Integration.objects.all().delete() - remote_repository = get(RemoteRepository, vcs_provider=GITHUB_APP) + github_app_installation = get( + GitHubAppInstallation, + ) + remote_repository = get( + RemoteRepository, + vcs_provider=GITHUB_APP, + github_app_installation=github_app_installation, + ) self.project.remote_repository = remote_repository self.project.save()