Skip to content

Commit 71f3862

Browse files
authored
Add models for GitHub App (#12070)
Since we have only one service with this type of integration, I didn't abstract this as a general attribute (like service_installation or similar), as we don't know if other services will ever bring something similar, and if they do, we don't know how they would implement it. Extracted from #11942
1 parent 551e5e8 commit 71f3862

File tree

4 files changed

+205
-0
lines changed

4 files changed

+205
-0
lines changed

readthedocs/oauth/admin.py

+10
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,22 @@
22

33
from django.contrib import admin
44

5+
from .models import GitHubAppInstallation
56
from .models import RemoteOrganization
67
from .models import RemoteOrganizationRelation
78
from .models import RemoteRepository
89
from .models import RemoteRepositoryRelation
910

1011

12+
@admin.register(GitHubAppInstallation)
13+
class GitHubAppInstallationAdmin(admin.ModelAdmin):
14+
list_display = (
15+
"installation_id",
16+
"target_type",
17+
"target_id",
18+
)
19+
20+
1121
@admin.register(RemoteRepository)
1222
class RemoteRepositoryAdmin(admin.ModelAdmin):
1323
"""Admin configuration for the RemoteRepository model."""

readthedocs/oauth/constants.py

+2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
GITHUB = "github"
2+
GITHUB_APP = "githubapp"
23
GITLAB = "gitlab"
34
BITBUCKET = "bitbucket"
45

56
VCS_PROVIDER_CHOICES = (
67
(GITHUB, "GitHub"),
8+
(GITHUB_APP, "GitHub"),
79
(GITLAB, "GitLab"),
810
(BITBUCKET, "Bitbucket"),
911
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# Generated by Django 4.2.18 on 2025-02-03 21:58
2+
import django.db.models.deletion
3+
import django_extensions.db.fields
4+
from django.db import migrations
5+
from django.db import models
6+
from django_safemigrate import Safe
7+
8+
9+
class Migration(migrations.Migration):
10+
safe = Safe.before_deploy
11+
12+
dependencies = [
13+
("oauth", "0017_remove_unused_indexes"),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="GitHubAppInstallation",
19+
fields=[
20+
(
21+
"id",
22+
models.AutoField(
23+
auto_created=True,
24+
primary_key=True,
25+
serialize=False,
26+
verbose_name="ID",
27+
),
28+
),
29+
(
30+
"created",
31+
django_extensions.db.fields.CreationDateTimeField(
32+
auto_now_add=True, verbose_name="created"
33+
),
34+
),
35+
(
36+
"modified",
37+
django_extensions.db.fields.ModificationDateTimeField(
38+
auto_now=True, verbose_name="modified"
39+
),
40+
),
41+
(
42+
"installation_id",
43+
models.PositiveBigIntegerField(
44+
db_index=True,
45+
help_text="The application installation ID",
46+
unique=True,
47+
),
48+
),
49+
(
50+
"target_id",
51+
models.PositiveBigIntegerField(
52+
help_text="A GitHub account ID, it can be from a user or an organization"
53+
),
54+
),
55+
(
56+
"target_type",
57+
models.CharField(
58+
choices=[("User", "User"), ("Organization", "Organization")],
59+
help_text="Account type that the target_id belongs to (user or organization)",
60+
max_length=255,
61+
),
62+
),
63+
(
64+
"extra_data",
65+
models.JSONField(
66+
default=dict,
67+
help_text="Extra data returned by the webhook when the installation is created",
68+
),
69+
),
70+
],
71+
options={
72+
"get_latest_by": "modified",
73+
"verbose_name": "GitHub app installation",
74+
"abstract": False,
75+
},
76+
),
77+
migrations.AddField(
78+
model_name="remoterepository",
79+
name="github_app_installation",
80+
field=models.ForeignKey(
81+
blank=True,
82+
null=True,
83+
on_delete=django.db.models.deletion.CASCADE,
84+
related_name="repositories",
85+
to="oauth.githubappinstallation",
86+
verbose_name="GitHub App Installation",
87+
),
88+
),
89+
migrations.AlterField(
90+
model_name="remoteorganization",
91+
name="vcs_provider",
92+
field=models.CharField(
93+
choices=[
94+
("github", "GitHub"),
95+
("githubapp", "GitHub"),
96+
("gitlab", "GitLab"),
97+
("bitbucket", "Bitbucket"),
98+
],
99+
max_length=32,
100+
verbose_name="VCS provider",
101+
),
102+
),
103+
migrations.AlterField(
104+
model_name="remoterepository",
105+
name="vcs_provider",
106+
field=models.CharField(
107+
choices=[
108+
("github", "GitHub"),
109+
("githubapp", "GitHub"),
110+
("gitlab", "GitLab"),
111+
("bitbucket", "Bitbucket"),
112+
],
113+
max_length=32,
114+
verbose_name="VCS provider",
115+
),
116+
),
117+
]

readthedocs/oauth/models.py

+76
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,71 @@
2020
log = structlog.get_logger(__name__)
2121

2222

23+
class GitHubAppInstallationManager(models.Manager):
24+
def get_or_create_installation(
25+
self, *, installation_id, target_id, target_type, extra_data=None
26+
):
27+
"""
28+
Get or create a GitHub app installation.
29+
30+
Only the installation_id is unique, the target_id and target_type could change,
31+
but this should never happen.
32+
"""
33+
installation, created = self.get_or_create(
34+
installation_id=installation_id,
35+
defaults={
36+
"target_id": target_id,
37+
"target_type": target_type,
38+
"extra_data": extra_data or {},
39+
},
40+
)
41+
# NOTE: An installation can't change its target_id or target_type.
42+
# This should never happen, unless this assumption is wrong.
43+
if installation.target_id != target_id or installation.target_type != target_type:
44+
log.exception(
45+
"Installation target_id or target_type changed. This shouldn't happen -- look into it",
46+
installation_id=installation.installation_id,
47+
target_id=installation.target_id,
48+
target_type=installation.target_type,
49+
new_target_id=target_id,
50+
new_target_type=target_type,
51+
)
52+
installation.target_id = target_id
53+
installation.target_type = target_type
54+
installation.save()
55+
return installation, created
56+
57+
58+
class GitHubAccountType(models.TextChoices):
59+
USER = "User", _("User")
60+
ORGANIZATION = "Organization", _("Organization")
61+
62+
63+
class GitHubAppInstallation(TimeStampedModel):
64+
installation_id = models.PositiveBigIntegerField(
65+
help_text=_("The application installation ID"),
66+
unique=True,
67+
db_index=True,
68+
)
69+
target_id = models.PositiveBigIntegerField(
70+
help_text=_("A GitHub account ID, it can be from a user or an organization"),
71+
)
72+
target_type = models.CharField(
73+
help_text=_("Account type that the target_id belongs to (user or organization)"),
74+
choices=GitHubAccountType.choices,
75+
max_length=255,
76+
)
77+
extra_data = models.JSONField(
78+
help_text=_("Extra data returned by the webhook when the installation is created"),
79+
default=dict,
80+
)
81+
82+
objects = GitHubAppInstallationManager()
83+
84+
class Meta(TimeStampedModel.Meta):
85+
verbose_name = _("GitHub app installation")
86+
87+
2388
class RemoteOrganization(TimeStampedModel):
2489
"""
2590
Organization from remote service.
@@ -173,6 +238,17 @@ class RemoteRepository(TimeStampedModel):
173238
remote_id = models.CharField(max_length=128)
174239
vcs_provider = models.CharField(_("VCS provider"), choices=VCS_PROVIDER_CHOICES, max_length=32)
175240

241+
github_app_installation = models.ForeignKey(
242+
GitHubAppInstallation,
243+
verbose_name=_("GitHub App Installation"),
244+
related_name="repositories",
245+
null=True,
246+
blank=True,
247+
# When an installation is deleted, we delete all its remote repositories
248+
# and relations, users will need to manually link the projects to each repository again.
249+
on_delete=models.CASCADE,
250+
)
251+
176252
objects = RemoteRepositoryQuerySet.as_manager()
177253

178254
class Meta:

0 commit comments

Comments
 (0)