Skip to content

Commit d3c1cb8

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 d3c1cb8

File tree

11 files changed

+310
-113
lines changed

11 files changed

+310
-113
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

+48
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
)
@@ -113,6 +116,7 @@ class Meta:
113116
'predefined_match_arg',
114117
'match_arg',
115118
'version_type',
119+
'action_arg',
116120
'action',
117121
]
118122
# Don't pollute the UI with help texts
@@ -133,6 +137,7 @@ def __init__(self, *args, **kwargs):
133137
(None, '-' * 9),
134138
(BRANCH, BRANCH_TEXT),
135139
(TAG, TAG_TEXT),
140+
(EXTERNAL, EXTERNAL_TEXT),
136141
]
137142

138143
# Remove privacy actions not available in community
@@ -155,6 +160,48 @@ def __init__(self, *args, **kwargs):
155160
if self.instance.pk and self.instance.predefined_match_arg:
156161
self.initial['match_arg'] = self.instance.get_match_arg()
157162

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