Redmine Query Language (RQL)
RQL, or Redmine Query Language, is a boolean expression language built into the Redmine Plus Automation plugin. It lets you write human-readable conditions that are evaluated at runtime to decide whether an automation rule should proceed, skip, or branch.
Use RQL inside the Redmine Query Language condition node in the Automation Builder. When an automation is triggered, the RQL expression is evaluated against the current context, such as the issue, the user who triggered the action, and the current date. The expression returns true when the rule should proceed or false when the rule should be skipped.
Quick Start
issue.status.name = "In Progress" AND issue.priority.name IN ("High", "Urgent")This expression matches any issue whose status is “In Progress” and whose priority is either “High” or “Urgent”.
More examples:
issue.subject ~ "bug"Matches issues whose subject contains the word “bug”, case-insensitively.
issue.due_date < today AND issue.status.is_closed = falseMatches overdue issues that are still open.
(issue.tracker.name = "Bug" AND issue.priority.position >= 3) OR (issue.tracker.name = "Support" AND issue.subject ~ "urgent")Matches high-priority bugs or support tickets with “urgent” in the subject.
Syntax Overview
An RQL expression is made up of one or more conditions joined by logical operators.
<field> <operator> <value>Multiple conditions can be combined:
<condition> AND <condition><condition> OR <condition>(<condition> OR <condition>) AND <condition>Key rules:
- Strings must be wrapped in double quotes (
"...") or single quotes ('...'). - Numbers are written as-is:
42,3.14,-7. - Keywords such as
EMPTY,NULL,AND,OR,IN,IS, andNOTare case-insensitive. - Parentheses
( )control evaluation order. - Whitespace is ignored between tokens.
- An empty query, including a blank or whitespace-only query, evaluates to
true.
Comparison Operators
| Operator | Name | Description | Example |
|---|---|---|---|
= | Equal | Case-insensitive for strings and type-coerced for numbers | issue.status.name = "Open" |
!= | Not equal | Inverse of = | issue.priority.name != "Low" |
> | Greater than | Works with numbers, dates, and date strings | issue.done_ratio > 50 |
>= | Greater than or equal | Works with numbers, dates, and date strings | issue.start_date >= "2024-01-01" |
< | Less than | Works with numbers, dates, and date strings | issue.priority.position < 3 |
<= | Less than or equal | Works with numbers, dates, and date strings | issue.due_date <= "2026-12-31" |
~ | Contains | Case-insensitive substring match | issue.subject ~ "bug" |
!~ | Does not contain | Inverse of ~ | issue.description !~ "test" |
IN | In list | Value is in the given set | issue.status.name IN ("New", "Open") |
NOT IN | Not in list | Value is not in the given set | issue.priority.name NOT IN ("Low", "Normal") |
IS | Is | Checks for NULL or EMPTY | issue.assigned_to IS EMPTY |
IS NOT | Is not | Inverse of IS | issue.due_date IS NOT NULL |
Equality
String comparisons with = and != are case-insensitive:
issue.status.name = "open"issue.priority.name != "low"Non-string values fall back to to_s comparison, so issue.id = 42 and issue.id = "42" both work.
Numeric and Date Comparison
The >, >=, <, and <= operators work with numbers and dates. Date strings in ISO format, such as "YYYY-MM-DD", are automatically parsed.
issue.done_ratio >= 80issue.start_date > "2024-01-01"issue.estimated_hours < 10.5You can also compare fields to other fields:
issue.start_date < issue.due_dateissue.priority.position > issue.status.positionContains
The ~ and !~ operators perform a case-insensitive substring search:
issue.subject ~ "login"issue.description !~ "draft"The match value is treated as a literal string, not a regular expression. If the field is nil, the contains check safely returns false.
Membership
Use IN and NOT IN to check whether a value is in a list. The list is enclosed in parentheses with comma-separated values:
issue.status.name IN ("New", "In Progress", "Reopened")issue.tracker.name NOT IN ("Support")issue.id IN (1, 2, 3)Values inside the list can be strings or numbers. String comparison is case-insensitive.
Null and Empty Checks
Use IS and IS NOT with the keywords NULL, EMPTY, NOT_NULL, or NOT_EMPTY:
issue.assigned_to IS EMPTYissue.due_date IS NOT NULLissue.description IS NOT EMPTYissue.category IS NULLNULL and EMPTY are treated identically. Both match nil values and empty strings.
Logical Operators
| Operator | Description |
|---|---|
AND | Both sides must be true |
OR | At least one side must be true |
Logical operators are case-insensitive, so and, AND, and And all work.
Precedence
AND and OR are evaluated left to right with equal precedence. Use parentheses to make grouping explicit:
A OR B AND CWithout parentheses, this is evaluated as:
(A OR B) AND CUse explicit grouping when needed:
A OR (B AND C)Short-Circuit Evaluation
ANDstops evaluating on the firstfalse.ORstops evaluating on the firsttrue.
This means the right side is not evaluated once the result is already known.
issue.status.name = "Closed" OR issue.some_optional_field = "x"Available Fields
Fields in RQL follow dot notation: root.attribute or root.association.attribute. Available roots depend on which automation trigger runs the rule.
issue - Current Issue
The issue root is available on all issue-related triggers, such as issue created and issue updated.
Direct Attributes
| Field | Type | Description |
|---|---|---|
issue.id | Integer | Issue ID |
issue.subject | String | Issue subject line |
issue.description | String | Issue description |
issue.estimated_hours | Float | Estimated hours, defaults to 0 |
issue.done_ratio | Integer | Percent done from 0 to 100, defaults to 0 |
issue.start_date | Date | Start date |
issue.due_date | Date | Due date |
issue.is_private | Boolean | Whether the issue is private |
issue.closed_on | DateTime | When the issue was closed |
issue.created_on | DateTime | When the issue was created |
issue.updated_on | DateTime | When the issue was last updated |
issue.url | String | Full URL to the issue |
Associations
Each association exposes its own attributes.
| Field Path | Type | Description |
|---|---|---|
issue.project.* | Project | The issue’s project |
issue.author.* | User | The issue creator |
issue.assigned_to.* | User | The current assignee |
issue.status.* | IssueStatus | Current status |
issue.priority.* | IssuePriority | Current priority |
issue.tracker.* | Tracker | Issue tracker |
issue.category.* | IssueCategory | Issue category |
issue.fixed_version.* | Version | Target version |
issue.parent.* | Issue | Parent issue for subtasks |
issue.root.* | Issue | Root issue in the subtask tree |
issue.first_comment.* | Journal | First comment or note on the issue |
issue.last_comment.* | Journal | Most recent comment or note |
Issue Custom Fields
Access any issue custom field by its numeric ID:
issue.cf_1issue.cf_42Custom field values are automatically coerced to the appropriate type: integer, float, date, or string.
initiator - Triggering User
The initiator root is always available in every automation context.
| Field | Type | Description |
|---|---|---|
initiator.id | Integer | User ID |
initiator.login | String | Login name |
initiator.name | String | Full name |
initiator.firstname | String | First name |
initiator.lastname | String | Last name |
initiator.mail | String | Email address |
initiator.language | String | Language preference, such as "en" or "de" |
initiator.admin | Boolean | Whether the user is an admin |
initiator.status | Integer | User status |
initiator.created_on | DateTime | Account creation date |
initiator.updated_on | DateTime | Account last updated |
initiator.last_login_on | DateTime | Last login time |
initiator.url | String | User profile URL |
initiator.cf_<ID> | Varies | User custom fields |
today - Current Date
The today root is always available and returns the current date at the time the automation runs.
| Field | Type | Description |
|---|---|---|
today | Date | The current date, usable directly in comparisons |
today.year | Integer | Current year |
today.month | Integer | Current month from 1 to 12 |
today.day | Integer | Current day of the month |
Date Math Methods
| Method | Returns | Description |
|---|---|---|
today.plus_days(N) | Date | Add N days |
today.minus_days(N) | Date | Subtract N days |
today.plus_weeks(N) | Date | Add N weeks |
today.minus_weeks(N) | Date | Subtract N weeks |
today.plus_months(N) | Date | Add N months |
today.minus_months(N) | Date | Subtract N months |
today.plus_years(N) | Date | Add N years |
today.minus_years(N) | Date | Subtract N years |
today.format("pattern") | String | Format the date, for example "%Y-%m-%d" |
Examples:
issue.due_date <= today.plus_days(7)issue.start_date >= today.minus_days(30)issue.due_date < todaynow - Current Date and Time
The now root is always available and returns the current date and time at the time the automation runs.
| Field | Type | Description |
|---|---|---|
now.year | Integer | Current year |
now.month | Integer | Current month from 1 to 12 |
now.day | Integer | Current day |
now.hour | Integer | Current hour from 0 to 23 |
now.min | Integer | Current minute from 0 to 59 |
now.sec | Integer | Current second from 0 to 59 |
DateTime Math Methods
| Method | Returns | Description |
|---|---|---|
now.plus_days(N) | DateTime | Add N days |
now.minus_days(N) | DateTime | Subtract N days |
now.plus_weeks(N) | DateTime | Add N weeks |
now.minus_weeks(N) | DateTime | Subtract N weeks |
now.plus_months(N) | DateTime | Add N months |
now.minus_months(N) | DateTime | Subtract N months |
now.plus_years(N) | DateTime | Add N years |
now.minus_years(N) | DateTime | Subtract N years |
now.format("pattern") | String | Format the date/time, for example "%Y-%m-%d %H:%M" |
Examples:
issue.created_on > now.minus_months(1)issue.updated_on > now.minus_days(7)comment - Comment or Journal Entry
The comment root is available on the comment added trigger.
| Field | Type | Description |
|---|---|---|
comment.id | Integer | Journal entry ID |
comment.notes | String | Comment text |
comment.private_notes | Boolean | Whether the comment is private |
comment.created_on | DateTime | When the comment was created |
comment.updated_on | DateTime | When the comment was last updated |
comment.user.* | User | The user who wrote the comment |
comment.updated_by.* | User | The user who last updated the comment |
Examples:
comment.notes ~ "/approve"comment.user.login != issue.author.logincomment.private_notes = falsetime_entry - Time Entry
The time_entry root is available on time entry created and time entry updated triggers.
| Field | Type | Description |
|---|---|---|
time_entry.id | Integer | Time entry ID |
time_entry.comments | String | Time entry description |
time_entry.hours | Float | Hours logged, defaults to 0 |
time_entry.spent_on | Date | Date the time was spent |
time_entry.created_on | DateTime | When the entry was created |
time_entry.updated_on | DateTime | When the entry was last updated |
time_entry.user.* | User | The user the time is logged for |
time_entry.author.* | User | The user who created the entry |
time_entry.activity.* | TimeEntryActivity | The activity type |
Time Entry Activity Fields
| Field | Type | Description |
|---|---|---|
time_entry.activity.id | Integer | Activity ID |
time_entry.activity.name | String | Activity name, such as “Development” |
time_entry.activity.is_default | Boolean | Whether it is the default activity |
time_entry.activity.active | Boolean | Whether it is active |
time_entry.activity.position | Integer | Sort position |
Examples:
time_entry.hours > 8time_entry.activity.name = "Development"time_entry.spent_on = todaylinked_issue - Related Issue
The linked_issue root is available on the issue relation added trigger. It exposes the same fields as issue.
linked_issue.tracker.name = "Bug"linked_issue.priority.position >= 3project - Current Project
The project root represents the issue’s project.
| Field | Type | Description |
|---|---|---|
project.id | Integer | Project ID |
project.name | String | Project name |
project.description | String | Project description |
project.identifier | String | URL-friendly project identifier |
project.is_public | Boolean | Whether the project is public |
project.status | Integer | Project status |
project.created_on | DateTime | Project creation date |
project.updated_on | DateTime | Project last updated |
project.url | String | Project URL |
project.cf_<ID> | Varies | Project custom fields |
Association Attribute Reference
IssueStatus
Accessed through issue.status.*.
| Field | Type |
|---|---|
.id | Integer |
.name | String |
.description | String |
.is_closed | Boolean |
.position | Integer |
IssuePriority
Accessed through issue.priority.*.
| Field | Type |
|---|---|
.id | Integer |
.name | String |
.is_default | Boolean |
.active | Boolean |
.position | Integer |
Tracker
Accessed through issue.tracker.*.
| Field | Type |
|---|---|
.id | Integer |
.name | String |
.description | String |
IssueCategory
Accessed through issue.category.*.
| Field | Type |
|---|---|
.id | Integer |
.name | String |
Version
Accessed through issue.fixed_version.*.
| Field | Type |
|---|---|
.id | Integer |
.name | String |
.description | String |
.status | String |
.sharing | String |
.effective_date | Date |
.created_on | DateTime |
.updated_on | DateTime |
Value Methods
RQL supports calling methods on resolved field values to transform them before comparison. Methods are called using dot notation with parentheses for arguments.
String Methods
| Method | Description | Example |
|---|---|---|
.upcase | Convert to uppercase | issue.subject.upcase = "BUG FIX" |
.downcase | Convert to lowercase | issue.subject.downcase = "bug fix" |
.strip | Remove leading and trailing whitespace | issue.subject.strip = "trimmed" |
.truncate(N) | Truncate to N characters | issue.subject.truncate(10) = "Hello W..." |
Integer Methods
| Method | Description | Example |
|---|---|---|
.plus(N) | Add N | issue.done_ratio.plus(10) = 60 |
.minus(N) | Subtract N | issue.done_ratio.minus(50) = 0 |
Float Methods
| Method | Description | Example |
|---|---|---|
.plus(N) | Add N | issue.estimated_hours.plus(1.5) = 10 |
.minus(N) | Subtract N | issue.estimated_hours.minus(2) = 6.5 |
.round(N) | Round to N decimal places | issue.estimated_hours.round(0) = 9 |
.ceil(N) | Round up to N decimal places | issue.estimated_hours.ceil(0) = 9 |
.floor(N) | Round down to N decimal places | issue.estimated_hours.floor(0) = 8 |
Custom Fields
Custom fields are accessed using the cf_<ID> pattern, where <ID> is the numeric ID of the custom field in Redmine.
issue.cf_1 = "some value"issue.cf_5 > 100issue.cf_12 >= "2024-01-01"issue.cf_8 IN ("A", "B", "C")issue.cf_3 = trueissue.cf_7 IS EMPTYCustom fields are available on:
- Issues:
issue.cf_<ID> - Projects:
project.cf_<ID>orissue.project.cf_<ID> - Users:
initiator.cf_<ID>,issue.author.cf_<ID>,issue.assigned_to.cf_<ID> - Versions:
issue.fixed_version.cf_<ID> - Priorities:
issue.priority.cf_<ID>
Values are automatically coerced based on the custom field format:
| Format | RQL Type |
|---|---|
int | Integer |
float | Float |
date | Date |
| Everything else | String |
Custom Entity RQL
If the Custom Tables plugin is installed alongside Redmine Plus Automation, you can use the Custom Entity RQL condition node to query custom entity records.
custom_entity.id IS NOT NULLcustom_entity.cf_<ID> = "value"custom_entity.cf_<ID> > 100Custom entity conditions support the same operators and syntax as standard RQL. If the Custom Tables plugin is not installed, the custom entity condition node is not available.
Practical Examples
Auto-Assign Unowned High-Priority Bugs
Trigger: Issue created
Condition:
issue.tracker.name = "Bug" AND issue.priority.name IN ("High", "Urgent") AND issue.assigned_to IS EMPTYDetect Overdue Issues
Trigger: Scheduled or issue updated
issue.due_date < today AND issue.status.is_closed = falseFlag Stale Issues
Trigger: Scheduled
issue.updated_on < now.minus_days(30) AND issue.status.is_closed = falseClose Nearly Done Issues
Trigger: Issue updated
issue.done_ratio >= 80 AND issue.status.name != "Closed"Enforce Version on Close
Trigger: Issue updated
issue.status.is_closed = true AND issue.fixed_version IS EMPTYMatch Private Issues by Same Author
Trigger: Issue updated
issue.is_private = true AND issue.author.id = initiator.idOnly Top-Level Issues
issue.parent IS EMPTYOnly Subtasks
issue.parent IS NOT EMPTYComment-Based Slash Command
Trigger: Comment added
comment.notes ~ "/close"Guard Comment From Non-Author
Trigger: Comment added
comment.user.id != issue.author.idExcessive Time Logged
Trigger: Time entry created
time_entry.hours > 8Issues Within Current Release Window
Trigger: Any
issue.fixed_version.effective_date >= today AND issue.fixed_version.effective_date <= today.plus_days(14)Self-Assigned Issue Check
issue.assigned_to.id = initiator.idComplex Boolean Logic
(issue.tracker.name = "Bug" AND issue.priority.position >= 3) OR (issue.tracker.name = "Support" AND issue.subject ~ "urgent")Status Transition Guard
issue.status.name = "Resolved" AND issue.done_ratio = 100 AND issue.fixed_version IS NOT EMPTYError Handling
Parse Errors
If a query has invalid syntax, the parser returns a descriptive error message and the automation rule cannot run.
| Invalid Query | Error |
|---|---|
issue.status == "Open" | Double = is not a valid operator |
issue.status.name = Open | Unquoted string value; use "Open" |
issue.status.name = "Open | Unterminated string literal |
(issue.id > 0 | Missing closing parenthesis |
issue.id > 0) | Unexpected closing parenthesis |
@@ garbage | Unexpected character |
issue.status.name = | Expected value, got EOF |
AND issue.id > 0 | Leading operator without left-hand condition |
issue.id > 0 AND | Trailing operator without right-hand condition |
Runtime Behavior
- Unknown fields, such as
issue.nonexistent_field, resolve toniland do not crash the rule. - Nonexistent custom fields, such as
issue.cf_999999, also resolve tonilsafely. - Nil-safe navigation is supported. If any part of a nested field chain is
nil, such asissue.assigned_to.namewhen there is no assignee, the entire field resolves tonilwithout error.
Formal Grammar
This simplified grammar shows the shape of valid RQL expressions:
expression := term ((AND | OR) term)*term := '(' expression ')' | comparisoncomparison := field operator valuefield := IDENTIFIERoperator := '=' | '!=' | '>' | '>=' | '<' | '<=' | '~' | '!~' | 'IS' | 'IS NOT' | 'IN' | 'NOT IN'value := STRING | NUMBER | IDENTIFIER | arrayarray := '(' value (',' value)* ')'STRING := '"...' | "'..."NUMBER := -?[0-9]+(\.[0-9]+)?IDENTIFIER := [a-zA-Z_][a-zA-Z0-9_.]*Tips and Best Practices
- Always quote string values. Use
issue.status.name = "Open", notissue.status.name = Open. - Use
IS EMPTYandIS NOT EMPTYto check for missing values instead of comparing to"". - Use
INfor multi-value checks instead of chaining multipleORconditions. - Use parentheses when mixing
ANDandORto make your intent clear. - Use
todayandnowwith date math methods for dynamic date comparisons instead of hardcoding dates. - Use field-to-field comparisons when the rule depends on relationships between values.
- Check the run log after setting up an automation. If a condition fails, the log shows whether it was a syntax error or a non-match.
Preferred multi-value check:
issue.status.name IN ("New", "Open", "Reopened")Avoid chaining equivalent OR clauses:
issue.status.name = "New" OR issue.status.name = "Open" OR issue.status.name = "Reopened"Useful field-to-field comparisons:
issue.assigned_to.id = initiator.idissue.start_date < issue.due_date