Skip to content

Add models for GitHub App #12070

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 3 commits into from
Mar 26, 2025
Merged
Show file tree
Hide file tree
Changes from 2 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
10 changes: 10 additions & 0 deletions readthedocs/oauth/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,22 @@

from django.contrib import admin

from .models import GitHubAppInstallation
from .models import RemoteOrganization
from .models import RemoteOrganizationRelation
from .models import RemoteRepository
from .models import RemoteRepositoryRelation


@admin.register(GitHubAppInstallation)
class GitHubAppInstallationAdmin(admin.ModelAdmin):
list_display = (
"installation_id",
"target_type",
"target_id",
)


@admin.register(RemoteRepository)
class RemoteRepositoryAdmin(admin.ModelAdmin):
"""Admin configuration for the RemoteRepository model."""
Expand Down
2 changes: 2 additions & 0 deletions readthedocs/oauth/constants.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
GITHUB = "github"
GITHUB_APP = "githubapp"
GITLAB = "gitlab"
BITBUCKET = "bitbucket"

VCS_PROVIDER_CHOICES = (
(GITHUB, "GitHub"),
(GITHUB_APP, "GitHub"),
(GITLAB, "GitLab"),
(BITBUCKET, "Bitbucket"),
)
117 changes: 117 additions & 0 deletions readthedocs/oauth/migrations/0018_githubapp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Generated by Django 4.2.18 on 2025-02-03 21:58
import django.db.models.deletion
import django_extensions.db.fields
from django.db import migrations
from django.db import models
from django_safemigrate import Safe


class Migration(migrations.Migration):
safe = Safe.before_deploy

dependencies = [
("oauth", "0017_remove_unused_indexes"),
]

operations = [
migrations.CreateModel(
name="GitHubAppInstallation",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"installation_id",
models.PositiveBigIntegerField(
db_index=True,
help_text="The application installation ID",
unique=True,
),
),
(
"target_id",
models.PositiveBigIntegerField(
help_text="A GitHub account ID, it can be from a user or an organization"
),
),
(
"target_type",
models.CharField(
choices=[("User", "User"), ("Organization", "Organization")],
help_text="Account type that the target_id belongs to (user or organization)",
max_length=255,
),
),
(
"extra_data",
models.JSONField(
default=dict,
help_text="Extra data returned by the webhook when the installation is created",
),
),
],
options={
"get_latest_by": "modified",
"verbose_name": "GitHub app installation",
"abstract": False,
},
),
migrations.AddField(
model_name="remoterepository",
name="github_app_installation",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="repositories",
to="oauth.githubappinstallation",
verbose_name="GitHub App Installation",
),
),
migrations.AlterField(
model_name="remoteorganization",
name="vcs_provider",
field=models.CharField(
choices=[
("github", "GitHub"),
("githubapp", "GitHub"),
("gitlab", "GitLab"),
("bitbucket", "Bitbucket"),
],
max_length=32,
verbose_name="VCS provider",
),
),
migrations.AlterField(
model_name="remoterepository",
name="vcs_provider",
field=models.CharField(
choices=[
("github", "GitHub"),
("githubapp", "GitHub"),
("gitlab", "GitLab"),
("bitbucket", "Bitbucket"),
],
max_length=32,
verbose_name="VCS provider",
),
),
]
76 changes: 76 additions & 0 deletions readthedocs/oauth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,71 @@
log = structlog.get_logger(__name__)


class GitHubAppInstallationManager(models.Manager):
def get_or_create_installation(
self, *, installation_id, target_id, target_type, extra_data=None
):
"""
Get or create a GitHub app installation.

Only the installation_id is unique, the target_id and target_type could change,
but this should never happen.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heh... foreboding.

"""
installation, created = self.get_or_create(
installation_id=installation_id,
defaults={
"target_id": target_id,
"target_type": target_type,
"extra_data": extra_data or {},
},
)
# NOTE: An installation can't change its target_id or target_type.
# This should never happen, unless this assumption is wrong.
if installation.target_id != target_id or installation.target_type != target_type:
log.exception(
"Installation target_id or target_type changed",
installation_id=installation.installation_id,
target_id=installation.target_id,
target_type=installation.target_type,
new_target_id=target_id,
new_target_type=target_type,
)
installation.target_id = target_id
installation.target_type = target_type
installation.save()
return installation, created


class GitHubAccountType(models.TextChoices):
USER = "User", _("User")
ORGANIZATION = "Organization", _("Organization")


class GitHubAppInstallation(TimeStampedModel):
installation_id = models.PositiveBigIntegerField(
help_text=_("The application installation ID"),
unique=True,
db_index=True,
)
target_id = models.PositiveBigIntegerField(
help_text=_("A GitHub account ID, it can be from a user or an organization"),
)
target_type = models.CharField(
help_text=_("Account type that the target_id belongs to (user or organization)"),
choices=GitHubAccountType.choices,
max_length=255,
)
extra_data = models.JSONField(
help_text=_("Extra data returned by the webhook when the installation is created"),
default=dict,
)

objects = GitHubAppInstallationManager()

class Meta(TimeStampedModel.Meta):
verbose_name = _("GitHub app installation")


class RemoteOrganization(TimeStampedModel):
"""
Organization from remote service.
Expand Down Expand Up @@ -173,6 +238,17 @@ class RemoteRepository(TimeStampedModel):
remote_id = models.CharField(max_length=128)
vcs_provider = models.CharField(_("VCS provider"), choices=VCS_PROVIDER_CHOICES, max_length=32)

github_app_installation = models.ForeignKey(
GitHubAppInstallation,
verbose_name=_("GitHub App Installation"),
related_name="repositories",
null=True,
blank=True,
# When an installation is deleted, we delete all its remote repositories
# and relations, users will need to manually link the projects to each repository again.
Comment on lines +247 to +248
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a nicer UI we can build for that in the future? Seems like a pretty large downside.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe something that facilitates re-connection in batch, maybe could be exposed as an API to start. But I only see this a problem for users with lots of repos connected, re-connection is straightforward, just select the repo from the settings and click save.

on_delete=models.CASCADE,
)

objects = RemoteRepositoryQuerySet.as_manager()

class Meta:
Expand Down