Skip to content

Commit 1aaa918

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 1aaa918

File tree

12 files changed

+448
-134
lines changed

12 files changed

+448
-134
lines changed

readthedocs/api/v2/views/integrations.py

+32-21
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

+24
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

+50-1
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

+2-4
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
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)