Skip to content

Commit e3cf808

Browse files
Git backend: make default_branch to point to VCS' default branch (#9424)
* 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 <branch>` from the steps executed for this case. Closes #9367 * 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. * 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 <URL>`). I put some commented code/ideas in place to come back to this once we feel it's the right time. * Migrations dependencies * Lint * Add note about how to expand use cases for this command * 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 fc90d36. * Apply suggestions from code review Co-authored-by: Eric Holscher <[email protected]> * Typo * TODO comment to potentially remove `identifier` from STABLE * Tests: adjust our tests to match the new changes * Lint * 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) * 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. * 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. * Docs: mention it's optional * Test: this feature flag is required I removed because executing tests with `--nomigrations` made it fail. Co-authored-by: Eric Holscher <[email protected]>
1 parent 78fe3ab commit e3cf808

File tree

12 files changed

+234
-109
lines changed

12 files changed

+234
-109
lines changed

docs/user/integrations.rst

+8-1
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,17 @@ token
153153
The integration token found on the project's **Integrations** dashboard page
154154
(:guilabel:`Admin` > :guilabel:`Integrations`).
155155

156+
default_branch
157+
This is the default branch of the repository
158+
(ie. the one checked out when cloning the repository without arguments)
159+
160+
*Optional*
161+
156162
For example, the cURL command to build the ``dev`` branch, using the token
157163
``1234``, would be::
158164

159-
curl -X POST -d "branches=dev" -d "token=1234" https://readthedocs.org/api/v2/webhook/example-project/1/
165+
curl -X POST -d "branches=dev" -d "token=1234" -d "default_branch=main"
166+
https://readthedocs.org/api/v2/webhook/example-project/1/
160167

161168
A command like the one above could be called from a cron job or from a hook
162169
inside Git_, Subversion_, Mercurial_, or Bazaar_.

readthedocs/api/v2/views/integrations.py

+60-7
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from readthedocs.builds.constants import (
1919
EXTERNAL_VERSION_STATE_CLOSED,
2020
EXTERNAL_VERSION_STATE_OPEN,
21+
LATEST,
2122
)
2223
from readthedocs.core.signals import webhook_bitbucket, webhook_github, webhook_gitlab
2324
from readthedocs.core.views.hooks import (
@@ -280,6 +281,30 @@ def get_closed_external_version_response(self, project):
280281
"versions": [version_closed] if version_closed else [],
281282
}
282283

284+
def update_default_branch(self, default_branch):
285+
"""
286+
Update the `Version.identifer` for `latest` with the VCS's `default_branch`.
287+
288+
The VCS's `default_branch` is the branch cloned when there is no specific branch specified
289+
(e.g. `git clone <URL>`).
290+
291+
Some VCS providers (GitHub and GitLab) send the `default_branch` via incoming webhooks.
292+
We use that data to update our database and keep it in sync.
293+
294+
This solves the problem about "changing the default branch in GitHub"
295+
and also importing repositories with a different `default_branch` than `main` manually:
296+
https://github.com/readthedocs/readthedocs.org/issues/9367
297+
298+
In case the user already selected a `default-branch` from the "Advanced settings",
299+
it does not override it.
300+
"""
301+
if not self.project.default_branch:
302+
(
303+
self.project.versions.filter(slug=LATEST).update(
304+
identifier=default_branch
305+
)
306+
)
307+
283308

284309
class GitHubWebhookView(WebhookMixin, APIView):
285310

@@ -417,6 +442,12 @@ def handle_webhook(self):
417442
event=event,
418443
)
419444

445+
# Always update `latest` branch to point to the default branch in the repository
446+
# even if the event is not gonna be handled. This helps us to keep our db in sync.
447+
default_branch = self.data.get("repository", {}).get("default_branch", None)
448+
if default_branch:
449+
self.update_default_branch(default_branch)
450+
420451
if event == GITHUB_PING:
421452
return {"detail": "Webhook configured correctly"}
422453

@@ -467,14 +498,15 @@ def handle_webhook(self):
467498
# Trigger a build for all branches in the push
468499
if event == GITHUB_PUSH:
469500
try:
470-
branches = [self._normalize_ref(self.data['ref'])]
471-
return self.get_response_push(self.project, branches)
501+
branch = self._normalize_ref(self.data["ref"])
502+
return self.get_response_push(self.project, [branch])
472503
except KeyError:
473504
raise ParseError('Parameter "ref" is required')
474505

475506
return None
476507

477508
def _normalize_ref(self, ref):
509+
"""Remove `ref/(heads|tags)/` from the reference to match a Version on the db."""
478510
pattern = re.compile(r'^refs/(heads|tags)/')
479511
return pattern.sub('', ref)
480512

@@ -568,6 +600,13 @@ def handle_webhook(self):
568600
data=self.request.data,
569601
event=event,
570602
)
603+
604+
# Always update `latest` branch to point to the default branch in the repository
605+
# even if the event is not gonna be handled. This helps us to keep our db in sync.
606+
default_branch = self.data.get("project", {}).get("default_branch", None)
607+
if default_branch:
608+
self.update_default_branch(default_branch)
609+
571610
# Handle push events and trigger builds
572611
if event in (GITLAB_PUSH, GITLAB_TAG_PUSH):
573612
data = self.request.data
@@ -583,8 +622,8 @@ def handle_webhook(self):
583622
return self.sync_versions_response(self.project)
584623
# Normal push to master
585624
try:
586-
branches = [self._normalize_ref(data['ref'])]
587-
return self.get_response_push(self.project, branches)
625+
branch = self._normalize_ref(data["ref"])
626+
return self.get_response_push(self.project, [branch])
588627
except KeyError:
589628
raise ParseError('Parameter "ref" is required')
590629

@@ -660,6 +699,11 @@ def handle_webhook(self):
660699
data=self.request.data,
661700
event=event,
662701
)
702+
703+
# NOTE: we can't call `self.update_default_branch` here because
704+
# BitBucket does not tell us what is the `default_branch` for a
705+
# repository in these incoming webhooks.
706+
663707
if event == BITBUCKET_PUSH:
664708
try:
665709
data = self.request.data
@@ -676,8 +720,11 @@ def handle_webhook(self):
676720
# we don't trigger the sync versions, because that
677721
# will be triggered with the normal push.
678722
if branches:
679-
return self.get_response_push(self.project, branches)
680-
log.debug('Triggered sync_versions.')
723+
return self.get_response_push(
724+
self.project,
725+
branches,
726+
)
727+
log.debug("Triggered sync_versions.")
681728
return self.sync_versions_response(self.project)
682729
except KeyError:
683730
raise ParseError('Invalid request')
@@ -710,7 +757,8 @@ class APIWebhookView(WebhookMixin, APIView):
710757
Expects the following JSON::
711758
712759
{
713-
"branches": ["master"]
760+
"branches": ["master"],
761+
"default_branch": "main"
714762
}
715763
"""
716764

@@ -753,8 +801,13 @@ def handle_webhook(self):
753801
'branches',
754802
[self.project.get_default_branch()],
755803
)
804+
default_branch = self.request.data.get("default_branch", None)
756805
if isinstance(branches, str):
757806
branches = [branches]
807+
808+
if default_branch and isinstance(default_branch, str):
809+
self.update_default_branch(default_branch)
810+
758811
return self.get_response_push(self.project, branches)
759812
except TypeError:
760813
raise ParseError('Invalid request')

readthedocs/builds/managers.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Build and Version class model Managers."""
22

33
import structlog
4-
54
from django.core.exceptions import ObjectDoesNotExist
65
from django.db import models
76
from polymorphic.managers import PolymorphicManager
@@ -50,6 +49,12 @@ def create_stable(self, **kwargs):
5049
'verbose_name': STABLE_VERBOSE_NAME,
5150
'machine': True,
5251
'active': True,
52+
# TODO: double-check if we still require the `identifier: STABLE` field.
53+
# At the time of creation, we don't really know what's the branch/tag identifier
54+
# for the STABLE version. It makes sense to be `None`, probably.
55+
#
56+
# Note that we removed the `identifier: LATEST` from `create_latest` as a way to
57+
# use the default branch.
5358
'identifier': STABLE,
5459
'type': TAG,
5560
}
@@ -62,7 +67,6 @@ def create_latest(self, **kwargs):
6267
'verbose_name': LATEST_VERBOSE_NAME,
6368
'machine': True,
6469
'active': True,
65-
'identifier': LATEST,
6670
'type': BRANCH,
6771
}
6872
defaults.update(kwargs)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 3.2.13 on 2022-07-11 08:53
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("builds", "0045_alter_build_status"),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name="version",
15+
name="identifier",
16+
field=models.CharField(
17+
blank=True, max_length=255, null=True, verbose_name="Identifier"
18+
),
19+
),
20+
]

readthedocs/builds/models.py

+14-5
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,15 @@ class Version(TimeStampedModel):
119119
)
120120
# used by the vcs backend
121121

122-
#: The identifier is the ID for the revision this is version is for. This
123-
#: might be the revision number (e.g. in SVN), or the commit hash (e.g. in
124-
#: Git). If the this version is pointing to a branch, then ``identifier``
125-
#: will contain the branch name.
126-
identifier = models.CharField(_('Identifier'), max_length=255)
122+
#: The identifier is the ID for the revision this is version is for.
123+
#: This might be the revision number (e.g. in SVN),
124+
#: or the commit hash (e.g. in Git).
125+
#: If the this version is pointing to a branch,
126+
#: then ``identifier`` will contain the branch name.
127+
#: `None`/`null` means it will use the VCS default branch.
128+
identifier = models.CharField(
129+
_("Identifier"), max_length=255, null=True, blank=True
130+
)
127131

128132
#: This is the actual name that we got for the commit stored in
129133
#: ``identifier``. This might be the tag or branch name like ``"v1.0.4"``.
@@ -363,6 +367,11 @@ def delete(self, *args, **kwargs): # pylint: disable=arguments-differ
363367
@property
364368
def identifier_friendly(self):
365369
"""Return display friendly identifier."""
370+
if not self.identifier:
371+
# Text shown to user when we don't know yet what's the ``identifier`` for this version.
372+
# This usually happens when we haven't pulled the ``default_branch`` for LATEST.
373+
return "Unknown yet"
374+
366375
if re.match(r'^[0-9a-f]{40}$', self.identifier, re.I):
367376
return self.identifier[:8]
368377
return self.identifier

readthedocs/core/views/hooks.py

-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ def build_branches(project, branch_list):
5252
not_building = set()
5353
for branch in branch_list:
5454
versions = project.versions_from_branch_name(branch)
55-
5655
for version in versions:
5756
log.debug(
5857
'Processing.',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Generated by Django 3.2.13 on 2022-07-11 09:06
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("projects", "0094_auto_20221221_1045"),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name="historicalproject",
15+
name="default_branch",
16+
field=models.CharField(
17+
blank=True,
18+
default=None,
19+
help_text='What branch "latest" points to. Leave empty to use the default value for your VCS.',
20+
max_length=255,
21+
null=True,
22+
verbose_name="Default branch",
23+
),
24+
),
25+
migrations.AlterField(
26+
model_name="project",
27+
name="default_branch",
28+
field=models.CharField(
29+
blank=True,
30+
default=None,
31+
help_text='What branch "latest" points to. Leave empty to use the default value for your VCS.',
32+
max_length=255,
33+
null=True,
34+
verbose_name="Default branch",
35+
),
36+
),
37+
]

readthedocs/projects/models.py

+14-15
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,8 @@ class Project(models.Model):
175175
default=LATEST,
176176
help_text=_('The version of your project that / redirects to'),
177177
)
178-
# In default_branch, None means the backend should choose the
179-
# appropriate branch. Eg 'master' for git
178+
# In default_branch, ``None`` means the backend will use the default branch
179+
# cloned for each backend.
180180
default_branch = models.CharField(
181181
_('Default branch'),
182182
max_length=255,
@@ -185,8 +185,7 @@ class Project(models.Model):
185185
blank=True,
186186
help_text=_(
187187
'What branch "latest" points to. Leave empty '
188-
'to use the default value for your VCS (eg. '
189-
'<code>trunk</code> or <code>master</code>).',
188+
"to use the default value for your VCS.",
190189
),
191190
)
192191
requirements_file = models.CharField(
@@ -480,21 +479,21 @@ def save(self, *args, **kwargs): # pylint: disable=arguments-differ
480479
if not self.slug:
481480
raise Exception(_('Model must have slug'))
482481
super().save(*args, **kwargs)
483-
try:
484-
latest = self.versions.filter(slug=LATEST).first()
485-
default_branch = self.get_default_branch()
486-
if latest and latest.identifier != default_branch:
487-
latest.identifier = default_branch
488-
latest.save()
489-
except Exception:
490-
log.exception('Failed to update latest identifier')
491482

492483
try:
493-
branch = self.get_default_branch()
494484
if not self.versions.filter(slug=LATEST).exists():
495-
self.versions.create_latest(identifier=branch)
485+
self.versions.create_latest()
496486
except Exception:
497-
log.exception('Error creating default branches')
487+
log.exception("Error creating default branches")
488+
489+
# Update `Version.identifier` for `latest` with the default branch the user has selected,
490+
# even if it's `None` (meaning to match the `default_branch` of the repository)
491+
# NOTE: this code is required to be *after* ``create_latest()``.
492+
# It has to be updated after creating LATEST originally.
493+
log.debug(
494+
"Updating default branch.", slug=LATEST, identifier=self.default_branch
495+
)
496+
self.versions.filter(slug=LATEST).update(identifier=self.default_branch)
498497

499498
def delete(self, *args, **kwargs): # pylint: disable=arguments-differ
500499
from readthedocs.projects.tasks.utils import clean_project_resources

0 commit comments

Comments
 (0)