Skip to content

Commit 6319e42

Browse files
committed
Automation Rules: support external versions
Allow to build an external version based on its source and base branch. Ref #7653
1 parent 555194f commit 6319e42

File tree

10 files changed

+271
-105
lines changed

10 files changed

+271
-105
lines changed

readthedocs/api/v2/views/integrations.py

+30-19
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,10 +325,13 @@ 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:
329337
raise ParseError('Parameters "sha" and "number" are required')
@@ -530,10 +538,13 @@ 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:
539550
raise ParseError('Parameters "id" and "iid" are required')

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

+37
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
ALL_VERSIONS,
1515
BRANCH,
1616
BRANCH_TEXT,
17+
EXTERNAL,
18+
EXTERNAL_TEXT,
1719
TAG,
1820
TAG_TEXT,
1921
)
@@ -113,6 +115,7 @@ class Meta:
113115
'predefined_match_arg',
114116
'match_arg',
115117
'version_type',
118+
'action_arg',
116119
'action',
117120
]
118121
# Don't pollute the UI with help texts
@@ -133,6 +136,7 @@ def __init__(self, *args, **kwargs):
133136
(None, '-' * 9),
134137
(BRANCH, BRANCH_TEXT),
135138
(TAG, TAG_TEXT),
139+
(EXTERNAL, EXTERNAL_TEXT),
136140
]
137141

138142
# Remove privacy actions not available in community
@@ -155,6 +159,38 @@ def __init__(self, *args, **kwargs):
155159
if self.instance.pk and self.instance.predefined_match_arg:
156160
self.initial['match_arg'] = self.instance.get_match_arg()
157161

162+
def clean_action(self):
163+
"""Check the action is allowed for this type of rule."""
164+
action = self.cleaned_data['action']
165+
version_type = self.cleaned_data['version_type']
166+
allowed_actions = VersionAutomationRule.allowed_actions_on_external_versions.keys()
167+
if version_type == EXTERNAL and action not in allowed_actions:
168+
raise forms.ValidationError(
169+
_('Invalid action for this version type.'),
170+
)
171+
return action
172+
173+
def clean_action_arg(self):
174+
"""
175+
Validate the action argument.
176+
177+
Currently only external versions accept this argument.
178+
Indicates the pattern of the base_branch.
179+
"""
180+
action_arg = self.cleaned_data['action_arg']
181+
version_type = self.cleaned_data['version_type']
182+
if version_type == EXTERNAL:
183+
if not action_arg:
184+
action_arg = '.*'
185+
try:
186+
re.compile(action_arg)
187+
return action_arg
188+
except Exception:
189+
raise forms.ValidationError(
190+
_('Invalid Python regular expression.'),
191+
)
192+
return ''
193+
158194
def clean_match_arg(self):
159195
"""Check that a custom match was given if a predefined match wasn't used."""
160196
match_arg = self.cleaned_data['match_arg']
@@ -185,6 +221,7 @@ def save(self, commit=True):
185221
predefined_match_arg=self.cleaned_data['predefined_match_arg'],
186222
version_type=self.cleaned_data['version_type'],
187223
action=self.cleaned_data['action'],
224+
action_arg=self.cleaned_data['action_arg'],
188225
)
189226
if not rule.description:
190227
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

readthedocs/builds/models.py

+35-53
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import re
77
from shutil import rmtree
88

9-
import regex
109
from django.conf import settings
1110
from django.core.files.storage import get_storage_class
1211
from django.db import models
@@ -58,6 +57,7 @@
5857
get_bitbucket_username_repo,
5958
get_github_username_repo,
6059
get_gitlab_username_repo,
60+
match_regex,
6161
)
6262
from readthedocs.builds.version_slug import VersionSlugField
6363
from readthedocs.config import LATEST_CONFIGURATION_VERSION
@@ -952,6 +952,7 @@ class VersionAutomationRule(PolymorphicModel, TimeStampedModel):
952952
MAKE_VERSION_PUBLIC_ACTION = 'make-version-public'
953953
MAKE_VERSION_PRIVATE_ACTION = 'make-version-private'
954954
SET_DEFAULT_VERSION_ACTION = 'set-default-version'
955+
BUILD_EXTERNAL_VERSION = 'build-external-version'
955956

956957
ACTIONS = (
957958
(ACTIVATE_VERSION_ACTION, _('Activate version')),
@@ -960,10 +961,24 @@ class VersionAutomationRule(PolymorphicModel, TimeStampedModel):
960961
(MAKE_VERSION_PRIVATE_ACTION, _('Make version private')),
961962
(SET_DEFAULT_VERSION_ACTION, _('Set version as default')),
962963
(DELETE_VERSION_ACTION, _('Delete version (on branch/tag deletion)')),
964+
(BUILD_EXTERNAL_VERSION, _('Build version')),
963965
)
964966

965-
allowed_actions_on_create = {}
966-
allowed_actions_on_delete = {}
967+
allowed_actions_on_create = {
968+
ACTIVATE_VERSION_ACTION: actions.activate_version,
969+
HIDE_VERSION_ACTION: actions.hide_version,
970+
MAKE_VERSION_PUBLIC_ACTION: actions.set_public_privacy_level,
971+
MAKE_VERSION_PRIVATE_ACTION: actions.set_private_privacy_level,
972+
SET_DEFAULT_VERSION_ACTION: actions.set_default_version,
973+
}
974+
975+
allowed_actions_on_delete = {
976+
DELETE_VERSION_ACTION: actions.delete_version,
977+
}
978+
979+
allowed_actions_on_external_versions = {
980+
BUILD_EXTERNAL_VERSION: actions.build_external_version,
981+
}
967982

968983
project = models.ForeignKey(
969984
Project,
@@ -1035,16 +1050,18 @@ def run(self, version, *args, **kwargs):
10351050
Run an action if `version` matches the rule.
10361051
10371052
:type version: readthedocs.builds.models.Version
1038-
:returns: True if the action was performed
1053+
:returns: A tuple of (boolean, ANY), where the first element
1054+
indicates if the action was performed, and the second is the result
1055+
returned by the action.
10391056
"""
10401057
if version.type == self.version_type:
1041-
match, result = self.match(version, self.get_match_arg())
1058+
match, result = self.match(version, self.get_match_arg(), *args, **kwargs)
10421059
if match:
1043-
self.apply_action(version, result)
1044-
return True
1045-
return False
1060+
action_result = self.apply_action(version, result)
1061+
return True, action_result
1062+
return False, None
10461063

1047-
def match(self, version, match_arg):
1064+
def match(self, version, match_arg, *args, **kwargs):
10481065
"""
10491066
Returns True and the match result if the version matches the rule.
10501067
@@ -1067,10 +1084,11 @@ def apply_action(self, version, match_result):
10671084
action = (
10681085
self.allowed_actions_on_create.get(self.action)
10691086
or self.allowed_actions_on_delete.get(self.action)
1087+
or self.allowed_actions_on_external_versions.get(self.action)
10701088
)
10711089
if action is None:
10721090
raise NotImplementedError
1073-
action(version, match_result, self.action_arg)
1091+
return action(version, match_result, self.action_arg)
10741092

10751093
def move(self, steps):
10761094
"""
@@ -1172,52 +1190,16 @@ def __str__(self):
11721190

11731191
class RegexAutomationRule(VersionAutomationRule):
11741192

1175-
TIMEOUT = 1 # timeout in seconds
1176-
1177-
allowed_actions_on_create = {
1178-
VersionAutomationRule.ACTIVATE_VERSION_ACTION: actions.activate_version,
1179-
VersionAutomationRule.HIDE_VERSION_ACTION: actions.hide_version,
1180-
VersionAutomationRule.MAKE_VERSION_PUBLIC_ACTION: actions.set_public_privacy_level,
1181-
VersionAutomationRule.MAKE_VERSION_PRIVATE_ACTION: actions.set_private_privacy_level,
1182-
VersionAutomationRule.SET_DEFAULT_VERSION_ACTION: actions.set_default_version,
1183-
}
1184-
1185-
allowed_actions_on_delete = {
1186-
VersionAutomationRule.DELETE_VERSION_ACTION: actions.delete_version,
1187-
}
1188-
11891193
class Meta:
11901194
proxy = True
11911195

1192-
def match(self, version, match_arg):
1193-
"""
1194-
Find a match using regex.search.
1195-
1196-
.. note::
1197-
1198-
We use the regex module with the timeout
1199-
arg to avoid ReDoS.
1200-
1201-
We could use a finite state machine type of regex too,
1202-
but there isn't a stable library at the time of writting this code.
1203-
"""
1204-
try:
1205-
match = regex.search(
1206-
match_arg,
1207-
version.verbose_name,
1208-
# Compatible with the re module
1209-
flags=regex.VERSION0,
1210-
timeout=self.TIMEOUT,
1211-
)
1212-
return bool(match), match
1213-
except TimeoutError:
1214-
log.warning(
1215-
'Timeout while parsing regex. pattern=%s, input=%s',
1216-
match_arg, version.verbose_name,
1217-
)
1218-
except Exception as e:
1219-
log.info('Error parsing regex: %s', e)
1220-
return False, None
1196+
def match(self, version, match_arg, *args, **kwargs):
1197+
version_name = version.verbose_name
1198+
if version.is_external:
1199+
version_data = kwargs['version_data']
1200+
version_name = version_data.source_branch
1201+
result = match_regex(match_arg, version_name)
1202+
return bool(result), result
12211203

12221204
def get_edit_url(self):
12231205
return reverse(

0 commit comments

Comments
 (0)