Customizing Review Request Conditions¶
Conditions allow administrators to define rules for when most integrations are used. For example, a Slack integration can be set up to only post when a review request targets a specific repository, or when the owner is a member of a certain group.
Review Board provides a set of built-in choices to select from when configuring conditions. For example, a review request’s repository, review groups, branches, user roles, and more.
Extensions can add their own available choices. This can be used along with
custom review request fields, custom
extension-stored or API-stored data in ReviewRequest.extra_data, or with logic you
define in your extension.
There are three key things to know about building conditions:
Condition choices are created by subclassing one of the base classes from
djblets.conditions.choicesand mixing inreviewboard.reviews.conditions. ReviewRequestConditionChoiceMixin.Condition choices declare operators (such as Is, Contains, Is one of, or Starts with) by setting the
operatorsattribute.Condition choices are then registered by passing their classes (not instances) to
ReviewRequestConditionChoicesHook.
Choosing a Base Class¶
Djblets_, Review Board’s companion library for writing extensions, provides several ready-made base classes you can choose from. Each has a built-in set of standard operators.
These are:
djblets.conditions.choices.BaseConditionBooleanChoiceUsed for boolean (true/false) values. This provides:
Is (with a True / False selector)
djblets.conditions.choices.BaseConditionIntegerChoiceUsed for integer values. This provides:
Is / Is not
Greater than / Less than
djblets.conditions.choices.BaseConditionStringChoiceUsed for text/string values. This provides the operators:
Is / Is not
Contains / Does not contain
Starts with / Ends with
Matches regex / Does not match regex
djblets.conditions.choices.BaseConditionModelChoiceUsed to select an optional single database object from a list. This provides:
Is unset
Is / Is not
djblets.conditions.choices.BaseConditionRequiredModelChoiceUsed to select a required single database object from a list. This provides:
Is / Is not
djblets.conditions.choices.BaseConditionModelMultipleChoiceUsed to select one or more database options from a list.
Any / None
Is one of / Is not one of
djblets.conditions.choices.BaseConditionChoiceA base class for defining entirely-new condition choices.
Subclasses are required to set
operatorsanddefault_value_field.
Creating a Choice¶
To create a condition choice:
Subclass one of the base classes above and mix in
reviewboard.reviews.conditions.ReviewRequestConditionChoiceMixin.Define the required attributes:
-
A unique string identifier for the choice.
You should use a vendor or extension prefix to avoid conflicts with Review Board’s built-in choices or other extensions.
Allowed characters:
A-Z,a-z,0-9,-, and_. -
The human-readable name shown in the condition selector. This should be short and descriptive.
-
Define the required method:
-
Returns the value that will be matched against, using the user’s selected operator.
This would return any data your extension stores or computes that would determine if the condition choice applies.
This will receive the review request as its first argument, and must take
**kwargsas the last argument.We’ll talk about this more in a minute.
-
Here’s an example condition choice that matches a milestone stored by your extension.
from djblets.conditions.choices import BaseConditionStringChoice
from reviewboard.reviews.conditions import ReviewRequestConditionChoiceMixin
from reviewboard.reviews.models import ReviewRequest
class MilestoneChoice(ReviewRequestConditionChoiceMixin,
BaseConditionStringChoice):
choice_id = 'myvendor_my-milestone'
name = 'Milestone'
def get_match_value(
self,
review_request: ReviewRequest,
**kwargs,
) -> str:
return review_request.extra_data.get('myvendor_milestone', '')
Model Choices¶
BaseConditionModelChoice,
BaseConditionRequiredModelChoice,
and BaseConditionModelMultipleChoice
all work off of the database, using Django database models.
These take a
queryset
attribute that populates the entries to show and match against.
You’ll usually only use this if your extension is providing its own
database models that have a relation back to the
ReviewRequest model.
For example:
from django.db.models import QuerySet
from djblets.conditions.choices import BaseConditionModelMultipleChoice
from reviewboard.reviews.conditions import ReviewRequestConditionChoiceMixin
from reviewboard.reviews.models import ReviewRequest
from myextension.models import Project
class ProjectsChoice(ReviewRequestConditionChoiceMixin,
BaseConditionModelMultipleChoice):
choice_id = 'myvendor_projects'
name = 'Projects'
queryset = Project.objects.all()
def get_match_value(
self,
review_request: ReviewRequest,
**kwargs,
) -> QuerySet[Project]:
# Return all entries associated with this review request.
return review_request.myvendor_projects.all()
Caching Match Values¶
Computing a match value may be expensive. For example, you might query the database, or you might look something up in an external system. In these cases, you’ll want to cache the computed match value.
Caching avoids performing these lookups or computations multiple times in the event where the user has configured your condition choice multiple times in the same set of rules.
get_match_value()
takes a value_state_cache keyword argument, which is a dictionary shared
across all conditions the user has chosen. Any state you compute can (and
should!) be stored there.
We’ll update our example from above to build in some caching:
from collections.abc import Sequence
from djblets.conditions.choices import BaseConditionModelMultipleChoice
from djblets.conditions.values import ValueStateCache
from reviewboard.reviews.conditions import ReviewRequestConditionChoiceMixin
from reviewboard.reviews.models import ReviewRequest
from myextension.models import Project
class ProjectsChoice(ReviewRequestConditionChoiceMixin,
BaseConditionModelMultipleChoice):
choice_id = 'myvendor_projects'
name = 'Projects'
queryset = Project.objects.all()
def get_match_value(
self,
review_request: ReviewRequest,
value_state_cache: ValueStateCache,
**kwargs,
) -> Sequence[Project]:
try:
projects = value_state_cache['myvendor_projects']
except KeyError:
projects = list(review_request.myvendor_projects.all())
value_state_cache['myvendor_projects'] = projects
return projects
Matching Against a List of Values¶
Some choices represent a list of values (such as the files changed in a diff). You may want to give users the ability to match against any item in the list, instead of trying to match the entire list itself.
In the diff files example, you may want the user to be able to choose
Changed file path -> Starts with -> “docs/”, and if any of the
files start with docs/, it would be a match.
To do this, simply mix
ConditionChoiceMatchListItemsMixin
into your class.
For example:
from collections.abc import Sequence
from djblets.conditions.choices import (BaseConditionStringChoice,
ConditionChoiceMatchListItemsMixin)
from djblets.conditions.values import ValueStateCache
from reviewboard.reviews.conditions import ReviewRequestConditionChoiceMixin
from reviewboard.reviews.models import ReviewRequest
class ChangedFileChoice(ConditionChoiceMatchListItemsMixin,
ReviewRequestConditionChoiceMixin,
BaseConditionStringChoice):
choice_id = 'myvendor_changed-file'
name = 'Changed file path'
def get_match_value(
self,
review_request: ReviewRequest,
value_state_cache: ValueStateCache,
**kwargs,
) -> Sequence[str]:
try:
files = value_state_cache['myvendor_changed_files']
except KeyError:
diffset = review_request.get_latest_diffset()
files: list[str] = []
if diffset is not None:
files = list(diffset.files.values_list('dest_file',
flat=True))
value_state_cache['myvendor_changed_files'] = files
return files
This is best used with
ContainsOperator and
DoesNotContainOperator.
If you want to require that every item in the list matches, instead of just
any item, you can set require_match_all_items to True on the class:
...
class ChangedFileChoice(ConditionChoiceMatchListItemsMixin,
ReviewRequestConditionChoiceMixin,
BaseConditionStringChoice):
require_match_all_items = True
...
Customizing Operators¶
Every condition choice needs operators.
Operators define how a match value is compared against a user’s provided value.
As we saw above, there are base classes for condition choices that provide a default list of operators. You can use these as-is, or you can customize the list.
For example, if we wanted to use
BaseConditionStringChoice but limit
our operators to Is one of and Is not one of, we could
do:
from djblets.conditions.choices import BaseConditionStringChoice
from djblets.conditions.operators import (ConditionOperators,
IsNotOneOfOperator,
IsOneOfOperator)
from reviewboard.reviews.conditions import ReviewRequestConditionChoiceMixin
class MilestoneChoice(ReviewRequestConditionChoiceMixin,
BaseConditionStringChoice):
...
operators = ConditionOperators([
IsOneOfOperator,
IsNotOneOfOperator,
])
Built-in operators¶
There are many built-in operators you can use. Most have a twin operator that matches the opposite values, so we’ll group them that way:
Check whether there’s a match value:
AnyOperator(Has a value)UnsetOperator(Is unset)
Check whether a value equals the match value:
IsOperator(Is)IsNotOperator(Is not)
Check whether a match value is found in a set of user-provided values:
IsOneOfOperator(Is one of)IsNotOneOfOperator(Is not one of)
Check whether the user-provided value is found in a set of match values:
ContainsOperator(Contains)DoesNotContainOperator(Does not contain)
Check whether the match value contains any of the user-provided values:
ContainsAnyOperator(Any of)DoesNotContainAnyOperator(Not any of)
Checks whether the match value starts with a user-provided prefix:
StartsWithOperator(Starts with)
Checks whether the match value ends with a user-provided suffix:
EndsWithOperator(Ends with)
Checks whether the match value is greater than a user-provided value:
GreaterThanOperator(Greater than)
Checks whether the match value is less than a user-provided value:
LessThanOperator(Less than)
Checks whether the user-provided Python Regex (Regular Expression) pattern matches the match value:
MatchesRegexOperator(Matches regex)DoesNotMatchRegexOperator(Does not match regex)
Renaming an Operator¶
Sometimes the default label for an operator isn’t quite right for your condition choice. You may want to give it a different name.
You can easily do this with
with_overrides():
from djblets.conditions.operators import AnyOperator, UnsetOperator
...
operators = ConditionOperators([
AnyOperator.with_overrides(name='Has any projects'),
UnsetOperator.with_overrides(name='Has no projects'),
])
Writing a Custom Operator¶
The built-in operators may not be right for your condition choice. You may want to create your own operator.
To write a custom operator:
Subclass
djblets.conditions.operators.BaseConditionOperator.Define the required attributes:
-
A unique string identifier for the operator.
You should use a vendor or extension prefix to avoid conflicts with Review Board’s built-in operators or other extensions.
Allowed characters:
A-Z,a-z,0-9,-, and_. -
The human-readable name shown in the operator selector. This should be short and descriptive.
-
The field type the user will use to provide a value.
This can be explicitly set to
Noneif the operator doesn’t take a value. If it’s omitted from the class, the choice’s default value field will be used.
-
Define the required method:
-
Returns whether the operator matches the choice-provided match value against the user-provided condition value (if one is available).
-
For example:
from djblets.conditions.choices import BaseConditionStringChoice
from djblets.conditions.operators import (BaseConditionOperator,
ConditionOperators)
from reviewboard.reviews.conditions import ReviewRequestConditionChoiceMixin
from reviewboard.reviews.models import ReviewRequest
# This operator doesn't take a provided value. It just matches
# against the string "urgent".
class IsUrgentOperator(BaseConditionOperator):
operator_id = 'myvendor_is-urgent'
name = 'Is urgent'
value_field = None # No user-provided value is needed.
def matches(
self,
match_value: str,
**kwargs,
) -> bool:
return match_value == 'urgent'
# This operator takes a text field meant to include a priority level
# that would append to "priority". For example, a user-provided value
# of "1" would match "priority1".
#
# This doesn't set `value_field`, so it uses the default for the
# choice.
class IsPriorityLevelOperator(BaseConditionOperator):
operator_id = 'myvendor_priority-level'
name = 'Priority level'
def matches(
self,
match_value: str,
condition_value: str,
**kwargs,
) -> bool:
return match_value == f'priority{condition_value}'
class PriorityChoice(ReviewRequestConditionChoiceMixin,
BaseConditionStringChoice):
choice_id = 'myvendor_my-priority'
name = 'Priority'
operators = ConditionOperators([
IsUrgentOperator,
IsPriorityLevelOperator,
])
def get_match_value(
self,
review_request: ReviewRequest,
**kwargs,
) -> str:
return review_request.extra_data.get('myvendor_priority', '')
Customizing the Value Field¶
We talked about an operator’s value field. Operators may set a custom field, disable a field, or fall back on the choice’s value field.
If you’re subclassing one of the built-in condition choice classes, like
BaseConditionIntegerChoice, a suitable
default value field will be supplied for you.
If you want to customize the field used, you can override
default_value_field.
In most cases, you’ll want to use one of the pre-built field types:
-
A drop-down with “True” and “False” entries.
-
Single-line text input.
-
A field that validates as an integer.
-
A text input that validates and compiles as a Python Regex (Regular Expression), used for pattern matching.
-
A field for selecting a specific database model entry.
ConditionValueMultipleModelFieldA field for selecting zero or more database model entries.
ConditionValueMultipleChoiceFieldA multiple choice of values (as we’ll see next).
We’ll look at a couple of these.
Restricting to a Fixed Set of Values¶
A good reason to set your own field is to limit the options available to
the user. You can use
ConditionValueMultipleChoiceField to
provide a fixed list of options to choose from.
Each option in the list is a Python tuple in the form of (value, label).
It also takes a generic type (the [str] after
the name in the code sample below), which specifies the type of value found in
the list.
This is best used with
IsOneOfOperator and
IsNotOneOfOperator.
For example:
from djblets.conditions.choices import BaseConditionStringChoice
from djblets.conditions.operators import (ConditionOperators,
IsNotOneOfOperator,
IsOneOfOperator)
from djblets.conditions.values import ConditionValueMultipleChoiceField
from reviewboard.reviews.conditions import ReviewRequestConditionChoiceMixin
class CategoryChoice(ReviewRequestConditionChoiceMixin,
BaseConditionStringChoice):
choice_id = 'myvendor_category'
name = 'Category'
operators = ConditionOperators([
IsOneOfOperator,
IsNotOneOfOperator,
])
default_value_field = ConditionValueMultipleChoiceField[str](choices=[
('architecture', 'Architecture'),
('bug', 'Bug'),
('docs', 'Documentation'),
('feature', 'Feature'),
('security', 'Security'),
('whimsy', 'Whimsy'),
])
...
Using a Django Form Field¶
Another reason to override the field type is to use a specific (or a custom)
Django form field. You’ll wrap this with
ConditionValueFormField.
You can use this to customize the attributes going into the form or the validation behavior. Any Django form field can be provided.
For example:
from django import forms
from djblets.conditions.choices import BaseConditionIntegerChoice
from djblets.conditions.values import ConditionValueFormField
from reviewboard.reviews.conditions import ReviewRequestConditionChoiceMixin
from reviewboard.reviews.models import ReviewRequest
class ScoreChoice(ReviewRequestConditionChoiceMixin,
BaseConditionIntegerChoice):
choice_id = 'myvendor_score'
name = 'Review Score'
default_value_field = ConditionValueFormField(forms.IntegerField(
min_value=0,
max_value=100,
))
def get_match_value(
self,
review_request: ReviewRequest,
**kwargs,
) -> int:
return review_request.extra_data.get('myvendor_score', 0)
Registering Choices¶
In order for your condition choice to appear as an option for users, you’ll need to register it. This is done by passing one or more condition choice classes (not instances) to a ReviewRequestConditionChoicesHook:
from reviewboard.extensions.base import Extension
from reviewboard.extensions.hooks import ReviewRequestConditionChoicesHook
class MyExtension(Extension):
def initialize(self) -> None:
ReviewRequestConditionChoicesHook(self, [
MyCategoryChoice,
MyTaskIDChoice,
])
Your choices will immediately appear in the condition selector when administrators configure any integration.