From c761af67c2b11275df20d5f1f54115cd71d4d0c9 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Mon, 14 Jul 2025 15:56:32 -0500 Subject: [PATCH 1/7] Projects: create integration for GitHub App projects --- readthedocs/integrations/models.py | 4 +- readthedocs/oauth/models.py | 6 +++ .../0151_create_gh_app_integration.py | 31 ++++++++++++ readthedocs/projects/signals.py | 23 +++++++++ readthedocs/projects/tests/test_signals.py | 47 +++++++++++++++++++ readthedocs/projects/tests/test_views.py | 7 ++- 6 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 readthedocs/projects/migrations/0151_create_gh_app_integration.py create mode 100644 readthedocs/projects/tests/test_signals.py diff --git a/readthedocs/integrations/models.py b/readthedocs/integrations/models.py index f193b3e0c8c..ca3c4c39786 100644 --- a/readthedocs/integrations/models.py +++ b/readthedocs/integrations/models.py @@ -4,7 +4,6 @@ import re import uuid -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 @@ -373,8 +372,7 @@ def get_absolute_url(self) -> str | None: # 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}" + return self.project.remote_repository.github_app_installation.html_url except AttributeError: return None diff --git a/readthedocs/oauth/models.py b/readthedocs/oauth/models.py index 4753410de64..e2219810b09 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 html_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/0151_create_gh_app_integration.py b/readthedocs/projects/migrations/0151_create_gh_app_integration.py new file mode 100644 index 00000000000..2b63549d0f3 --- /dev/null +++ b/readthedocs/projects/migrations/0151_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", "0150_add_ssh_key_with_write_access"), + ] + + operations = [ + migrations.RunPython(forwards_func), + ] diff --git a/readthedocs/projects/signals.py b/readthedocs/projects/signals.py index e27fc7256f5..c7fe00626ba 100644 --- a/readthedocs/projects/signals.py +++ b/readthedocs/projects/signals.py @@ -5,6 +5,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver +from readthedocs.integrations.models import Integration from readthedocs.projects.models import AddonsConfig from readthedocs.projects.models import Project @@ -27,3 +28,25 @@ 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 use it to guide the user if our apps loses access to the repository. + 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() 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..5e4031f09b9 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,10 @@ 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() From e9eda5e74ab925dfbce500ad58b60e547605c75b Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Mon, 14 Jul 2025 16:17:29 -0500 Subject: [PATCH 2/7] Always link to app --- readthedocs/integrations/models.py | 33 ++++++++++++++++++------ readthedocs/projects/tests/test_views.py | 6 ++++- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/readthedocs/integrations/models.py b/readthedocs/integrations/models.py index ca3c4c39786..ebb165597f7 100644 --- a/readthedocs/integrations/models.py +++ b/readthedocs/integrations/models.py @@ -4,6 +4,7 @@ import re import uuid +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,6 +352,23 @@ def can_sync(self): 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 has the following information: + + - installation_id + - repository_id + - repository_full_name + """ + integration_type_id = Integration.GITHUBAPP has_sync = False is_remote_only = True @@ -365,16 +383,15 @@ def get_absolute_url(self) -> str | None: Instead of showing a link to the integration details page, for GHA projects we show a link in the UI to the GHA installation page for the installation used by the project. + + If the project was disconnected from the remote repository, + we can't link to the installation page, but we can still link + to the GitHub App page, from where the user can install the app + into the correct account. """ - # If the GHA is disconnected we'll disonnect the remote repository and - # so we won't have a URL to the installation page the project should be - # 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: + if self.project.is_github_app_project: return self.project.remote_repository.github_app_installation.html_url - except AttributeError: - return None + return f"https://github.com/apps/{settings.GITHUB_APP_NAME}/installations/new/" @property def is_active(self) -> bool: diff --git a/readthedocs/projects/tests/test_views.py b/readthedocs/projects/tests/test_views.py index 5e4031f09b9..7611ae99604 100644 --- a/readthedocs/projects/tests/test_views.py +++ b/readthedocs/projects/tests/test_views.py @@ -88,7 +88,11 @@ def test_github_app_integration(self): github_app_installation = get( GitHubAppInstallation, ) - remote_repository = get(RemoteRepository, vcs_provider=GITHUB_APP, github_app_installation=github_app_installation) + remote_repository = get( + RemoteRepository, + vcs_provider=GITHUB_APP, + github_app_installation=github_app_installation, + ) self.project.remote_repository = remote_repository self.project.save() From b1f66486676d2de6e0f3c874827e86a8c5be94a2 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Mon, 14 Jul 2025 16:22:09 -0500 Subject: [PATCH 3/7] Don't link, that would be confusing --- readthedocs/integrations/models.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/readthedocs/integrations/models.py b/readthedocs/integrations/models.py index ebb165597f7..844c87e13f6 100644 --- a/readthedocs/integrations/models.py +++ b/readthedocs/integrations/models.py @@ -4,7 +4,6 @@ import re import uuid -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 @@ -383,15 +382,15 @@ def get_absolute_url(self) -> str | None: Instead of showing a link to the integration details page, for GHA projects we show a link in the UI to the GHA installation page for the installation used by the project. - - If the project was disconnected from the remote repository, - we can't link to the installation page, but we can still link - to the GitHub App page, from where the user can install the app - into the correct account. """ + # If the GHA is disconnected we'll disonnect the remote repository and + # so we won't have a URL to the installation page the project should be + # 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. if self.project.is_github_app_project: return self.project.remote_repository.github_app_installation.html_url - return f"https://github.com/apps/{settings.GITHUB_APP_NAME}/installations/new/" + return None @property def is_active(self) -> bool: From 042ffd34ed14afa3ba315d62781524d03b8fb3e1 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Mon, 14 Jul 2025 16:29:33 -0500 Subject: [PATCH 4/7] This isn't for linking --- readthedocs/projects/signals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readthedocs/projects/signals.py b/readthedocs/projects/signals.py index c7fe00626ba..89048a0f114 100644 --- a/readthedocs/projects/signals.py +++ b/readthedocs/projects/signals.py @@ -41,7 +41,7 @@ def create_integration_on_github_app_project(instance, *args, **kwargs): integration_type=Integration.GITHUBAPP, ) # Save some metadata about the GitHub App installation and repository, - # so we can use it to guide the user if our apps loses access to the 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 = { From adcca9bf70f5f96acf561df2b9e12bf5aba61c09 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Tue, 15 Jul 2025 13:36:12 -0500 Subject: [PATCH 5/7] Updates from review --- readthedocs/integrations/models.py | 16 ++++++++++------ readthedocs/oauth/models.py | 2 +- readthedocs/projects/signals.py | 15 ++++++++++----- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/readthedocs/integrations/models.py b/readthedocs/integrations/models.py index 844c87e13f6..7bbbc73c5b6 100644 --- a/readthedocs/integrations/models.py +++ b/readthedocs/integrations/models.py @@ -3,6 +3,7 @@ import json import re import uuid +from dataclasses import dataclass from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericRelation @@ -350,6 +351,13 @@ 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. @@ -361,11 +369,7 @@ class GitHubAppIntegration(Integration): 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 has the following information: - - - installation_id - - repository_id - - repository_full_name + The provider_data field is a JSON representation of the GitHubAppIntegrationProviderData class. """ integration_type_id = Integration.GITHUBAPP @@ -389,7 +393,7 @@ def get_absolute_url(self) -> str | None: # that is removed from the installation can still link to the # installation the project was _previously_ using. if self.project.is_github_app_project: - return self.project.remote_repository.github_app_installation.html_url + return self.project.remote_repository.github_app_installation.url return None @property diff --git a/readthedocs/oauth/models.py b/readthedocs/oauth/models.py index e2219810b09..5cf93bb7d6d 100644 --- a/readthedocs/oauth/models.py +++ b/readthedocs/oauth/models.py @@ -96,7 +96,7 @@ def service(self): return GitHubAppService(self) @property - def html_url(self): + 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}" diff --git a/readthedocs/projects/signals.py b/readthedocs/projects/signals.py index 89048a0f114..cdb8dda1cf5 100644 --- a/readthedocs/projects/signals.py +++ b/readthedocs/projects/signals.py @@ -1,10 +1,13 @@ """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 @@ -44,9 +47,11 @@ def create_integration_on_github_app_project(instance, *args, **kwargs): # 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 = { - "installation_id": installation.installation_id, - "repository_id": remote_repo.remote_id, - "repository_full_name": remote_repo.full_name, - } + 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() From 1e7acd7f966a5c4c7cb6a0cf209d49ed10b829d2 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Tue, 15 Jul 2025 13:58:59 -0500 Subject: [PATCH 6/7] Fix migration --- ..._gh_app_integration.py => 0152_create_gh_app_integration.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename readthedocs/projects/migrations/{0151_create_gh_app_integration.py => 0152_create_gh_app_integration.py} (94%) diff --git a/readthedocs/projects/migrations/0151_create_gh_app_integration.py b/readthedocs/projects/migrations/0152_create_gh_app_integration.py similarity index 94% rename from readthedocs/projects/migrations/0151_create_gh_app_integration.py rename to readthedocs/projects/migrations/0152_create_gh_app_integration.py index 2b63549d0f3..3806b81c286 100644 --- a/readthedocs/projects/migrations/0151_create_gh_app_integration.py +++ b/readthedocs/projects/migrations/0152_create_gh_app_integration.py @@ -23,7 +23,7 @@ def forwards_func(apps, schema_editor): class Migration(migrations.Migration): safe = Safe.after_deploy() dependencies = [ - ("projects", "0150_add_ssh_key_with_write_access"), + ("projects", "0151_addons_linkpreviews_selector"), ] operations = [ From deed4e215c44115f317c3461303173f14093c5c5 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Wed, 16 Jul 2025 10:52:49 -0500 Subject: [PATCH 7/7] Update readthedocs/integrations/models.py Co-authored-by: Manuel Kaufmann --- readthedocs/integrations/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readthedocs/integrations/models.py b/readthedocs/integrations/models.py index 7bbbc73c5b6..2f0d71dc666 100644 --- a/readthedocs/integrations/models.py +++ b/readthedocs/integrations/models.py @@ -369,7 +369,7 @@ class GitHubAppIntegration(Integration): 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. + The `provider_data` field is a JSON representation of the `GitHubAppIntegrationProviderData` class. """ integration_type_id = Integration.GITHUBAPP