Skip to content

Commit 551e5e8

Browse files
authored
Webhooks: refactor branch/tag building (#12014)
- Webhooks can trigger builds to tags, not just branches - Since webhooks already know the type of the version, we use it to filter by results. - verbose_name is the value we use to match branches or tags. Extracted from #11942
1 parent 5840a67 commit 551e5e8

File tree

6 files changed

+131
-71
lines changed

6 files changed

+131
-71
lines changed

readthedocs/api/v2/views/integrations.py

+25-26
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import hashlib
44
import hmac
55
import json
6-
import re
76
from functools import namedtuple
87
from textwrap import dedent
98

@@ -19,18 +18,22 @@
1918
from rest_framework.status import HTTP_400_BAD_REQUEST
2019
from rest_framework.views import APIView
2120

21+
from readthedocs.builds.constants import BRANCH
2222
from readthedocs.builds.constants import LATEST
23+
from readthedocs.builds.constants import TAG
2324
from readthedocs.core.signals import webhook_bitbucket
2425
from readthedocs.core.signals import webhook_github
2526
from readthedocs.core.signals import webhook_gitlab
26-
from readthedocs.core.views.hooks import build_branches
27+
from readthedocs.core.views.hooks import VersionInfo
2728
from readthedocs.core.views.hooks import build_external_version
29+
from readthedocs.core.views.hooks import build_versions_from_names
2830
from readthedocs.core.views.hooks import close_external_version
2931
from readthedocs.core.views.hooks import get_or_create_external_version
3032
from readthedocs.core.views.hooks import trigger_sync_versions
3133
from readthedocs.integrations.models import HttpExchange
3234
from readthedocs.integrations.models import Integration
3335
from readthedocs.projects.models import Project
36+
from readthedocs.vcs_support.backends.git import parse_version_from_ref
3437

3538

3639
log = structlog.get_logger(__name__)
@@ -205,7 +208,7 @@ def get_integration(self):
205208
)
206209
return self.integration
207210

208-
def get_response_push(self, project, branches):
211+
def get_response_push(self, project, versions_info: list[VersionInfo]):
209212
"""
210213
Build branches on push events and return API response.
211214
@@ -219,14 +222,12 @@ def get_response_push(self, project, branches):
219222
220223
:param project: Project instance
221224
:type project: Project
222-
:param branches: List of branch/tag names to build
223-
:type branches: list(str)
224225
"""
225-
to_build, not_building = build_branches(project, branches)
226+
to_build, not_building = build_versions_from_names(project, versions_info)
226227
if not_building:
227228
log.info(
228-
"Skipping project branches.",
229-
branches=branches,
229+
"Skipping project versions.",
230+
versions=not_building,
230231
)
231232
triggered = bool(to_build)
232233
return {
@@ -520,18 +521,15 @@ def handle_webhook(self):
520521
# Trigger a build for all branches in the push
521522
if event == GITHUB_PUSH:
522523
try:
523-
branch = self._normalize_ref(self.data["ref"])
524-
return self.get_response_push(self.project, [branch])
524+
version_name, version_type = parse_version_from_ref(self.data["ref"])
525+
return self.get_response_push(
526+
self.project, [VersionInfo(name=version_name, type=version_type)]
527+
)
525528
except KeyError as exc:
526529
raise ParseError('Parameter "ref" is required') from exc
527530

528531
return None
529532

530-
def _normalize_ref(self, ref):
531-
"""Remove `ref/(heads|tags)/` from the reference to match a Version on the db."""
532-
pattern = re.compile(r"^refs/(heads|tags)/")
533-
return pattern.sub("", ref)
534-
535533

536534
class GitLabWebhookView(WebhookMixin, APIView):
537535
"""
@@ -640,8 +638,10 @@ def handle_webhook(self):
640638
return self.sync_versions_response(self.project)
641639
# Normal push to master
642640
try:
643-
branch = self._normalize_ref(data["ref"])
644-
return self.get_response_push(self.project, [branch])
641+
version_name, version_type = parse_version_from_ref(self.data["ref"])
642+
return self.get_response_push(
643+
self.project, [VersionInfo(name=version_name, type=version_type)]
644+
)
645645
except KeyError as exc:
646646
raise ParseError('Parameter "ref" is required') from exc
647647

@@ -659,10 +659,6 @@ def handle_webhook(self):
659659
return self.get_closed_external_version_response(self.project)
660660
return None
661661

662-
def _normalize_ref(self, ref):
663-
pattern = re.compile(r"^refs/(heads|tags)/")
664-
return pattern.sub("", ref)
665-
666662

667663
class BitbucketWebhookView(WebhookMixin, APIView):
668664
"""
@@ -722,21 +718,22 @@ def handle_webhook(self):
722718
try:
723719
data = self.request.data
724720
changes = data["push"]["changes"]
725-
branches = []
721+
versions_info = []
726722
for change in changes:
727723
old = change["old"]
728724
new = change["new"]
729725
# Normal push to master
730726
if old is not None and new is not None:
731-
branches.append(new["name"])
727+
version_type = BRANCH if new["type"] == "branch" else TAG
728+
versions_info.append(VersionInfo(name=new["name"], type=version_type))
732729
# BitBuck returns an array of changes rather than
733730
# one webhook per change. If we have at least one normal push
734731
# we don't trigger the sync versions, because that
735732
# will be triggered with the normal push.
736-
if branches:
733+
if versions_info:
737734
return self.get_response_push(
738735
self.project,
739-
branches,
736+
versions_info,
740737
)
741738
log.debug("Triggered sync_versions.")
742739
return self.sync_versions_response(self.project)
@@ -833,7 +830,9 @@ def handle_webhook(self):
833830
if default_branch and isinstance(default_branch, str):
834831
self.update_default_branch(default_branch)
835832

836-
return self.get_response_push(self.project, branches)
833+
# branches can be a branch or a tag, so we set it to None, so both types are considered.
834+
versions_info = [VersionInfo(name=branch, type=None) for branch in branches]
835+
return self.get_response_push(self.project, versions_info)
837836
except TypeError as exc:
838837
raise ParseError("Invalid request") from exc
839838

readthedocs/core/views/hooks.py

+34-20
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Views pertaining to builds."""
22

3+
from dataclasses import dataclass
4+
from typing import Literal
5+
36
import structlog
47

58
from readthedocs.api.v2.models import BuildAPIKey
@@ -12,12 +15,24 @@
1215
from readthedocs.projects.tasks.builds import sync_repository_task
1316

1417

18+
@dataclass
19+
class VersionInfo:
20+
"""
21+
Version information.
22+
23+
If type is None, it means that the version can be either a branch or a tag.
24+
"""
25+
26+
name: str
27+
type: Literal["branch", "tag", None]
28+
29+
1530
log = structlog.get_logger(__name__)
1631

1732

18-
def _build_version(project, slug, already_built=()):
33+
def _build_version(project, version):
1934
"""
20-
Where we actually trigger builds for a project and slug.
35+
Where we actually trigger builds for a project and version.
2136
2237
All webhook logic should route here to call ``trigger_build``.
2338
"""
@@ -27,44 +42,43 @@ def _build_version(project, slug, already_built=()):
2742
# Previously we were building the latest version (inactive or active)
2843
# when building the default version,
2944
# some users may have relied on this to update the version list #4450
30-
version = project.versions.filter(active=True, slug=slug).first()
31-
if version and slug not in already_built:
45+
if version.active:
3246
log.info(
33-
"Building.",
47+
"Triggering build.",
3448
project_slug=project.slug,
3549
version_slug=version.slug,
3650
)
3751
trigger_build(project=project, version=version)
38-
return slug
52+
return True
3953

40-
log.info("Not building.", version_slug=slug)
41-
return None
54+
log.info("Not building.", version_slug=version.slug)
55+
return False
4256

4357

44-
def build_branches(project, branch_list):
58+
def build_versions_from_names(project, versions_info: list[VersionInfo]):
4559
"""
46-
Build the branches for a specific project.
60+
Build the branches or tags from the project.
4761
48-
Returns:
49-
to_build - a list of branches that were built
50-
not_building - a list of branches that we won't build
62+
:param project: Project instance
63+
:returns: A tuple with the versions that were built and the versions that were not built.
5164
"""
5265
to_build = set()
5366
not_building = set()
54-
for branch in branch_list:
55-
versions = project.versions_from_branch_name(branch)
56-
for version in versions:
67+
for version_info in versions_info:
68+
for version in project.versions_from_name(version_info.name, version_info.type):
5769
log.debug(
5870
"Processing.",
5971
project_slug=project.slug,
6072
version_slug=version.slug,
6173
)
62-
ret = _build_version(project, version.slug, already_built=to_build)
63-
if ret:
64-
to_build.add(ret)
74+
if version.slug in to_build:
75+
continue
76+
version_built = _build_version(project, version)
77+
if version_built:
78+
to_build.add(version.slug)
6579
else:
6680
not_building.add(version.slug)
67-
return (to_build, not_building)
81+
return to_build, not_building
6882

6983

7084
def trigger_sync_versions(project):

readthedocs/projects/models.py

+25-6
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from django.core.validators import MaxValueValidator
1515
from django.core.validators import MinValueValidator
1616
from django.db import models
17+
from django.db.models import Q
1718
from django.urls import reverse
1819
from django.utils import timezone
1920
from django.utils.crypto import get_random_string
@@ -1230,14 +1231,32 @@ def update_stable_version(self):
12301231
)
12311232
return new_stable
12321233

1233-
def versions_from_branch_name(self, branch):
1234-
return (
1235-
self.versions.filter(identifier=branch)
1236-
| self.versions.filter(identifier="remotes/origin/%s" % branch)
1237-
| self.versions.filter(identifier="origin/%s" % branch)
1238-
| self.versions.filter(verbose_name=branch)
1234+
def versions_from_name(self, name, type=None):
1235+
"""
1236+
Get all versions attached to the branch or tag name.
1237+
1238+
Normally, only one version should be returned, but since LATEST and STABLE
1239+
are aliases for the branch/tag, they may be returned as well.
1240+
1241+
If type is None, both, tags and branches will be taken into consideration.
1242+
"""
1243+
queryset = self.versions(manager=INTERNAL)
1244+
queryset = queryset.filter(
1245+
# Normal branches
1246+
Q(verbose_name=name, machine=False)
1247+
# Latest and stable have the name of the branch/tag in the identifier.
1248+
# NOTE: if stable is a branch, identifier will be the commit hash,
1249+
# so we don't have a way to match it by name.
1250+
# We should do another lookup to get the original stable version,
1251+
# or change our logic to store the tags name in the identifier of stable.
1252+
| Q(identifier=name, machine=True)
12391253
)
12401254

1255+
if type:
1256+
queryset = queryset.filter(type=type)
1257+
1258+
return queryset.distinct()
1259+
12411260
def get_default_version(self):
12421261
"""
12431262
Get the default version (slug).

0 commit comments

Comments
 (0)