Skip to content

Commit 1423438

Browse files
stsewdhumitos
andauthored
Projects: create integration for GitHub App projects (#12322)
I guess there is the question about how/when to delete these... I think if the user disconnects the project manually (selecting None on the connected repo option), then we should delete the integration, we could also enable the delete button for the integration as well, and that will delete the integration and also set the project's remote repo to None. --------- Co-authored-by: Manuel Kaufmann <humitos@gmail.com>
1 parent 4c90340 commit 1423438

File tree

6 files changed

+145
-8
lines changed

6 files changed

+145
-8
lines changed

readthedocs/integrations/models.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import json
44
import re
55
import uuid
6+
from dataclasses import dataclass
67

7-
from django.conf import settings
88
from django.contrib.contenttypes.fields import GenericForeignKey
99
from django.contrib.contenttypes.fields import GenericRelation
1010
from django.contrib.contenttypes.models import ContentType
@@ -351,7 +351,27 @@ def can_sync(self):
351351
return False
352352

353353

354+
@dataclass
355+
class GitHubAppIntegrationProviderData:
356+
installation_id: int
357+
repository_id: int
358+
repository_full_name: str
359+
360+
354361
class GitHubAppIntegration(Integration):
362+
"""
363+
Dummy integration for GitHub App projects.
364+
365+
This is a proxy model for the Integration model, which is used to
366+
represent GitHub App integrations in the UI.
367+
368+
This integration is automatically created when a project is linked to a
369+
remote repository from a GitHub App installation, and it remains
370+
associated with the project even if the remote repository is removed.
371+
372+
The `provider_data` field is a JSON representation of the `GitHubAppIntegrationProviderData` class.
373+
"""
374+
355375
integration_type_id = Integration.GITHUBAPP
356376
has_sync = False
357377
is_remote_only = True
@@ -372,11 +392,9 @@ def get_absolute_url(self) -> str | None:
372392
# using. We might want to store this on the model later so a repository
373393
# that is removed from the installation can still link to the
374394
# installation the project was _previously_ using.
375-
try:
376-
installation_id = self.project.remote_repository.github_app_installation.installation_id
377-
return f"https://github.com/apps/{settings.GITHUB_APP_NAME}/installations/{installation_id}"
378-
except AttributeError:
379-
return None
395+
if self.project.is_github_app_project:
396+
return self.project.remote_repository.github_app_installation.url
397+
return None
380398

381399
@property
382400
def is_active(self) -> bool:

readthedocs/oauth/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import structlog
66
from allauth.socialaccount.models import SocialAccount
7+
from django.conf import settings
78
from django.contrib.auth.models import User
89
from django.core.validators import URLValidator
910
from django.db import models
@@ -94,6 +95,11 @@ def service(self):
9495

9596
return GitHubAppService(self)
9697

98+
@property
99+
def url(self):
100+
"""Return the URL to the GitHub App installation page."""
101+
return f"https://github.com/apps/{settings.GITHUB_APP_NAME}/installations/{self.installation_id}"
102+
97103
def delete(self, *args, **kwargs):
98104
"""Override delete method to remove orphaned remote organizations."""
99105
self.delete_repositories()
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Generated by Django 5.2.3 on 2025-07-14 20:18
2+
from django.db import migrations
3+
from django_safemigrate import Safe
4+
5+
6+
def forwards_func(apps, schema_editor):
7+
"""Create GitHub App integration for projects connected to a GitHub App."""
8+
Project = apps.get_model("projects", "Project")
9+
for project in Project.objects.filter(remote_repository__vcs_provider="githubapp"):
10+
integration, _ = project.integrations.get_or_create(
11+
integration_type="githubapp",
12+
)
13+
remote_repo = project.remote_repository
14+
installation = project.remote_repository.github_app_installation
15+
integration.provider_data = {
16+
"installation_id": installation.installation_id,
17+
"repository_id": remote_repo.remote_id,
18+
"repository_full_name": remote_repo.full_name,
19+
}
20+
integration.save()
21+
22+
23+
class Migration(migrations.Migration):
24+
safe = Safe.after_deploy()
25+
dependencies = [
26+
("projects", "0151_addons_linkpreviews_selector"),
27+
]
28+
29+
operations = [
30+
migrations.RunPython(forwards_func),
31+
]

readthedocs/projects/signals.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
"""Project signals."""
22

3+
from dataclasses import asdict
4+
35
import django.dispatch
46
import structlog
57
from django.db.models.signals import post_save
68
from django.dispatch import receiver
79

10+
from readthedocs.integrations.models import GitHubAppIntegrationProviderData
11+
from readthedocs.integrations.models import Integration
812
from readthedocs.projects.models import AddonsConfig
913
from readthedocs.projects.models import Project
1014

@@ -27,3 +31,27 @@
2731
def create_addons_on_new_projects(instance, *args, **kwargs):
2832
"""Create ``AddonsConfig`` on new projects."""
2933
AddonsConfig.objects.get_or_create(project=instance)
34+
35+
36+
@receiver(post_save, sender=Project)
37+
def create_integration_on_github_app_project(instance, *args, **kwargs):
38+
"""Create a GitHub App integration when a project is linked to a GitHub App."""
39+
project = instance
40+
if not project.is_github_app_project:
41+
return
42+
43+
integration, _ = project.integrations.get_or_create(
44+
integration_type=Integration.GITHUBAPP,
45+
)
46+
# Save some metadata about the GitHub App installation and repository,
47+
# so we can know which repository the project was linked to.
48+
remote_repo = project.remote_repository
49+
installation = project.remote_repository.github_app_installation
50+
integration.provider_data = asdict(
51+
GitHubAppIntegrationProviderData(
52+
installation_id=installation.installation_id,
53+
repository_id=int(remote_repo.remote_id),
54+
repository_full_name=remote_repo.full_name,
55+
)
56+
)
57+
integration.save()
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from django.contrib.auth.models import User
2+
from django.test import TestCase
3+
from django_dynamic_fixture import get
4+
from readthedocs.integrations.models import Integration
5+
from readthedocs.oauth.constants import GITHUB, GITHUB_APP
6+
from readthedocs.oauth.models import GitHubAppInstallation, RemoteRepository
7+
from readthedocs.projects.models import Project
8+
9+
10+
class TestProjectSignals(TestCase):
11+
def setUp(self):
12+
self.user = get(User)
13+
self.project = get(Project, users=[self.user])
14+
15+
def test_create_github_app_integration(self):
16+
github_repo = get(
17+
RemoteRepository,
18+
vcs_provider=GITHUB,
19+
)
20+
github_app_repo = get(
21+
RemoteRepository,
22+
vcs_provider=GITHUB_APP,
23+
github_app_installation=get(GitHubAppInstallation)
24+
)
25+
26+
assert not self.project.is_github_app_project
27+
assert not self.project.integrations.exists()
28+
29+
# Not a GitHub App repository, no integration created.
30+
self.project.remote_repository = github_repo
31+
self.project.save()
32+
assert not self.project.is_github_app_project
33+
assert not self.project.integrations.exists()
34+
35+
# Now set the remote repository to a GitHub App repository.
36+
self.project.remote_repository = github_app_repo
37+
self.project.save()
38+
assert self.project.is_github_app_project
39+
integration = self.project.integrations.first()
40+
assert integration.integration_type == Integration.GITHUBAPP
41+
42+
# Even if the connection is removed, the integration should still exist.
43+
self.project.remote_repository = None
44+
self.project.save()
45+
assert not self.project.is_github_app_project
46+
integration = self.project.integrations.first()
47+
assert integration.integration_type == Integration.GITHUBAPP

readthedocs/projects/tests/test_views.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from readthedocs.integrations.models import Integration
99
from readthedocs.invitations.models import Invitation
1010
from readthedocs.oauth.constants import GITHUB_APP
11-
from readthedocs.oauth.models import RemoteRepository
11+
from readthedocs.oauth.models import GitHubAppInstallation, RemoteRepository
1212
from readthedocs.organizations.models import Organization
1313
from readthedocs.projects.constants import (
1414
DOWNLOADABLE_MEDIA_TYPES,
@@ -85,7 +85,14 @@ def test_github_integration(self):
8585

8686
def test_github_app_integration(self):
8787
Integration.objects.all().delete()
88-
remote_repository = get(RemoteRepository, vcs_provider=GITHUB_APP)
88+
github_app_installation = get(
89+
GitHubAppInstallation,
90+
)
91+
remote_repository = get(
92+
RemoteRepository,
93+
vcs_provider=GITHUB_APP,
94+
github_app_installation=github_app_installation,
95+
)
8996
self.project.remote_repository = remote_repository
9097
self.project.save()
9198

0 commit comments

Comments
 (0)