Skip to content

Projects: create integration for GitHub App projects #12322

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jul 16, 2025
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions readthedocs/integrations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions readthedocs/oauth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
]
28 changes: 28 additions & 0 deletions readthedocs/projects/signals.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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()
47 changes: 47 additions & 0 deletions readthedocs/projects/tests/test_signals.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 9 additions & 2 deletions readthedocs/projects/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand Down