Skip to content

Commit 5e04368

Browse files
authored
Add GitHub App integration type (#12273)
I'm treating the GHA integration as a remote integration, with just enough configuration to display something in the UI. I just stubbed in the handling of data from the GHA for now. - Refs #12130 - Refs readthedocs/common#276 - Refs readthedocs/ext-theme#617
1 parent ea6e1b6 commit 5e04368

File tree

5 files changed

+127
-3
lines changed

5 files changed

+127
-3
lines changed

common

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Generated by Django 5.2.1 on 2025-06-30 23:10
2+
3+
from django.db import migrations
4+
from django.db import models
5+
from django_safemigrate import Safe
6+
7+
8+
class Migration(migrations.Migration):
9+
safe = Safe.always()
10+
11+
dependencies = [
12+
("integrations", "0014_add_index_speedup"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="GitHubAppIntegration",
18+
fields=[],
19+
options={
20+
"proxy": True,
21+
"indexes": [],
22+
"constraints": [],
23+
},
24+
bases=("integrations.integration",),
25+
),
26+
migrations.AlterField(
27+
model_name="integration",
28+
name="integration_type",
29+
field=models.CharField(
30+
choices=[
31+
("github_webhook", "GitHub incoming webhook"),
32+
("bitbucket_webhook", "Bitbucket incoming webhook"),
33+
("gitlab_webhook", "GitLab incoming webhook"),
34+
("api_webhook", "Generic API incoming webhook"),
35+
("githubapp", "GitHub App"),
36+
],
37+
max_length=32,
38+
verbose_name="Integration type",
39+
),
40+
),
41+
]

readthedocs/integrations/models.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
import re
55
import uuid
66

7+
from django.conf import settings
78
from django.contrib.contenttypes.fields import GenericForeignKey
89
from django.contrib.contenttypes.fields import GenericRelation
910
from django.contrib.contenttypes.models import ContentType
1011
from django.db import models
1112
from django.db import transaction
13+
from django.urls import reverse
1214
from django.utils.crypto import get_random_string
1315
from django.utils.safestring import mark_safe
1416
from django.utils.translation import gettext_lazy as _
@@ -240,7 +242,16 @@ def get(self, *args, **kwargs):
240242
original = super().get(*args, **kwargs)
241243
return self._get_subclass_replacement(original)
242244

243-
def subclass(self, instance):
245+
def subclass(self, instance=None):
246+
"""
247+
Return a subclass or list of subclasses integrations.
248+
249+
If an instance was passed in, return a single subclasses integration
250+
instance. If this is a queryset or manager, render the list as a list
251+
using the integration subsclasses.
252+
"""
253+
if instance is None:
254+
return [self._get_subclass_replacement(_instance) for _instance in self]
244255
return self._get_subclass_replacement(instance)
245256

246257
def create(self, **kwargs):
@@ -263,6 +274,7 @@ def create(self, **kwargs):
263274
class Integration(TimeStampedModel):
264275
"""Inbound webhook integration for projects."""
265276

277+
GITHUBAPP = "githubapp"
266278
GITHUB_WEBHOOK = "github_webhook"
267279
BITBUCKET_WEBHOOK = "bitbucket_webhook"
268280
GITLAB_WEBHOOK = "gitlab_webhook"
@@ -275,7 +287,9 @@ class Integration(TimeStampedModel):
275287
(API_WEBHOOK, _("Generic API incoming webhook")),
276288
)
277289

278-
INTEGRATIONS = WEBHOOK_INTEGRATIONS
290+
REMOTE_ONLY_INTEGRATIONS = ((GITHUBAPP, _("GitHub App")),)
291+
292+
INTEGRATIONS = WEBHOOK_INTEGRATIONS + REMOTE_ONLY_INTEGRATIONS
279293

280294
project = models.ForeignKey(
281295
Project,
@@ -307,6 +321,8 @@ class Integration(TimeStampedModel):
307321

308322
# Integration attributes
309323
has_sync = False
324+
is_remote_only = False
325+
is_active = True
310326

311327
def __str__(self):
312328
return self.get_integration_type_display()
@@ -316,6 +332,9 @@ def save(self, *args, **kwargs):
316332
self.secret = get_random_string(length=32)
317333
super().save(*args, **kwargs)
318334

335+
def get_absolute_url(self) -> str:
336+
return reverse("projects_integrations_detail", args=(self.project.slug, self.pk))
337+
319338

320339
class GitHubWebhook(Integration):
321340
integration_type_id = Integration.GITHUB_WEBHOOK
@@ -332,6 +351,47 @@ def can_sync(self):
332351
return False
333352

334353

354+
class GitHubAppIntegration(Integration):
355+
integration_type_id = Integration.GITHUBAPP
356+
has_sync = False
357+
is_remote_only = True
358+
359+
class Meta:
360+
proxy = True
361+
362+
def get_absolute_url(self) -> str | None:
363+
"""
364+
Get URL of the GHA installation page.
365+
366+
Instead of showing a link to the integration details page, for GHA
367+
projects we show a link in the UI to the GHA installation page for the
368+
installation used by the project.
369+
"""
370+
# If the GHA is disconnected we'll disonnect the remote repository and
371+
# so we won't have a URL to the installation page the project should be
372+
# using. We might want to store this on the model later so a repository
373+
# that is removed from the installation can still link to the
374+
# 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
380+
381+
@property
382+
def is_active(self) -> bool:
383+
"""
384+
Is the GHA connection active for this project?
385+
386+
This assumes that the status of the GHA connect will be reflected as
387+
soon as there is an event that might disconnect the GHA on GitHub's
388+
side -- uninstalling the app or revoking permission to the repository.
389+
We listen for these events and should disconnect the remote
390+
repository, but would leave this integration.
391+
"""
392+
return self.project.is_github_app_project
393+
394+
335395
class BitbucketWebhook(Integration):
336396
integration_type_id = Integration.BITBUCKET_WEBHOOK
337397
has_sync = True

readthedocs/projects/views/private.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -910,8 +910,19 @@ def get_queryset(self):
910910
return self.get_integration_queryset()
911911

912912
def get_object(self):
913+
integration = self.get_integration()
914+
# Don't allow an integration detail page if the integration subclass
915+
# does not support configuration
916+
if integration.is_remote_only:
917+
raise Http404
913918
return self.get_integration()
914919

920+
def get_context_data(self, **kwargs):
921+
context = super().get_context_data(**kwargs)
922+
if "object_list" in context:
923+
context["subclassed_object_list"] = context["object_list"].subclass()
924+
return context
925+
915926
def get_integration_queryset(self):
916927
self.project = self.get_project()
917928
return self.model.objects.filter(project=self.project)

readthedocs/rtd_tests/tests/test_oauth.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
LATEST,
2121
)
2222
from readthedocs.builds.models import Build, Version
23+
from readthedocs.integrations.models import GitHubAppIntegration
2324
from readthedocs.integrations.models import GitHubWebhook, GitLabWebhook
2425
from readthedocs.oauth.constants import BITBUCKET, GITHUB, GITHUB_APP, GITLAB
2526
from readthedocs.oauth.models import (
@@ -78,6 +79,10 @@ def setUp(self):
7879
self.project = get(
7980
Project, users=[self.user], remote_repository=self.remote_repository
8081
)
82+
self.integration = get(
83+
GitHubAppIntegration,
84+
project=self.project,
85+
)
8186

8287
self.remote_organization = get(
8388
RemoteOrganization,
@@ -1138,6 +1143,13 @@ def test_post_comment(self, request):
11381143
"body": f"<!-- readthedocs-{another_project.id} -->\nComment from another project.",
11391144
}
11401145

1146+
def test_integration_attributes(self):
1147+
assert self.integration.is_active
1148+
assert self.integration.get_absolute_url() == "https://github.com/apps/readthedocs/installations/1111"
1149+
self.project.remote_repository = None
1150+
assert not self.integration.is_active
1151+
assert self.integration.get_absolute_url() is None
1152+
11411153

11421154
@override_settings(
11431155
PUBLIC_API_URL="https://app.readthedocs.org",

0 commit comments

Comments
 (0)