From 353578bdaedb91b4d68a543372ef4499cf911fbe Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Mon, 11 Jul 2022 11:07:42 +0200 Subject: [PATCH 01/17] Git backend: make `defaul_branch` to point to VCS' default branch Currently, importing a project manually without setting the `default_branch`, will make Git VCS to use `master` as the default branch. However, with the last swap about `master` and `main` branches, repositories defaulting to `main` branch fail when being imported on Read the Docs. Leaving the user in a blocked state they can get out. This commit allows `Version.identifier` to be NULL meaning that "it won't try to guess what's the default branch of the VCS" and just leave the repository in the immediate state after being cloned (which is already the default branch). To do this, I'm removing the command `git checkout --force ` from the steps executed for this case. Closes #9367 --- readthedocs/builds/managers.py | 2 - .../builds/migrations/0045_identifier_null.py | 20 ++++++++++ readthedocs/builds/models.py | 13 ++++--- .../0090_default_branch_helptext.py | 37 +++++++++++++++++++ readthedocs/projects/models.py | 18 ++------- readthedocs/vcs_support/backends/git.py | 12 ++---- 6 files changed, 73 insertions(+), 29 deletions(-) create mode 100644 readthedocs/builds/migrations/0045_identifier_null.py create mode 100644 readthedocs/projects/migrations/0090_default_branch_helptext.py diff --git a/readthedocs/builds/managers.py b/readthedocs/builds/managers.py index cf82b8b209b..5ae3884706d 100644 --- a/readthedocs/builds/managers.py +++ b/readthedocs/builds/managers.py @@ -1,7 +1,6 @@ """Build and Version class model Managers.""" import structlog - from django.core.exceptions import ObjectDoesNotExist from django.db import models from polymorphic.managers import PolymorphicManager @@ -62,7 +61,6 @@ def create_latest(self, **kwargs): 'verbose_name': LATEST_VERBOSE_NAME, 'machine': True, 'active': True, - 'identifier': LATEST, 'type': BRANCH, } defaults.update(kwargs) diff --git a/readthedocs/builds/migrations/0045_identifier_null.py b/readthedocs/builds/migrations/0045_identifier_null.py new file mode 100644 index 00000000000..6851d721e79 --- /dev/null +++ b/readthedocs/builds/migrations/0045_identifier_null.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.13 on 2022-07-11 08:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("builds", "0044_alter_version_documentation_type"), + ] + + operations = [ + migrations.AlterField( + model_name="version", + name="identifier", + field=models.CharField( + blank=True, max_length=255, null=True, verbose_name="Identifier" + ), + ), + ] diff --git a/readthedocs/builds/models.py b/readthedocs/builds/models.py index ab447d04770..5a6f0017747 100644 --- a/readthedocs/builds/models.py +++ b/readthedocs/builds/models.py @@ -120,11 +120,14 @@ class Version(TimeStampedModel): ) # used by the vcs backend - #: The identifier is the ID for the revision this is version is for. This - #: might be the revision number (e.g. in SVN), or the commit hash (e.g. in - #: Git). If the this version is pointing to a branch, then ``identifier`` - #: will contain the branch name. - identifier = models.CharField(_('Identifier'), max_length=255) + # The identifier is the ID for the revision this is version is for. This + # might be the revision number (e.g. in SVN), or the commit hash (e.g. in + # Git). If the this version is pointing to a branch, then ``identifier`` + # will contain the branch name. NULL value means it will use the VCS + # default branch cloned. + identifier = models.CharField( + _("Identifier"), max_length=255, null=True, blank=True + ) #: This is the actual name that we got for the commit stored in #: ``identifier``. This might be the tag or branch name like ``"v1.0.4"``. diff --git a/readthedocs/projects/migrations/0090_default_branch_helptext.py b/readthedocs/projects/migrations/0090_default_branch_helptext.py new file mode 100644 index 00000000000..e28d6c130e9 --- /dev/null +++ b/readthedocs/projects/migrations/0090_default_branch_helptext.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.13 on 2022-07-11 09:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0089_update_help_text"), + ] + + operations = [ + migrations.AlterField( + model_name="historicalproject", + name="default_branch", + field=models.CharField( + blank=True, + default=None, + help_text='What branch "latest" points to. Leave empty to use the default value for your VCS.', + max_length=255, + null=True, + verbose_name="Default branch", + ), + ), + migrations.AlterField( + model_name="project", + name="default_branch", + field=models.CharField( + blank=True, + default=None, + help_text='What branch "latest" points to. Leave empty to use the default value for your VCS.', + max_length=255, + null=True, + verbose_name="Default branch", + ), + ), + ] diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index 40f876daf1b..f5633665b42 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -164,8 +164,8 @@ class Project(models.Model): default=LATEST, help_text=_('The version of your project that / redirects to'), ) - # In default_branch, None means the backend should choose the - # appropriate branch. Eg 'master' for git + # In default_branch, ``None`` means the backend will use the default branch + # clonned for each backend. default_branch = models.CharField( _('Default branch'), max_length=255, @@ -174,8 +174,7 @@ class Project(models.Model): blank=True, help_text=_( 'What branch "latest" points to. Leave empty ' - 'to use the default value for your VCS (eg. ' - 'trunk or master).', + "to use the default value for your VCS.", ), ) requirements_file = models.CharField( @@ -468,19 +467,10 @@ def save(self, *args, **kwargs): # pylint: disable=arguments-differ if not self.slug: raise Exception(_('Model must have slug')) super().save(*args, **kwargs) - try: - latest = self.versions.filter(slug=LATEST).first() - default_branch = self.get_default_branch() - if latest and latest.identifier != default_branch: - latest.identifier = default_branch - latest.save() - except Exception: - log.exception('Failed to update latest identifier') try: - branch = self.get_default_branch() if not self.versions.filter(slug=LATEST).exists(): - self.versions.create_latest(identifier=branch) + self.versions.create_latest() except Exception: log.exception('Error creating default branches') diff --git a/readthedocs/vcs_support/backends/git.py b/readthedocs/vcs_support/backends/git.py index 0cb7d6a4a45..7684b4e8b6f 100644 --- a/readthedocs/vcs_support/backends/git.py +++ b/readthedocs/vcs_support/backends/git.py @@ -174,11 +174,7 @@ def fetch(self): code, stdout, stderr = self.run(*cmd) return code, stdout, stderr - def checkout_revision(self, revision=None): - if not revision: - branch = self.default_branch or self.fallback_branch - revision = 'origin/%s' % branch - + def checkout_revision(self, revision): try: code, out, err = self.run('git', 'checkout', '--force', revision) return [code, out, err] @@ -322,12 +318,12 @@ def submodules(self): def checkout(self, identifier=None): """Checkout to identifier or latest.""" super().checkout() - # Find proper identifier + + # NOTE: if there is no identifier, we default to default branch cloned if not identifier: - identifier = self.default_branch or self.fallback_branch + return identifier = self.find_ref(identifier) - # Checkout the correct identifier for this branch. code, out, err = self.checkout_revision(identifier) From bdf154a35ab40343a3897b9db99cd09a2e1ac67f Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Tue, 16 Aug 2022 17:21:50 +0200 Subject: [PATCH 02/17] Git backend: use `default_branch` to decide whether build latest Use `default_branch` coming from the Webhook itself to decide if a build for Latest Version with `identifier=None` has to be triggered or not. Note that this feature only works for GitHub and GitLab since Bitbucket does not send the `default_branch` of the repository. --- readthedocs/api/v2/views/integrations.py | 22 ++++++++++++++-------- readthedocs/core/views/hooks.py | 10 +++++++++- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/readthedocs/api/v2/views/integrations.py b/readthedocs/api/v2/views/integrations.py index 0b1dbeadc2d..4f8656f393d 100644 --- a/readthedocs/api/v2/views/integrations.py +++ b/readthedocs/api/v2/views/integrations.py @@ -175,7 +175,7 @@ def get_integration(self): ) return integration - def get_response_push(self, project, branches): + def get_response_push(self, project, branches, default_branch): """ Build branches on push events and return API response. @@ -192,7 +192,7 @@ def get_response_push(self, project, branches): :param branches: List of branch names to build :type branches: list(str) """ - to_build, not_building = build_branches(project, branches) + to_build, not_building = build_branches(project, branches, default_branch) if not_building: log.info( 'Skipping project branches.', @@ -468,7 +468,7 @@ def handle_webhook(self): if event == GITHUB_PUSH: try: branches = [self._normalize_ref(self.data['ref'])] - return self.get_response_push(self.project, branches) + return self.get_response_push(self.project, branches, default_branch) except KeyError: raise ParseError('Parameter "ref" is required') @@ -583,8 +583,9 @@ def handle_webhook(self): return self.sync_versions_response(self.project) # Normal push to master try: - branches = [self._normalize_ref(data['ref'])] - return self.get_response_push(self.project, branches) + default_branch = data["project"]["default_branch"] + branches = [self._normalize_ref(data["ref"])] + return self.get_response_push(self.project, branches, default_branch) except KeyError: raise ParseError('Parameter "ref" is required') @@ -676,8 +677,11 @@ def handle_webhook(self): # we don't trigger the sync versions, because that # will be triggered with the normal push. if branches: - return self.get_response_push(self.project, branches) - log.debug('Triggered sync_versions.') + # FIXME: Bitbucket does not tell us what's the default branch of this repository + return self.get_response_push( + self.project, branches, default_branch=None + ) + log.debug("Triggered sync_versions.") return self.sync_versions_response(self.project) except KeyError: raise ParseError('Invalid request') @@ -755,7 +759,9 @@ def handle_webhook(self): ) if isinstance(branches, str): branches = [branches] - return self.get_response_push(self.project, branches) + + # FIXME: find out what should be the default branch here + return self.get_response_push(self.project, branches, default_branch=None) except TypeError: raise ParseError('Invalid request') diff --git a/readthedocs/core/views/hooks.py b/readthedocs/core/views/hooks.py index d7ed214c0c8..b6314db9631 100644 --- a/readthedocs/core/views/hooks.py +++ b/readthedocs/core/views/hooks.py @@ -40,7 +40,7 @@ def _build_version(project, slug, already_built=()): return None -def build_branches(project, branch_list): +def build_branches(project, branch_list, default_branch): """ Build the branches for a specific project. @@ -53,6 +53,14 @@ def build_branches(project, branch_list): for branch in branch_list: versions = project.versions_from_branch_name(branch) + if branch == default_branch: + # When the branch wanted to be build is the "Default branch" of the repository, + # we want to build the "Latest" version (``identifier=None``) on Read the Docs. + # + # NOTE: we kept the old versions query as well because there are + # old projects that don't have any version with ``identifier=None`` + versions = project.versions.filter(identifier=None) | versions + for version in versions: log.debug( 'Processing.', From 37ff4c6bcb90d77fd8bd8829887bd4415053bdd5 Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Thu, 19 Jan 2023 19:01:17 +0100 Subject: [PATCH 03/17] Update `Version.identifier` based on VCS's `default_branch` The VCS's `default_branch` comes in the incoming webhooks that GitHub and GitLab sends to us when the user realizes an action in the repository. Note that in this commit BitBucket is not supported and will keep having the issues this PR solves. To solve this problem, we would need to update the `Version.identifier` from the builder immediately after clonning the default branch (e.g. `git clone `). I put some commented code/ideas in place to come back to this once we feel it's the right time. --- readthedocs/api/v2/views/integrations.py | 64 +++++++++++++++---- ...tifier_null.py => 0046_identifier_null.py} | 2 +- readthedocs/builds/models.py | 11 ++-- readthedocs/core/views/hooks.py | 11 +--- ...ext.py => 0091_default_branch_helptext.py} | 2 +- readthedocs/projects/models.py | 4 ++ readthedocs/vcs_support/backends/git.py | 17 +++++ 7 files changed, 82 insertions(+), 29 deletions(-) rename readthedocs/builds/migrations/{0045_identifier_null.py => 0046_identifier_null.py} (87%) rename readthedocs/projects/migrations/{0090_default_branch_helptext.py => 0091_default_branch_helptext.py} (95%) diff --git a/readthedocs/api/v2/views/integrations.py b/readthedocs/api/v2/views/integrations.py index 4f8656f393d..165e0f5b0fb 100644 --- a/readthedocs/api/v2/views/integrations.py +++ b/readthedocs/api/v2/views/integrations.py @@ -175,7 +175,7 @@ def get_integration(self): ) return integration - def get_response_push(self, project, branches, default_branch): + def get_response_push(self, project, branches): """ Build branches on push events and return API response. @@ -192,7 +192,7 @@ def get_response_push(self, project, branches, default_branch): :param branches: List of branch names to build :type branches: list(str) """ - to_build, not_building = build_branches(project, branches, default_branch) + to_build, not_building = build_branches(project, branches) if not_building: log.info( 'Skipping project branches.', @@ -280,6 +280,30 @@ def get_closed_external_version_response(self, project): "versions": [version_closed] if version_closed else [], } + def update_default_branch(self, default_branch): + """ + Update the `Version.identifer` for `latest` with the VCS's `default_branch`. + + The VCS's `default_branch` is the branch cloned when there is no specific branch specified + (e.g. `git clone `). + + Some VCS providers (GitHub and GitLab) send the `default_branch` via incoming webhooks. + We use that data to update our database and keep it in sync. + + This solves the problem about "changing the default branch in GitHub" + and also importing repositories with a different `default_branch` than `main` manually: + https://github.com/readthedocs/readthedocs.org/issues/9367 + + In case the user already selected a `default-branch` from the "Advanced settings", + it does not override it. + """ + if not self.project.default_branch: + ( + self.project.versions.filter(slug=LATEST).update( + identifier=default_branch + ) + ) + class GitHubWebhookView(WebhookMixin, APIView): @@ -417,6 +441,12 @@ def handle_webhook(self): event=event, ) + # Always update `latest` branch to point to the default branch in the repository + # even if the event is not gonna be handled. This helps us to keep our db in sync. + default_branch = self.data.get("repository", {}).get("default_branch", None) + if default_branch: + self.update_default_branch(default_branch) + if event == GITHUB_PING: return {"detail": "Webhook configured correctly"} @@ -467,14 +497,15 @@ def handle_webhook(self): # Trigger a build for all branches in the push if event == GITHUB_PUSH: try: - branches = [self._normalize_ref(self.data['ref'])] - return self.get_response_push(self.project, branches, default_branch) + branch = self._normalize_ref(self.data["ref"]) + return self.get_response_push(self.project, [branch]) except KeyError: raise ParseError('Parameter "ref" is required') return None def _normalize_ref(self, ref): + "Remove `ref/(heads|tags)/` from the reference to match a Version on the db." pattern = re.compile(r'^refs/(heads|tags)/') return pattern.sub('', ref) @@ -568,6 +599,13 @@ def handle_webhook(self): data=self.request.data, event=event, ) + + # Always update `latest` branch to point to the default branch in the repository + # even if the event is not gonna be handled. This helps us to keep our db in sync. + default_branch = self.data.get("project", {}).get("default_branch", None) + if default_branch: + self.update_default_branch(default_branch) + # Handle push events and trigger builds if event in (GITLAB_PUSH, GITLAB_TAG_PUSH): data = self.request.data @@ -583,9 +621,8 @@ def handle_webhook(self): return self.sync_versions_response(self.project) # Normal push to master try: - default_branch = data["project"]["default_branch"] - branches = [self._normalize_ref(data["ref"])] - return self.get_response_push(self.project, branches, default_branch) + branch = self._normalize_ref(data["ref"]) + return self.get_response_push(self.project, [branch]) except KeyError: raise ParseError('Parameter "ref" is required') @@ -661,6 +698,11 @@ def handle_webhook(self): data=self.request.data, event=event, ) + + # NOTE: we can't call `self.update_default_branch` here because + # BitBucket does not tell us what is the `default_branch` for a + # repository in these incoming webhooks. + if event == BITBUCKET_PUSH: try: data = self.request.data @@ -677,9 +719,9 @@ def handle_webhook(self): # we don't trigger the sync versions, because that # will be triggered with the normal push. if branches: - # FIXME: Bitbucket does not tell us what's the default branch of this repository return self.get_response_push( - self.project, branches, default_branch=None + self.project, + branches, ) log.debug("Triggered sync_versions.") return self.sync_versions_response(self.project) @@ -759,9 +801,7 @@ def handle_webhook(self): ) if isinstance(branches, str): branches = [branches] - - # FIXME: find out what should be the default branch here - return self.get_response_push(self.project, branches, default_branch=None) + return self.get_response_push(self.project, branches) except TypeError: raise ParseError('Invalid request') diff --git a/readthedocs/builds/migrations/0045_identifier_null.py b/readthedocs/builds/migrations/0046_identifier_null.py similarity index 87% rename from readthedocs/builds/migrations/0045_identifier_null.py rename to readthedocs/builds/migrations/0046_identifier_null.py index 6851d721e79..a2606b050a2 100644 --- a/readthedocs/builds/migrations/0045_identifier_null.py +++ b/readthedocs/builds/migrations/0046_identifier_null.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ("builds", "0044_alter_version_documentation_type"), + ("builds", "0045_alter_build_status"), ] operations = [ diff --git a/readthedocs/builds/models.py b/readthedocs/builds/models.py index 917aa3f9f3e..eaaed773b80 100644 --- a/readthedocs/builds/models.py +++ b/readthedocs/builds/models.py @@ -119,11 +119,12 @@ class Version(TimeStampedModel): ) # used by the vcs backend - # The identifier is the ID for the revision this is version is for. This - # might be the revision number (e.g. in SVN), or the commit hash (e.g. in - # Git). If the this version is pointing to a branch, then ``identifier`` - # will contain the branch name. NULL value means it will use the VCS - # default branch cloned. + #: The identifier is the ID for the revision this is version is for. + #: This might be the revision number (e.g. in SVN), + #: or the commit hash (e.g. in Git). + #: If the this version is pointing to a branch, + #: then ``identifier`` will contain the branch name. + #: NULL value means it will use the VCS default branch cloned. identifier = models.CharField( _("Identifier"), max_length=255, null=True, blank=True ) diff --git a/readthedocs/core/views/hooks.py b/readthedocs/core/views/hooks.py index b6314db9631..49832a05339 100644 --- a/readthedocs/core/views/hooks.py +++ b/readthedocs/core/views/hooks.py @@ -40,7 +40,7 @@ def _build_version(project, slug, already_built=()): return None -def build_branches(project, branch_list, default_branch): +def build_branches(project, branch_list): """ Build the branches for a specific project. @@ -52,15 +52,6 @@ def build_branches(project, branch_list, default_branch): not_building = set() for branch in branch_list: versions = project.versions_from_branch_name(branch) - - if branch == default_branch: - # When the branch wanted to be build is the "Default branch" of the repository, - # we want to build the "Latest" version (``identifier=None``) on Read the Docs. - # - # NOTE: we kept the old versions query as well because there are - # old projects that don't have any version with ``identifier=None`` - versions = project.versions.filter(identifier=None) | versions - for version in versions: log.debug( 'Processing.', diff --git a/readthedocs/projects/migrations/0090_default_branch_helptext.py b/readthedocs/projects/migrations/0091_default_branch_helptext.py similarity index 95% rename from readthedocs/projects/migrations/0090_default_branch_helptext.py rename to readthedocs/projects/migrations/0091_default_branch_helptext.py index e28d6c130e9..d736e280dc2 100644 --- a/readthedocs/projects/migrations/0090_default_branch_helptext.py +++ b/readthedocs/projects/migrations/0091_default_branch_helptext.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ("projects", "0089_update_help_text"), + ("projects", "0090_dont_allow_ips_on_domains"), ] operations = [ diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index 588e3077ca7..a099b4700c9 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -480,6 +480,10 @@ def save(self, *args, **kwargs): # pylint: disable=arguments-differ raise Exception(_('Model must have slug')) super().save(*args, **kwargs) + # Update `Version.identifier` for `latest` with the default branch the use has selected, + # even if it's `None` (meaning to match the `default_branch` of the repository) + self.versions.filter(slug=LATEST).update(identifier=self.default_branch) + try: if not self.versions.filter(slug=LATEST).exists(): self.versions.create_latest() diff --git a/readthedocs/vcs_support/backends/git.py b/readthedocs/vcs_support/backends/git.py index 7684b4e8b6f..78a76a74cf5 100644 --- a/readthedocs/vcs_support/backends/git.py +++ b/readthedocs/vcs_support/backends/git.py @@ -194,6 +194,23 @@ def clone(self): try: code, stdout, stderr = self.run(*cmd) + + # TODO: for those VCS providers that don't tell us the `default_branch` of the repository + # in the incoming webhook, we need to get it from the cloned repository itself. + # + # cmd = ['git', 'symbolic-ref', 'refs/remotes/origin/HEAD'] + # _, default_branch, _ = self.run(*cmd) + # default_branch = default_branch.replace('refs/remotes/origin/', '') + # + # The idea is to hit the APIv2 here to update the `latest` version with + # the `default_branch` we just got from the repository itself, + # after clonning it. + # However, we don't know the PK for the version we want to update. + # + # api_v2.version(pk).patch( + # {'default_branch': default_branch} + # ) + return code, stdout, stderr except RepositoryError: raise RepositoryError(RepositoryError.CLONE_ERROR()) From 4c6f1a721489f7afcb1b1884494f2c65d1025353 Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Thu, 19 Jan 2023 19:11:54 +0100 Subject: [PATCH 04/17] Migrations dependencies --- ...fault_branch_helptext.py => 0095_default_branch_helptext.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename readthedocs/projects/migrations/{0091_default_branch_helptext.py => 0095_default_branch_helptext.py} (95%) diff --git a/readthedocs/projects/migrations/0091_default_branch_helptext.py b/readthedocs/projects/migrations/0095_default_branch_helptext.py similarity index 95% rename from readthedocs/projects/migrations/0091_default_branch_helptext.py rename to readthedocs/projects/migrations/0095_default_branch_helptext.py index d736e280dc2..4423cc29d33 100644 --- a/readthedocs/projects/migrations/0091_default_branch_helptext.py +++ b/readthedocs/projects/migrations/0095_default_branch_helptext.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ("projects", "0090_dont_allow_ips_on_domains"), + ("projects", "0094_auto_20221221_1045"), ] operations = [ From c3a4d54f68fcd31f2844e1eac4386343992eb37e Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Thu, 19 Jan 2023 19:30:17 +0100 Subject: [PATCH 05/17] Lint --- readthedocs/api/v2/views/integrations.py | 3 ++- readthedocs/vcs_support/backends/git.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/readthedocs/api/v2/views/integrations.py b/readthedocs/api/v2/views/integrations.py index 165e0f5b0fb..a6bbf3fcc22 100644 --- a/readthedocs/api/v2/views/integrations.py +++ b/readthedocs/api/v2/views/integrations.py @@ -18,6 +18,7 @@ from readthedocs.builds.constants import ( EXTERNAL_VERSION_STATE_CLOSED, EXTERNAL_VERSION_STATE_OPEN, + LATEST, ) from readthedocs.core.signals import webhook_bitbucket, webhook_github, webhook_gitlab from readthedocs.core.views.hooks import ( @@ -505,7 +506,7 @@ def handle_webhook(self): return None def _normalize_ref(self, ref): - "Remove `ref/(heads|tags)/` from the reference to match a Version on the db." + """Remove `ref/(heads|tags)/` from the reference to match a Version on the db.""" pattern = re.compile(r'^refs/(heads|tags)/') return pattern.sub('', ref) diff --git a/readthedocs/vcs_support/backends/git.py b/readthedocs/vcs_support/backends/git.py index 78a76a74cf5..16ca406a373 100644 --- a/readthedocs/vcs_support/backends/git.py +++ b/readthedocs/vcs_support/backends/git.py @@ -195,8 +195,9 @@ def clone(self): try: code, stdout, stderr = self.run(*cmd) - # TODO: for those VCS providers that don't tell us the `default_branch` of the repository - # in the incoming webhook, we need to get it from the cloned repository itself. + # TODO: for those VCS providers that don't tell us the `default_branch` + # of the repository in the incoming webhook, + # we need to get it from the cloned repository itself. # # cmd = ['git', 'symbolic-ref', 'refs/remotes/origin/HEAD'] # _, default_branch, _ = self.run(*cmd) From fc90d3605f1b774445bbad3471017d30422e39ef Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Mon, 23 Jan 2023 11:09:31 +0100 Subject: [PATCH 06/17] Add note about how to expand use cases for this command --- readthedocs/core/management/commands/contact_owners.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/readthedocs/core/management/commands/contact_owners.py b/readthedocs/core/management/commands/contact_owners.py index c84d6fc4c32..5c0257c4e2c 100644 --- a/readthedocs/core/management/commands/contact_owners.py +++ b/readthedocs/core/management/commands/contact_owners.py @@ -1,8 +1,8 @@ -import structlog import sys from pathlib import Path from pprint import pprint +import structlog from django.conf import settings from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand @@ -48,6 +48,13 @@ class Command(BaseCommand): By default the command won't send the email/notification (dry-run mode), add the ``--production`` flag to actually send the email/notification. + + .. note:: + + If you need to extend the behavior or add a new use case, + we recommend creating a simple script file that re-use the methods and functions from this command. + This is an example to contact Domain owners: + https://gist.github.com/humitos/3e08ed4763a9312f5c0a9a997ea95a42 """ help = 'Send an email or sticky notification from a file (markdown) to all owners.' From 5fc00a3826e2f52b7246bd7eff80f349ff0789bc Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Mon, 23 Jan 2023 11:12:39 +0100 Subject: [PATCH 07/17] Revert "Add note about how to expand use cases for this command" Meh, I made a mistake. This commit should have gone to a different branch. This reverts commit fc90d3605f1b774445bbad3471017d30422e39ef. --- readthedocs/core/management/commands/contact_owners.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/readthedocs/core/management/commands/contact_owners.py b/readthedocs/core/management/commands/contact_owners.py index 5c0257c4e2c..c84d6fc4c32 100644 --- a/readthedocs/core/management/commands/contact_owners.py +++ b/readthedocs/core/management/commands/contact_owners.py @@ -1,8 +1,8 @@ +import structlog import sys from pathlib import Path from pprint import pprint -import structlog from django.conf import settings from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand @@ -48,13 +48,6 @@ class Command(BaseCommand): By default the command won't send the email/notification (dry-run mode), add the ``--production`` flag to actually send the email/notification. - - .. note:: - - If you need to extend the behavior or add a new use case, - we recommend creating a simple script file that re-use the methods and functions from this command. - This is an example to contact Domain owners: - https://gist.github.com/humitos/3e08ed4763a9312f5c0a9a997ea95a42 """ help = 'Send an email or sticky notification from a file (markdown) to all owners.' From 7aa90ebfe41cabf337e0acd21629011e2d6d417a Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Mon, 23 Jan 2023 11:45:04 +0100 Subject: [PATCH 08/17] Apply suggestions from code review Co-authored-by: Eric Holscher <25510+ericholscher@users.noreply.github.com> --- readthedocs/builds/models.py | 2 +- readthedocs/projects/models.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/readthedocs/builds/models.py b/readthedocs/builds/models.py index eaaed773b80..756b340fb95 100644 --- a/readthedocs/builds/models.py +++ b/readthedocs/builds/models.py @@ -124,7 +124,7 @@ class Version(TimeStampedModel): #: or the commit hash (e.g. in Git). #: If the this version is pointing to a branch, #: then ``identifier`` will contain the branch name. - #: NULL value means it will use the VCS default branch cloned. + #: `None`/`null` means it will use the VCS default branch. identifier = models.CharField( _("Identifier"), max_length=255, null=True, blank=True ) diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index a099b4700c9..f5161c85981 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -176,7 +176,7 @@ class Project(models.Model): help_text=_('The version of your project that / redirects to'), ) # In default_branch, ``None`` means the backend will use the default branch - # clonned for each backend. + # cloned for each backend. default_branch = models.CharField( _('Default branch'), max_length=255, @@ -480,8 +480,9 @@ def save(self, *args, **kwargs): # pylint: disable=arguments-differ raise Exception(_('Model must have slug')) super().save(*args, **kwargs) - # Update `Version.identifier` for `latest` with the default branch the use has selected, + # Update `Version.identifier` for `latest` with the default branch the user has selected, # even if it's `None` (meaning to match the `default_branch` of the repository) + log.debug("Updating default branch." slug=LATEST, identifier=self.default_branch) self.versions.filter(slug=LATEST).update(identifier=self.default_branch) try: From ecd47e9730c42625c3407ca39012d723c74b9d6e Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Mon, 23 Jan 2023 11:48:34 +0100 Subject: [PATCH 09/17] Typo --- readthedocs/projects/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index f5161c85981..0076bd8e7da 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -482,7 +482,9 @@ def save(self, *args, **kwargs): # pylint: disable=arguments-differ # Update `Version.identifier` for `latest` with the default branch the user has selected, # even if it's `None` (meaning to match the `default_branch` of the repository) - log.debug("Updating default branch." slug=LATEST, identifier=self.default_branch) + log.debug( + "Updating default branch.", slug=LATEST, identifier=self.default_branch + ) self.versions.filter(slug=LATEST).update(identifier=self.default_branch) try: From ce95eb120bfbd1ad58b1b28c1d8fd7c4e81cf5e6 Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Tue, 24 Jan 2023 09:06:20 +0100 Subject: [PATCH 10/17] TODO comment to potentially remove `identifier` from STABLE --- readthedocs/builds/managers.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/readthedocs/builds/managers.py b/readthedocs/builds/managers.py index 5ae3884706d..3ecb62fae69 100644 --- a/readthedocs/builds/managers.py +++ b/readthedocs/builds/managers.py @@ -49,6 +49,12 @@ def create_stable(self, **kwargs): 'verbose_name': STABLE_VERBOSE_NAME, 'machine': True, 'active': True, + # TODO: double-check if we still require the `identifier: STABLE` field. + # At the time of creation, we don't really know what's the branch/tag identifier + # for the STABLE version. It makes sense to be `None`, probably. + # + # Note that we removed the `identifier: LATEST` from `create_latest` as a way to + # use the default branch. 'identifier': STABLE, 'type': TAG, } From 8b295d2309a374a959778adfbeb64fc8ca5dcd76 Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Tue, 24 Jan 2023 09:37:05 +0100 Subject: [PATCH 11/17] Tests: adjust our tests to match the new changes --- readthedocs/builds/models.py | 5 ++++ readthedocs/projects/models.py | 13 +++++---- readthedocs/rtd_tests/tests/test_backend.py | 7 +++-- .../rtd_tests/tests/test_project_forms.py | 27 +------------------ 4 files changed, 19 insertions(+), 33 deletions(-) diff --git a/readthedocs/builds/models.py b/readthedocs/builds/models.py index 756b340fb95..9652de04249 100644 --- a/readthedocs/builds/models.py +++ b/readthedocs/builds/models.py @@ -367,6 +367,11 @@ def delete(self, *args, **kwargs): # pylint: disable=arguments-differ @property def identifier_friendly(self): """Return display friendly identifier.""" + if not self.identifier: + # Text shown to user when we don't know yet what's the ``identifier`` for this version. + # This usually happens when we haven't pulled the ``default_branch`` for LATEST. + return "Unknown yet" + if re.match(r'^[0-9a-f]{40}$', self.identifier, re.I): return self.identifier[:8] return self.identifier diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index 0076bd8e7da..d1961cee91f 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -480,18 +480,21 @@ def save(self, *args, **kwargs): # pylint: disable=arguments-differ raise Exception(_('Model must have slug')) super().save(*args, **kwargs) + try: + if not self.versions.filter(slug=LATEST).exists(): + self.versions.create_latest() + except Exception: + log.exception("Error creating default branches") + # Update `Version.identifier` for `latest` with the default branch the user has selected, # even if it's `None` (meaning to match the `default_branch` of the repository) + # NOTE: this code is required to be *after* ``create_latest()``. + # It has to be updated after creating LATEST originally. log.debug( "Updating default branch.", slug=LATEST, identifier=self.default_branch ) self.versions.filter(slug=LATEST).update(identifier=self.default_branch) - try: - if not self.versions.filter(slug=LATEST).exists(): - self.versions.create_latest() - except Exception: - log.exception('Error creating default branches') def delete(self, *args, **kwargs): # pylint: disable=arguments-differ from readthedocs.projects.tasks.utils import clean_project_resources diff --git a/readthedocs/rtd_tests/tests/test_backend.py b/readthedocs/rtd_tests/tests/test_backend.py index ac4a9fa0dfe..5d17600b392 100644 --- a/readthedocs/rtd_tests/tests/test_backend.py +++ b/readthedocs/rtd_tests/tests/test_backend.py @@ -200,8 +200,11 @@ def test_git_update_and_checkout(self): repo = self.project.vcs_repo() code, _, _ = repo.update() self.assertEqual(code, 0) - code, _, _ = repo.checkout() - self.assertEqual(code, 0) + + # Returns `None` because there is no `identifier`, + # so it uses the default branch + self.assertIsNone(repo.checkout()) + self.assertTrue(exists(repo.working_dir)) @patch('readthedocs.vcs_support.backends.git.Backend.fetch') diff --git a/readthedocs/rtd_tests/tests/test_project_forms.py b/readthedocs/rtd_tests/tests/test_project_forms.py index 75bedb43196..efc2c9b87bf 100644 --- a/readthedocs/rtd_tests/tests/test_project_forms.py +++ b/readthedocs/rtd_tests/tests/test_project_forms.py @@ -1,9 +1,7 @@ -from unittest import mock from django.contrib.auth.models import User from django.test import TestCase from django.test.utils import override_settings -from django.utils.translation import gettext_lazy as _ from django_dynamic_fixture import get from readthedocs.builds.constants import EXTERNAL, LATEST, STABLE @@ -25,11 +23,7 @@ UpdateProjectForm, WebHookForm, ) -from readthedocs.projects.models import ( - EnvironmentVariable, - Project, - WebHookEvent, -) +from readthedocs.projects.models import EnvironmentVariable, Project, WebHookEvent class TestProjectForms(TestCase): @@ -106,25 +100,6 @@ def test_empty_slug(self): self.assertFalse(form.is_valid()) self.assertIn('name', form.errors) - def test_changing_vcs_should_change_latest(self): - """When changing the project's VCS, latest should be changed too.""" - project = get(Project, repo_type=REPO_TYPE_HG, default_branch=None) - latest = project.versions.get(slug=LATEST) - self.assertEqual(latest.identifier, 'default') - - form = ProjectBasicsForm( - { - 'repo': 'http://github.com/test/test', - 'name': 'name', - 'repo_type': REPO_TYPE_GIT, - }, - instance=project, - ) - self.assertTrue(form.is_valid()) - form.save() - latest.refresh_from_db() - self.assertEqual(latest.identifier, 'master') - def test_changing_vcs_should_not_change_latest_is_not_none(self): """ When changing the project's VCS, From 0db8eed00580ca787a62e98a855993ac543bbd13 Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Tue, 24 Jan 2023 09:49:27 +0100 Subject: [PATCH 12/17] Lint --- readthedocs/projects/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index d1961cee91f..9c581f13f6a 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -495,7 +495,6 @@ def save(self, *args, **kwargs): # pylint: disable=arguments-differ ) self.versions.filter(slug=LATEST).update(identifier=self.default_branch) - def delete(self, *args, **kwargs): # pylint: disable=arguments-differ from readthedocs.projects.tasks.utils import clean_project_resources From e524e7594a2e902f288f838cafd4f35823e4e509 Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Tue, 24 Jan 2023 10:11:46 +0100 Subject: [PATCH 13/17] Webhooks: add support for `default_branch` in generic webhooks People is able to pass `default_branch=main` so Read the Docs can decide whether or not it has to trigger a build for `LATEST` in case its `identifier=None` (the default branch of the repository) --- docs/user/integrations.rst | 7 ++++++- readthedocs/api/v2/views/integrations.py | 8 +++++++- readthedocs/rtd_tests/tests/test_api.py | 8 ++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/user/integrations.rst b/docs/user/integrations.rst index a53d5f32ce8..5e45db39510 100644 --- a/docs/user/integrations.rst +++ b/docs/user/integrations.rst @@ -153,10 +153,15 @@ token The integration token found on the project's **Integrations** dashboard page (:guilabel:`Admin` > :guilabel:`Integrations`). +default_branch + This is the default branch of the repository + (ie. the one checked out when cloning the repository without arguments) + For example, the cURL command to build the ``dev`` branch, using the token ``1234``, would be:: - curl -X POST -d "branches=dev" -d "token=1234" https://readthedocs.org/api/v2/webhook/example-project/1/ + curl -X POST -d "branches=dev" -d "token=1234" -d "default_branch=main" + https://readthedocs.org/api/v2/webhook/example-project/1/ A command like the one above could be called from a cron job or from a hook inside Git_, Subversion_, Mercurial_, or Bazaar_. diff --git a/readthedocs/api/v2/views/integrations.py b/readthedocs/api/v2/views/integrations.py index a6bbf3fcc22..f95ce369c43 100644 --- a/readthedocs/api/v2/views/integrations.py +++ b/readthedocs/api/v2/views/integrations.py @@ -757,7 +757,8 @@ class APIWebhookView(WebhookMixin, APIView): Expects the following JSON:: { - "branches": ["master"] + "branches": ["master"], + "default_branch": "main" } """ @@ -800,8 +801,13 @@ def handle_webhook(self): 'branches', [self.project.get_default_branch()], ) + default_branch = self.request.data.get("default_branch", None) if isinstance(branches, str): branches = [branches] + + if default_branch and isinstance(default_branch, str): + self.update_default_branch(default_branch) + return self.get_response_push(self.project, branches) except TypeError: raise ParseError('Invalid request') diff --git a/readthedocs/rtd_tests/tests/test_api.py b/readthedocs/rtd_tests/tests/test_api.py index 4a20cd48e2a..e208554eb6c 100644 --- a/readthedocs/rtd_tests/tests/test_api.py +++ b/readthedocs/rtd_tests/tests/test_api.py @@ -2346,8 +2346,12 @@ def test_webhook_build_latest_and_master(self, trigger_build): self.project.slug, integration.pk, ), - {'token': integration.token, 'branches': default_branch.slug}, - format='json', + { + "token": integration.token, + "branches": default_branch.slug, + "default_branch": "master", + }, + format="json", ) self.assertEqual(resp.status_code, 200) self.assertTrue(resp.data['build_triggered']) From 03ef8ea5000d0a18e0bc292a081bef2ffe0c7d90 Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Tue, 24 Jan 2023 10:13:19 +0100 Subject: [PATCH 14/17] Tests: define `default_branch="master"` All these tests are expecting `master` to be the default branch. We have to force this now since it was automatically "guess" previously, but now we are not guessing anymore. --- readthedocs/rtd_tests/tests/test_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/readthedocs/rtd_tests/tests/test_api.py b/readthedocs/rtd_tests/tests/test_api.py index e208554eb6c..0368c5b37a6 100644 --- a/readthedocs/rtd_tests/tests/test_api.py +++ b/readthedocs/rtd_tests/tests/test_api.py @@ -928,6 +928,7 @@ def setUp(self): Project, build_queue=None, external_builds_enabled=True, + default_branch="master", ) self.version = get( Version, slug='master', verbose_name='master', From 2bf335b4da0f1f68f5b867fa6e9afbb3469a45ec Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Tue, 24 Jan 2023 10:14:30 +0100 Subject: [PATCH 15/17] Tests: remove `allow_deprecated_webhooks` from this check I don't understand why we were checking for this in this test, but I think it's not necessary since it's an old feature. --- readthedocs/rtd_tests/tests/test_api.py | 80 ++++++++++++------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/readthedocs/rtd_tests/tests/test_api.py b/readthedocs/rtd_tests/tests/test_api.py index 0368c5b37a6..2a06486670e 100644 --- a/readthedocs/rtd_tests/tests/test_api.py +++ b/readthedocs/rtd_tests/tests/test_api.py @@ -2407,46 +2407,46 @@ def test_get_version_by_id(self): self.assertEqual(resp.status_code, 200) version_data = { - 'type': 'tag', - 'verbose_name': '0.8', - 'built': False, - 'id': 18, - 'active': True, - 'project': { - 'analytics_code': None, - 'analytics_disabled': False, - 'canonical_url': 'http://readthedocs.org/docs/pip/en/latest/', - 'cdn_enabled': False, - 'conf_py_file': '', - 'container_image': None, - 'container_mem_limit': None, - 'container_time_limit': None, - 'default_branch': None, - 'default_version': 'latest', - 'description': '', - 'documentation_type': 'sphinx', - 'environment_variables': {}, - 'enable_epub_build': True, - 'enable_pdf_build': True, - 'features': ['allow_deprecated_webhooks'], - 'has_valid_clone': False, - 'has_valid_webhook': False, - 'id': 6, - 'install_project': False, - 'language': 'en', - 'max_concurrent_builds': None, - 'name': 'Pip', - 'programming_language': 'words', - 'python_interpreter': 'python3', - 'repo': 'https://github.com/pypa/pip', - 'repo_type': 'git', - 'requirements_file': None, - 'show_advertising': True, - 'skip': False, - 'slug': 'pip', - 'use_system_packages': False, - 'users': [1], - 'urlconf': None, + "type": "tag", + "verbose_name": "0.8", + "built": False, + "id": 18, + "active": True, + "project": { + "analytics_code": None, + "analytics_disabled": False, + "canonical_url": "http://readthedocs.org/docs/pip/en/latest/", + "cdn_enabled": False, + "conf_py_file": "", + "container_image": None, + "container_mem_limit": None, + "container_time_limit": None, + "default_branch": None, + "default_version": "latest", + "description": "", + "documentation_type": "sphinx", + "environment_variables": {}, + "enable_epub_build": True, + "enable_pdf_build": True, + "features": [], + "has_valid_clone": False, + "has_valid_webhook": False, + "id": 6, + "install_project": False, + "language": "en", + "max_concurrent_builds": None, + "name": "Pip", + "programming_language": "words", + "python_interpreter": "python3", + "repo": "https://github.com/pypa/pip", + "repo_type": "git", + "requirements_file": None, + "show_advertising": True, + "skip": False, + "slug": "pip", + "use_system_packages": False, + "users": [1], + "urlconf": None, }, 'privacy_level': 'public', 'downloads': {}, From 50403693dca062a5a70359728888ca89e228ab95 Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Wed, 25 Jan 2023 08:53:34 +0100 Subject: [PATCH 16/17] Docs: mention it's optional --- docs/user/integrations.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/user/integrations.rst b/docs/user/integrations.rst index 5e45db39510..f04e86de1bc 100644 --- a/docs/user/integrations.rst +++ b/docs/user/integrations.rst @@ -157,6 +157,8 @@ default_branch This is the default branch of the repository (ie. the one checked out when cloning the repository without arguments) + *Optional* + For example, the cURL command to build the ``dev`` branch, using the token ``1234``, would be:: From 223902b54ced485814f2005fc1fbd82a8372b1fd Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Wed, 25 Jan 2023 09:31:51 +0100 Subject: [PATCH 17/17] Test: this feature flag is required I removed because executing tests with `--nomigrations` made it fail. --- readthedocs/rtd_tests/tests/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readthedocs/rtd_tests/tests/test_api.py b/readthedocs/rtd_tests/tests/test_api.py index 2a06486670e..17379205894 100644 --- a/readthedocs/rtd_tests/tests/test_api.py +++ b/readthedocs/rtd_tests/tests/test_api.py @@ -2428,7 +2428,7 @@ def test_get_version_by_id(self): "environment_variables": {}, "enable_epub_build": True, "enable_pdf_build": True, - "features": [], + "features": ["allow_deprecated_webhooks"], "has_valid_clone": False, "has_valid_webhook": False, "id": 6,