Skip to content

Commit 555194f

Browse files
stsewdhumitos
andauthored
Automation rules: add delete version action (#7644)
Currently we automatically delete inactive versions when they are deleted from the repository. If the version is active we keep it, and users must deactivate it manually. With this rule users can automate that. Default versions can't deactivate it, users must change the default version first. This is the only rule that is run when a version is deleted. Closes #7637 Co-authored-by: Manuel Kaufmann <[email protected]>
1 parent 4d0ec15 commit 555194f

File tree

8 files changed

+226
-22
lines changed

8 files changed

+226
-22
lines changed

docs/automation-rules.rst

+17
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ Currently, the following actions are available:
6161
i.e. the version of your project that `/` redirects to.
6262
See more in :ref:`automatic-redirects:Root URL`.
6363
It also activates and builds the version.
64+
- **Delete version**: When a branch or tag is deleted from your repository,
65+
Read the Docs will delete it *only if isn't active*.
66+
This action allows you to delete *active* versions when a branch or tag is deleted from your repository.
67+
68+
.. note::
69+
70+
The default version isn't deleted even if it matches a rule.
71+
You can use the ``Set version as default`` action to change the default version
72+
before deleting the current one.
73+
6474

6575
.. note::
6676

@@ -99,6 +109,13 @@ Activate only new branches that belong to the ``1.x`` release
99109
- Version type: ``Branch``
100110
- Action: ``Activate version``
101111

112+
Delete an active version when a branch is deleted
113+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
114+
115+
- Match: ``Any version``
116+
- Version type: ``Branch``
117+
- Action: ``Delete version``
118+
102119
Set as default new tags that have the ``-stable`` or ``-release`` suffix
103120
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
104121

readthedocs/api/v2/utils.py

+41-13
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
STABLE_VERBOSE_NAME,
1515
TAG,
1616
)
17-
from readthedocs.builds.models import Version
17+
from readthedocs.builds.models import RegexAutomationRule, Version
1818

1919
log = logging.getLogger(__name__)
2020

@@ -139,8 +139,7 @@ def _set_or_create_version(project, slug, version_id, verbose_name, type_):
139139
return version, False
140140

141141

142-
def delete_versions_from_db(project, version_data):
143-
"""Delete all versions not in the current repo."""
142+
def _get_deleted_versions_qs(project, version_data):
144143
# We use verbose_name for tags
145144
# because several tags can point to the same identifier.
146145
versions_tags = [
@@ -153,7 +152,6 @@ def delete_versions_from_db(project, version_data):
153152
to_delete_qs = (
154153
project.versions
155154
.exclude(uploaded=True)
156-
.exclude(active=True)
157155
.exclude(slug__in=NON_REPOSITORY_VERSIONS)
158156
)
159157

@@ -165,33 +163,63 @@ def delete_versions_from_db(project, version_data):
165163
type=BRANCH,
166164
identifier__in=versions_branches,
167165
)
166+
return to_delete_qs
167+
168+
169+
def delete_versions_from_db(project, version_data):
170+
"""
171+
Delete all versions not in the current repo.
168172
169-
ret_val = set(to_delete_qs.values_list('slug', flat=True))
170-
if ret_val:
173+
:returns: The slug of the deleted versions from the database.
174+
"""
175+
to_delete_qs = (
176+
_get_deleted_versions_qs(project, version_data)
177+
.exclude(active=True)
178+
)
179+
deleted_versions = set(to_delete_qs.values_list('slug', flat=True))
180+
if deleted_versions:
171181
log.info(
172182
'(Sync Versions) Deleted Versions: project=%s, versions=[%s]',
173-
project.slug, ' '.join(ret_val),
183+
project.slug, ' '.join(deleted_versions),
174184
)
175185
to_delete_qs.delete()
176186

177-
return ret_val
187+
return deleted_versions
188+
189+
190+
def get_deleted_active_versions(project, version_data):
191+
"""Return the slug of active versions that were deleted from the repository."""
192+
to_delete_qs = (
193+
_get_deleted_versions_qs(project, version_data)
194+
.filter(active=True)
195+
)
196+
return set(to_delete_qs.values_list('slug', flat=True))
178197

179198

180-
def run_automation_rules(project, versions_slug):
199+
def run_automation_rules(project, added_versions, deleted_active_versions):
181200
"""
182201
Runs the automation rules on each version.
183202
184203
The rules are sorted by priority.
185204
205+
:param added_versions: Slugs of versions that were added.
206+
:param deleted_active_versions: Slugs of active versions that were deleted from the repository.
207+
186208
.. note::
187209
188210
Currently the versions aren't sorted in any way,
189211
the same order is keeped.
190212
"""
191-
versions = project.versions.filter(slug__in=versions_slug)
192-
rules = project.automation_rules.all()
193-
for version, rule in itertools.product(versions, rules):
194-
rule.run(version)
213+
class_ = RegexAutomationRule
214+
actions = [
215+
(added_versions, class_.allowed_actions_on_create),
216+
(deleted_active_versions, class_.allowed_actions_on_delete),
217+
]
218+
for versions_slug, allowed_actions in actions:
219+
versions = project.versions.filter(slug__in=versions_slug)
220+
rules = project.automation_rules.filter(action__in=allowed_actions)
221+
for version, rule in itertools.product(versions, rules):
222+
rule.run(version)
195223

196224

197225
class RemoteOrganizationPagination(PageNumberPagination):

readthedocs/api/v2/views/model_views.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
RemoteOrganizationPagination,
5454
RemoteProjectPagination,
5555
delete_versions_from_db,
56+
get_deleted_active_versions,
5657
run_automation_rules,
5758
sync_versions_to_db,
5859
)
@@ -220,6 +221,7 @@ def sync_versions(self, request, **kwargs): # noqa: D205
220221
)
221222
added_versions.update(ret_set)
222223
deleted_versions = delete_versions_from_db(project, data)
224+
deleted_active_versions = get_deleted_active_versions(project, data)
223225
except Exception as e:
224226
log.exception('Sync Versions Error')
225227
return Response(
@@ -233,7 +235,7 @@ def sync_versions(self, request, **kwargs): # noqa: D205
233235
# The order of added_versions isn't deterministic.
234236
# We don't track the commit time or any other metadata.
235237
# We usually have one version added per webhook.
236-
run_automation_rules(project, added_versions)
238+
run_automation_rules(project, added_versions, deleted_active_versions)
237239
except Exception:
238240
# Don't interrupt the request if something goes wrong
239241
# in the automation rules.

readthedocs/builds/automation_actions.py

+15
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@
88
- action_arg: An additional argument to apply the action
99
"""
1010

11+
import logging
12+
1113
from readthedocs.core.utils import trigger_build
1214
from readthedocs.projects.constants import PRIVATE, PUBLIC
1315

16+
log = logging.getLogger(__name__)
17+
1418

1519
def activate_version(version, match_result, action_arg, *args, **kwargs):
1620
"""
@@ -62,3 +66,14 @@ def set_private_privacy_level(version, match_result, action_arg, *args, **kwargs
6266
"""Sets the privacy_level of the version to private."""
6367
version.privacy_level = PRIVATE
6468
version.save()
69+
70+
71+
def delete_version(version, match_result, action_arg, *args, **kwargs):
72+
"""Delete a version if isn't marked as the default version."""
73+
if version.project.default_version == version.slug:
74+
log.info(
75+
"Skipping deleting default version. project=%s version=%s",
76+
version.project.slug, version.slug,
77+
)
78+
return
79+
version.delete()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 2.2.16 on 2020-11-05 19:26
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('builds', '0027_add_privacy_level_automation_rules'),
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)')], help_text='Action to apply to matching versions', max_length=32, verbose_name='Action'),
17+
),
18+
]

readthedocs/builds/models.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -947,6 +947,7 @@ class VersionAutomationRule(PolymorphicModel, TimeStampedModel):
947947
"""Versions automation rules for projects."""
948948

949949
ACTIVATE_VERSION_ACTION = 'activate-version'
950+
DELETE_VERSION_ACTION = 'delete-version'
950951
HIDE_VERSION_ACTION = 'hide-version'
951952
MAKE_VERSION_PUBLIC_ACTION = 'make-version-public'
952953
MAKE_VERSION_PRIVATE_ACTION = 'make-version-private'
@@ -958,8 +959,12 @@ class VersionAutomationRule(PolymorphicModel, TimeStampedModel):
958959
(MAKE_VERSION_PUBLIC_ACTION, _('Make version public')),
959960
(MAKE_VERSION_PRIVATE_ACTION, _('Make version private')),
960961
(SET_DEFAULT_VERSION_ACTION, _('Set version as default')),
962+
(DELETE_VERSION_ACTION, _('Delete version (on branch/tag deletion)')),
961963
)
962964

965+
allowed_actions_on_create = {}
966+
allowed_actions_on_delete = {}
967+
963968
project = models.ForeignKey(
964969
Project,
965970
related_name='automation_rules',
@@ -1052,14 +1057,17 @@ def match(self, version, match_arg):
10521057

10531058
def apply_action(self, version, match_result):
10541059
"""
1055-
Apply the action from allowed_actions.
1060+
Apply the action from allowed_actions_on_*.
10561061
10571062
:type version: readthedocs.builds.models.Version
10581063
:param any match_result: Additional context from the match operation
10591064
:raises: NotImplementedError if the action
10601065
isn't implemented or supported for this rule.
10611066
"""
1062-
action = self.allowed_actions.get(self.action)
1067+
action = (
1068+
self.allowed_actions_on_create.get(self.action)
1069+
or self.allowed_actions_on_delete.get(self.action)
1070+
)
10631071
if action is None:
10641072
raise NotImplementedError
10651073
action(version, match_result, self.action_arg)
@@ -1166,14 +1174,18 @@ class RegexAutomationRule(VersionAutomationRule):
11661174

11671175
TIMEOUT = 1 # timeout in seconds
11681176

1169-
allowed_actions = {
1177+
allowed_actions_on_create = {
11701178
VersionAutomationRule.ACTIVATE_VERSION_ACTION: actions.activate_version,
11711179
VersionAutomationRule.HIDE_VERSION_ACTION: actions.hide_version,
11721180
VersionAutomationRule.MAKE_VERSION_PUBLIC_ACTION: actions.set_public_privacy_level,
11731181
VersionAutomationRule.MAKE_VERSION_PRIVATE_ACTION: actions.set_private_privacy_level,
11741182
VersionAutomationRule.SET_DEFAULT_VERSION_ACTION: actions.set_default_version,
11751183
}
11761184

1185+
allowed_actions_on_delete = {
1186+
VersionAutomationRule.DELETE_VERSION_ACTION: actions.delete_version,
1187+
}
1188+
11771189
class Meta:
11781190
proxy = True
11791191

readthedocs/rtd_tests/tests/test_automation_rules.py

+49-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from unittest import mock
2+
23
import pytest
34
from django_dynamic_fixture import get
45

@@ -9,12 +10,12 @@
910
SEMVER_VERSIONS,
1011
TAG,
1112
)
12-
from readthedocs.projects.constants import PUBLIC, PRIVATE
1313
from readthedocs.builds.models import (
1414
RegexAutomationRule,
1515
Version,
1616
VersionAutomationRule,
1717
)
18+
from readthedocs.projects.constants import PRIVATE, PUBLIC
1819
from readthedocs.projects.models import Project
1920

2021

@@ -183,6 +184,53 @@ def test_action_activation(self, trigger_build):
183184
assert version.active is True
184185
trigger_build.assert_called_once()
185186

187+
@pytest.mark.parametrize('version_type', [BRANCH, TAG])
188+
def test_action_delete_version(self, version_type):
189+
slug = 'delete-me'
190+
version = get(
191+
Version,
192+
slug=slug,
193+
verbose_name=slug,
194+
project=self.project,
195+
active=True,
196+
type=version_type,
197+
)
198+
rule = get(
199+
RegexAutomationRule,
200+
project=self.project,
201+
priority=0,
202+
match_arg='.*',
203+
action=VersionAutomationRule.DELETE_VERSION_ACTION,
204+
version_type=version_type,
205+
)
206+
assert rule.run(version) is True
207+
assert not self.project.versions.filter(slug=slug).exists()
208+
209+
@pytest.mark.parametrize('version_type', [BRANCH, TAG])
210+
def test_action_delete_version_on_default_version(self, version_type):
211+
slug = 'delete-me'
212+
version = get(
213+
Version,
214+
slug=slug,
215+
verbose_name=slug,
216+
project=self.project,
217+
active=True,
218+
type=version_type,
219+
)
220+
self.project.default_version = slug
221+
self.project.save()
222+
223+
rule = get(
224+
RegexAutomationRule,
225+
project=self.project,
226+
priority=0,
227+
match_arg='.*',
228+
action=VersionAutomationRule.DELETE_VERSION_ACTION,
229+
version_type=version_type,
230+
)
231+
assert rule.run(version) is True
232+
assert self.project.versions.filter(slug=slug).exists()
233+
186234
def test_action_set_default_version(self):
187235
version = get(
188236
Version,

0 commit comments

Comments
 (0)