Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
04c195d
Initial implementation by the AI
humitos Mar 5, 2026
c934777
Update models slightly
humitos Mar 16, 2026
4c7bde8
Initial implementation
humitos Mar 16, 2026
fb2c5db
Make the implementation to work
humitos Mar 16, 2026
79eb0eb
Migration
humitos Mar 16, 2026
1159803
Remove AI created files
humitos Mar 16, 2026
93d97ea
Merge branch 'main' into humitos/webhook-filters-v2
humitos Mar 16, 2026
8712a6f
Small refactor
humitos Mar 16, 2026
eea2d6b
Minor changes
humitos Mar 16, 2026
4474989
Order and log
humitos Mar 17, 2026
e46aa93
Support predefined values for version pattern
humitos Mar 17, 2026
3943c66
Update migrations file to reflect changes and add data migration
humitos Mar 17, 2026
3e4d7a4
Update logs
humitos Mar 17, 2026
b09f269
Version types
humitos Mar 17, 2026
6afb742
Add move priority support
humitos Mar 17, 2026
1bef78d
Update migration to match code
humitos Mar 17, 2026
0f648d1
Update version match
humitos Mar 17, 2026
41d9e1e
Remove comment
humitos Mar 17, 2026
ebaa6da
Placeholder
humitos Mar 17, 2026
b13288c
Filter by enabled/disabled
humitos Mar 17, 2026
8a7b891
Update logs
humitos Mar 17, 2026
0ee7125
Add a comment about AutomationRuleMatch metadata
humitos Mar 17, 2026
80fc1fd
Re-use constants
humitos Mar 17, 2026
21d86fa
Validate version matching
humitos Mar 17, 2026
91a67b5
Predefined match cannot be null
humitos Mar 17, 2026
727169c
Allow multiline webhook match patterns
humitos Mar 17, 2026
3a6b538
Update docstring
humitos Mar 17, 2026
d031332
Add log for matching webhook rules
humitos Mar 17, 2026
7c100b5
Update patterns
humitos Mar 17, 2026
08cba3f
Update logging
humitos Mar 17, 2026
4afb1f0
Do not check labels on push events
humitos Mar 17, 2026
7221502
Use explicit `custom-match` value instead of `None`
humitos Mar 30, 2026
8bdaa4d
Update based on feedback
humitos Apr 2, 2026
3f9b6bd
Update code based on feedback
humitos Apr 2, 2026
23c9852
Attempt to use RichSelect and RichChoice
humitos Apr 2, 2026
f106ecd
Move forms to projects/forms.py
humitos Apr 7, 2026
a423f16
Move AutomationRule to projects app
humitos Apr 7, 2026
d5fa131
Fix some issues after moving the model
humitos Apr 7, 2026
f82d613
Handle custom match
humitos Apr 7, 2026
d9fe37f
Update reference to model
humitos Apr 7, 2026
71964c5
Get commit from pull request
humitos Apr 7, 2026
a6d9473
Split .match into .match_version and .match_webhook
humitos Apr 8, 2026
d60d079
Split `match_webhook` into 3 different helper methods
humitos Apr 8, 2026
897dc44
Update migrations
humitos Apr 8, 2026
8a97671
Pass `use_data_binding` to make the field work properly
humitos Apr 8, 2026
19b5ada
Override hidden widget to allow multiplechoice
humitos Apr 8, 2026
dcfc5ff
Use a custom field to handle multichoice select with RichSelect
humitos Apr 8, 2026
12cd37e
Revert "Override hidden widget to allow multiplechoice"
humitos Apr 8, 2026
d46be50
Move data migration to another migration file
humitos Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 22 additions & 12 deletions readthedocs/api/v2/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
from readthedocs.builds.constants import STABLE
from readthedocs.builds.constants import STABLE_VERBOSE_NAME
from readthedocs.builds.constants import TAG
from readthedocs.builds.models import RegexAutomationRule
from readthedocs.builds.models import Version
from readthedocs.core.utils.db import delete_in_batches
from readthedocs.projects.models import AutomationRule


log = structlog.get_logger(__name__)
Expand Down Expand Up @@ -242,17 +242,27 @@ def run_version_automation_rules(project, added_versions, deleted_active_version
Currently the versions aren't sorted in any way,
the same order is keeped.
"""
class_ = RegexAutomationRule
actions = [
(added_versions, class_.allowed_actions_on_create),
(deleted_active_versions, class_.allowed_actions_on_delete),
]
for versions_slug, allowed_actions in actions:
versions = project.versions.filter(slug__in=versions_slug)
rules = project.automation_rules.filter(action__in=allowed_actions)
for version, rule in itertools.product(versions, rules):
if rule.match(version):
rule.run(version)
version_slugs = added_versions.union(deleted_active_versions)
versions = project.versions.filter(slug__in=version_slugs)
rules = project.automation_rules.filter(
enabled=True,
action__in=AutomationRule.VERSION_ACTIONS,
).order_by("priority")
log.info(
"Running version automation rules.",
project_slug=project.slug,
version_slugs=version_slugs,
)
for version, rule in itertools.product(versions, rules):
if rule.match_version(version):
log.info(
"Automation rule matched.",
project_slug=project.slug,
rule_id=rule.pk,
rule_version_types=rule.version_types,
version_type=version.type,
)
rule.run(version)


def normalize_build_command(command, project_slug, version_slug):
Expand Down
18 changes: 9 additions & 9 deletions readthedocs/builds/automation_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@
log = structlog.get_logger(__name__)


def trigger_build_for_version(version, action_arg, *args, **kwargs):
def trigger_build_for_version(version, *args, **kwargs):
"""Trigger a build for this version."""
if version.active:
trigger_build(project=version.project, version=version, from_webhook=True)


def activate_version(version, action_arg, *args, **kwargs):
def activate_version(version, *args, **kwargs):
"""
Sets version as active.

Expand All @@ -35,19 +35,19 @@ def activate_version(version, action_arg, *args, **kwargs):
trigger_build(project=version.project, version=version)


def set_default_version(version, action_arg, *args, **kwargs):
def set_default_version(version, *args, **kwargs):
"""
Sets version as the project's default version.

The version is activated first.
"""
activate_version(version, action_arg)
activate_version(version)
project = version.project
project.default_version = version.slug
project.save()


def hide_version(version, action_arg, *args, **kwargs):
def hide_version(version, *args, **kwargs):
"""
Sets version as hidden.

Expand All @@ -57,22 +57,22 @@ def hide_version(version, action_arg, *args, **kwargs):
version.save()

if not version.active:
activate_version(version, action_arg)
activate_version(version)


def set_public_privacy_level(version, action_arg, *args, **kwargs):
def set_public_privacy_level(version, *args, **kwargs):
"""Sets the privacy_level of the version to public."""
version.privacy_level = PUBLIC
version.save()


def set_private_privacy_level(version, action_arg, *args, **kwargs):
def set_private_privacy_level(version, *args, **kwargs):
"""Sets the privacy_level of the version to private."""
version.privacy_level = PRIVATE
version.save()


def delete_version(version, action_arg, *args, **kwargs):
def delete_version(version, *args, **kwargs):
"""Delete a version if isn't marked as the default version."""
if version.project.default_version == version.slug:
log.info(
Expand Down
12 changes: 9 additions & 3 deletions readthedocs/builds/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,21 +128,27 @@
ALL_VERSIONS = "all-versions"
ALL_VERSIONS_REGEX = r".*"
SEMVER_VERSIONS = "semver-versions"
CUSTOM_MATCH = "custom-match"

# Pattern referred from
# https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
# without naming the capturing groups and with the addition of
# allowing an optional "v" prefix.
SEMVER_VERSIONS_REGEX = r"^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" # noqa


PREDEFINED_MATCH_ARGS = (
OLD_VERSION_PREDEFINED_MATCH_PATTERNS = (
(ALL_VERSIONS, _("Any version")),
(SEMVER_VERSIONS, _("SemVer versions")),
(None, _("Custom match")),
)

PREDEFINED_MATCH_ARGS_VALUES = {
VERSION_PREDEFINED_MATCH_PATTERNS = (
(ALL_VERSIONS, _("Any version")),
(SEMVER_VERSIONS, _("SemVer versions")),
(CUSTOM_MATCH, _("Custom match")),
)

VERSION_PREDEFINED_MATCH_PATTERN_VALUES = {
ALL_VERSIONS: ALL_VERSIONS_REGEX,
SEMVER_VERSIONS: SEMVER_VERSIONS_REGEX,
}
Expand Down
54 changes: 2 additions & 52 deletions readthedocs/builds/forms.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""Django forms for the builds app."""
# TODO: this file can be completely removed once we are fully into the new automation rules.
# We won't be using these forms anymore.

import re
import textwrap
Expand All @@ -15,14 +17,11 @@
from readthedocs.builds.constants import ALL_VERSIONS
from readthedocs.builds.constants import BRANCH
from readthedocs.builds.constants import BRANCH_TEXT
from readthedocs.builds.constants import EXTERNAL
from readthedocs.builds.constants import EXTERNAL_TEXT
from readthedocs.builds.constants import TAG
from readthedocs.builds.constants import TAG_TEXT
from readthedocs.builds.models import RegexAutomationRule
from readthedocs.builds.models import Version
from readthedocs.builds.models import VersionAutomationRule
from readthedocs.builds.models import WebhookAutomationRule
from readthedocs.builds.version_slug import generate_version_slug


Expand Down Expand Up @@ -220,52 +219,3 @@ def clean_match_arg(self):

def clean_project(self):
return self.project


class WebhookAutomationRuleForm(forms.ModelForm):
project = forms.CharField(widget=forms.HiddenInput(), required=False)
match_arg = forms.CharField(
label="File pattern match",
help_text=_(
textwrap.dedent(
"Pattern to match added/modified/removed files. It can include wildcards, for example: `docs/*.md`."
)
),
required=True,
)

class Meta:
model = WebhookAutomationRule
fields = [
"project",
"description",
"match_arg",
"version_type",
"action",
]
# Don't pollute the UI with help texts
help_texts = {
"version_type": "",
"action": "",
}

def __init__(self, *args, **kwargs):
self.project = kwargs.pop("project", None)
super().__init__(*args, **kwargs)

# Only list supported types
self.fields["version_type"].choices = [
(None, "-" * 9),
(BRANCH, BRANCH_TEXT),
(TAG, TAG_TEXT),
(EXTERNAL, EXTERNAL_TEXT),
]

# Only list supported actions (webhook rules only support trigger build)
self.fields["action"].choices = [
(None, "-" * 9),
(VersionAutomationRule.TRIGGER_BUILD_ACTION, "Trigger build for version"),
]

def clean_project(self):
return self.project
2 changes: 1 addition & 1 deletion readthedocs/builds/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ class AutomationRuleMatchManager(models.Manager):
def register_match(self, rule, version, max_registers=15):
created = self.create(
rule=rule,
match_arg=rule.get_match_arg(),
match_arg=rule.get_version_match_pattern(),
action=rule.action,
version_name=version.verbose_name,
version_type=version.type,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Generated by Django 5.2.12 on 2026-04-08 11:03

import django.db.models.deletion
from django.db import migrations
from django.db import models
from django_safemigrate import Safe


class Migration(migrations.Migration):
safe = Safe.after_deploy()

dependencies = [
("builds", "0070_delete_build_old_config"),
("projects", "0159_add_automationrule_v2"),
]

operations = [
migrations.AlterField(
model_name="automationrulematch",
name="rule",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="matches",
to="projects.automationrule",
verbose_name="Matched rule",
),
),
migrations.AlterField(
model_name="versionautomationrule",
name="project",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="version_automation_rules",
to="projects.project",
),
),
]
12 changes: 6 additions & 6 deletions readthedocs/builds/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@
from readthedocs.builds.constants import EXTERNAL_VERSION_STATES
from readthedocs.builds.constants import INTERNAL
from readthedocs.builds.constants import LATEST
from readthedocs.builds.constants import PREDEFINED_MATCH_ARGS
from readthedocs.builds.constants import PREDEFINED_MATCH_ARGS_VALUES
from readthedocs.builds.constants import OLD_VERSION_PREDEFINED_MATCH_PATTERNS
from readthedocs.builds.constants import STABLE
from readthedocs.builds.constants import VERSION_PREDEFINED_MATCH_PATTERN_VALUES
from readthedocs.builds.constants import VERSION_TYPES
from readthedocs.builds.managers import AutomationRuleMatchManager
from readthedocs.builds.managers import BuildConfigManager
Expand Down Expand Up @@ -1105,7 +1105,7 @@ class VersionAutomationRule(PolymorphicModel, TimeStampedModel):

project = models.ForeignKey(
Project,
related_name="automation_rules",
related_name="version_automation_rules",
on_delete=models.CASCADE,
)
priority = models.PositiveIntegerField(
Expand All @@ -1131,7 +1131,7 @@ class VersionAutomationRule(PolymorphicModel, TimeStampedModel):
"otherwise match_arg will be used."
),
max_length=255,
choices=PREDEFINED_MATCH_ARGS,
choices=OLD_VERSION_PREDEFINED_MATCH_PATTERNS,
null=True,
blank=True,
default=None,
Expand Down Expand Up @@ -1164,7 +1164,7 @@ class Meta:

def get_match_arg(self):
"""Get the match arg defined for `predefined_match_arg` or the match from user."""
match_arg = PREDEFINED_MATCH_ARGS_VALUES.get(
match_arg = VERSION_PREDEFINED_MATCH_PATTERN_VALUES.get(
self.predefined_match_arg,
)
return match_arg or self.match_arg
Expand Down Expand Up @@ -1366,7 +1366,7 @@ class AutomationRuleMatch(TimeStampedModel):
}

rule = models.ForeignKey(
VersionAutomationRule,
"projects.AutomationRule",
verbose_name=_("Matched rule"),
related_name="matches",
on_delete=models.CASCADE,
Expand Down
Loading
Loading