Skip to content

Commit de246b9

Browse files
committed
Allow to build an external version based on its source and base branch.
- Use a namedtuple to pass more data about the external version around - Automation rules can now receive extra kwargs to be passed down to the match and action functions - Automation rules now return a tuple, the first element indicate if rule matched and the second is the return value from the action, this was done, so we can know if the build was triggered or not for external versions. - Moved the actions to the base class, since it doesn't look like we are going to have a different set of actions - If the user doesn't have automation rules for external versions we just build everything as before, but if the user has at least one, we use the rules to trigger the build. Ref #7653
1 parent 555194f commit de246b9

File tree

14 files changed

+483
-135
lines changed

14 files changed

+483
-135
lines changed

docs/automation-rules.rst

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ Actions
5050
When a rule matches a new version, the specified action is performed on that version.
5151
Currently, the following actions are available:
5252

53+
For branches and tags
54+
~~~~~~~~~~~~~~~~~~~~~
55+
5356
- **Activate version**: Activates and builds the version.
5457
- **Hide version**: Hides the version. If the version is not active, activates it and builds the version.
5558
See :ref:`versions:Version States`.
@@ -71,14 +74,24 @@ Currently, the following actions are available:
7174
You can use the ``Set version as default`` action to change the default version
7275
before deleting the current one.
7376

74-
7577
.. note::
7678

7779
If your versions follow :pep:`440`,
7880
Read the Docs activates and builds the version if it's greater than the current stable version.
7981
The stable version is also automatically updated at the same time.
8082
See more in :doc:`versions`.
8183

84+
For pull/merge requests
85+
~~~~~~~~~~~~~~~~~~~~~~~
86+
87+
- **Build version**: When a pull request is opened or re-opened,
88+
build the version if it matches the source and base branch.
89+
90+
.. note::
91+
92+
By default Read the Docs will build all versions from pull requests
93+
if there aren't any rules for external versions.
94+
8295
Order
8396
-----
8497

@@ -141,6 +154,22 @@ Activate all new tags and branches that start with ``v`` or ``V``
141154
- Version type: ``Branch``
142155
- Action: ``Activate version``
143156

157+
Build pull requests from the ``main`` branch only
158+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
159+
160+
- Custom match: ``.*``
161+
- Version type: ``External``
162+
- Base branch: ``^main$``
163+
- Action: ``Build version``
164+
165+
Build all pull request where the source branch has the ``docs/`` prefix
166+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
167+
168+
- Custom match: ``^docs/``
169+
- Version type: ``External``
170+
- Base branch: ``.*``
171+
- Action: ``Build version``
172+
144173
Activate all new tags that don't contain the ``-nightly`` suffix
145174
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
146175

docs/guides/autobuild-docs-for-pull-requests.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ Features
3939
and after the build has finished we send success notification if the build succeeded without any error
4040
or failure notification if the build failed.
4141

42+
- **Build based on the source and base branch**: By default we build all pull requests,
43+
but you can setup rules to build only from a given base branch,
44+
or from pull requests where the source branch matches a pattern.
45+
See :doc:`/automation-rules`.
46+
4247
.. figure:: ../_static/images/guides/github-build-status-reporting.gif
4348
:align: center
4449
:alt: GitHub Build Status Reporting for Pull Requests.

readthedocs/api/v2/views/integrations.py

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import hashlib
44
import hmac
55
import json
6+
from functools import namedtuple
67
import logging
78
import re
89

@@ -56,6 +57,12 @@
5657
BITBUCKET_PUSH = 'repo:push'
5758

5859

60+
ExternalVersionData = namedtuple(
61+
'ExternalVersionData',
62+
['id', 'source_branch', 'base_branch', 'commit'],
63+
)
64+
65+
5966
class WebhookMixin:
6067

6168
"""Base class for Webhook mixins."""
@@ -227,20 +234,20 @@ def get_external_version_response(self, project):
227234
:param project: Project instance
228235
:type project: readthedocs.projects.models.Project
229236
"""
230-
identifier, verbose_name = self.get_external_version_data()
237+
version_data = self.get_external_version_data()
231238
# create or get external version object using `verbose_name`.
232-
external_version = get_or_create_external_version(
233-
project, identifier, verbose_name
234-
)
239+
external_version = get_or_create_external_version(project, version_data)
235240
# returns external version verbose_name (pull/merge request number)
236241
to_build = build_external_version(
237-
project=project, version=external_version, commit=identifier
242+
project=project,
243+
version=external_version,
244+
version_data=version_data,
238245
)
239246

240247
return {
241-
'build_triggered': True,
248+
'build_triggered': bool(to_build),
242249
'project': project.slug,
243-
'versions': [to_build],
250+
'versions': [to_build] if to_build else [],
244251
}
245252

246253
def get_delete_external_version_response(self, project):
@@ -258,11 +265,9 @@ def get_delete_external_version_response(self, project):
258265
:param project: Project instance
259266
:type project: Project
260267
"""
261-
identifier, verbose_name = self.get_external_version_data()
268+
version_data = self.get_external_version_data()
262269
# Delete external version
263-
deleted_version = delete_external_version(
264-
project, identifier, verbose_name
265-
)
270+
deleted_version = delete_external_version(project, version_data)
266271
return {
267272
'version_deleted': deleted_version is not None,
268273
'project': project.slug,
@@ -320,13 +325,16 @@ def get_data(self):
320325
def get_external_version_data(self):
321326
"""Get Commit Sha and pull request number from payload."""
322327
try:
323-
identifier = self.data['pull_request']['head']['sha']
324-
verbose_name = str(self.data['number'])
325-
326-
return identifier, verbose_name
328+
data = ExternalVersionData(
329+
id=str(self.data['number']),
330+
commit=self.data['pull_request']['head']['sha'],
331+
source_branch=self.data['pull_request']['head']['ref'],
332+
base_branch=self.data['pull_request']['base']['ref'],
333+
)
334+
return data
327335

328336
except KeyError:
329-
raise ParseError('Parameters "sha" and "number" are required')
337+
raise ParseError('Invalid payload')
330338

331339
def is_payload_valid(self):
332340
"""
@@ -530,13 +538,16 @@ def is_payload_valid(self):
530538
def get_external_version_data(self):
531539
"""Get commit SHA and merge request number from payload."""
532540
try:
533-
identifier = self.data['object_attributes']['last_commit']['id']
534-
verbose_name = str(self.data['object_attributes']['iid'])
535-
536-
return identifier, verbose_name
541+
data = ExternalVersionData(
542+
id=str(self.data['object_attributes']['iid']),
543+
commit=self.data['object_attributes']['last_commit']['id'],
544+
source_branch=self.data['object_attributes']['source_branch'],
545+
base_branch=self.data['object_attributes']['target_branch'],
546+
)
547+
return data
537548

538549
except KeyError:
539-
raise ParseError('Parameters "id" and "iid" are required')
550+
raise ParseError('Invalid payload')
540551

541552
def handle_webhook(self):
542553
"""

readthedocs/builds/automation_actions.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import logging
1212

13+
from readthedocs.builds.utils import match_regex
1314
from readthedocs.core.utils import trigger_build
1415
from readthedocs.projects.constants import PRIVATE, PUBLIC
1516

@@ -31,6 +32,29 @@ def activate_version(version, match_result, action_arg, *args, **kwargs):
3132
)
3233

3334

35+
def build_external_version(version, match_result, action_arg, version_data, **kwargs):
36+
"""
37+
Build an external version if matches the given base branch.
38+
39+
:param action_arg: A pattern to match the base branch.
40+
:param version_data: `ExternalVersionData` instance.
41+
:returns: A boolean indicating if the build was triggered.
42+
"""
43+
base_branch_regex = action_arg
44+
result = match_regex(
45+
base_branch_regex,
46+
version_data.base_branch,
47+
)
48+
if result:
49+
trigger_build(
50+
project=version.project,
51+
version=version,
52+
commit=version.identifier,
53+
)
54+
return True
55+
return False
56+
57+
3458
def set_default_version(version, match_result, action_arg, *args, **kwargs):
3559
"""
3660
Sets version as the project's default version.

readthedocs/builds/forms.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Django forms for the builds app."""
22

3+
import itertools
34
import re
45
import textwrap
56

@@ -14,6 +15,8 @@
1415
ALL_VERSIONS,
1516
BRANCH,
1617
BRANCH_TEXT,
18+
EXTERNAL,
19+
EXTERNAL_TEXT,
1720
TAG,
1821
TAG_TEXT,
1922
)
@@ -99,8 +102,9 @@ class RegexAutomationRuleForm(forms.ModelForm):
99102
"""
100103
A regular expression to match the version.
101104
<a href="https://docs.readthedocs.io/page/automation-rules.html#user-defined-matches">
102-
Check the documentation for valid patterns.
105+
Check the documentation
103106
</a>
107+
for valid patterns.
104108
"""
105109
)),
106110
required=False,
@@ -113,6 +117,7 @@ class Meta:
113117
'predefined_match_arg',
114118
'match_arg',
115119
'version_type',
120+
'action_arg',
116121
'action',
117122
]
118123
# Don't pollute the UI with help texts
@@ -133,6 +138,7 @@ def __init__(self, *args, **kwargs):
133138
(None, '-' * 9),
134139
(BRANCH, BRANCH_TEXT),
135140
(TAG, TAG_TEXT),
141+
(EXTERNAL, EXTERNAL_TEXT),
136142
]
137143

138144
# Remove privacy actions not available in community
@@ -155,6 +161,48 @@ def __init__(self, *args, **kwargs):
155161
if self.instance.pk and self.instance.predefined_match_arg:
156162
self.initial['match_arg'] = self.instance.get_match_arg()
157163

164+
def clean_action(self):
165+
"""Check the action is allowed for the type of version."""
166+
action = self.cleaned_data['action']
167+
version_type = self.cleaned_data['version_type']
168+
internal_allowed_actions = set(
169+
itertools.chain(
170+
VersionAutomationRule.allowed_actions_on_create.keys(),
171+
VersionAutomationRule.allowed_actions_on_delete.keys(),
172+
)
173+
)
174+
allowed_actions = {
175+
BRANCH: internal_allowed_actions,
176+
TAG: internal_allowed_actions,
177+
EXTERNAL: set(VersionAutomationRule.allowed_actions_on_external_versions.keys()),
178+
}
179+
if action not in allowed_actions.get(version_type, []):
180+
raise forms.ValidationError(
181+
_('Invalid action for this version type.'),
182+
)
183+
return action
184+
185+
def clean_action_arg(self):
186+
"""
187+
Validate the action argument.
188+
189+
Currently only external versions accept this argument,
190+
and it's the pattern to match the base_branch.
191+
"""
192+
action_arg = self.cleaned_data['action_arg']
193+
version_type = self.cleaned_data['version_type']
194+
if version_type == EXTERNAL:
195+
if not action_arg:
196+
action_arg = '.*'
197+
try:
198+
re.compile(action_arg)
199+
return action_arg
200+
except Exception:
201+
raise forms.ValidationError(
202+
_('Invalid Python regular expression.'),
203+
)
204+
return ''
205+
158206
def clean_match_arg(self):
159207
"""Check that a custom match was given if a predefined match wasn't used."""
160208
match_arg = self.cleaned_data['match_arg']
@@ -185,6 +233,7 @@ def save(self, commit=True):
185233
predefined_match_arg=self.cleaned_data['predefined_match_arg'],
186234
version_type=self.cleaned_data['version_type'],
187235
action=self.cleaned_data['action'],
236+
action_arg=self.cleaned_data['action_arg'],
188237
)
189238
if not rule.description:
190239
rule.description = rule.get_description()

readthedocs/builds/managers.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,7 @@ class VersionAutomationRuleManager(PolymorphicManager):
194194
"""
195195

196196
def add_rule(
197-
self, *, project, description, match_arg, version_type,
198-
action, action_arg=None, predefined_match_arg=None,
197+
self, *, project, description, match_arg, version_type, action, **kwargs,
199198
):
200199
"""
201200
Append an automation rule to `project`.
@@ -219,9 +218,8 @@ def add_rule(
219218
priority=priority,
220219
description=description,
221220
match_arg=match_arg,
222-
predefined_match_arg=predefined_match_arg,
223221
version_type=version_type,
224222
action=action,
225-
action_arg=action_arg,
223+
**kwargs,
226224
)
227225
return rule
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 2.2.16 on 2020-11-16 17:37
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('builds', '0028_add_delete_version_action'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='versionautomationrule',
15+
name='action',
16+
field=models.CharField(choices=[('activate-version', 'Activate version'), ('hide-version', 'Hide version'), ('make-version-public', 'Make version public'), ('make-version-private', 'Make version private'), ('set-default-version', 'Set version as default'), ('delete-version', 'Delete version (on branch/tag deletion)'), ('build-external-version', 'Build version')], help_text='Action to apply to matching versions', max_length=32, verbose_name='Action'),
17+
),
18+
]

0 commit comments

Comments
 (0)