Skip to content

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 = false

Matches 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, and NOT are 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

OperatorNameDescriptionExample
=EqualCase-insensitive for strings and type-coerced for numbersissue.status.name = "Open"
!=Not equalInverse of =issue.priority.name != "Low"
>Greater thanWorks with numbers, dates, and date stringsissue.done_ratio > 50
>=Greater than or equalWorks with numbers, dates, and date stringsissue.start_date >= "2024-01-01"
<Less thanWorks with numbers, dates, and date stringsissue.priority.position < 3
<=Less than or equalWorks with numbers, dates, and date stringsissue.due_date <= "2026-12-31"
~ContainsCase-insensitive substring matchissue.subject ~ "bug"
!~Does not containInverse of ~issue.description !~ "test"
INIn listValue is in the given setissue.status.name IN ("New", "Open")
NOT INNot in listValue is not in the given setissue.priority.name NOT IN ("Low", "Normal")
ISIsChecks for NULL or EMPTYissue.assigned_to IS EMPTY
IS NOTIs notInverse of ISissue.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 >= 80
issue.start_date > "2024-01-01"
issue.estimated_hours < 10.5

You can also compare fields to other fields:

issue.start_date < issue.due_date
issue.priority.position > issue.status.position

Contains

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 EMPTY
issue.due_date IS NOT NULL
issue.description IS NOT EMPTY
issue.category IS NULL

NULL and EMPTY are treated identically. Both match nil values and empty strings.

Logical Operators

OperatorDescription
ANDBoth sides must be true
ORAt 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 C

Without parentheses, this is evaluated as:

(A OR B) AND C

Use explicit grouping when needed:

A OR (B AND C)

Short-Circuit Evaluation

  • AND stops evaluating on the first false.
  • OR stops evaluating on the first true.

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

FieldTypeDescription
issue.idIntegerIssue ID
issue.subjectStringIssue subject line
issue.descriptionStringIssue description
issue.estimated_hoursFloatEstimated hours, defaults to 0
issue.done_ratioIntegerPercent done from 0 to 100, defaults to 0
issue.start_dateDateStart date
issue.due_dateDateDue date
issue.is_privateBooleanWhether the issue is private
issue.closed_onDateTimeWhen the issue was closed
issue.created_onDateTimeWhen the issue was created
issue.updated_onDateTimeWhen the issue was last updated
issue.urlStringFull URL to the issue

Associations

Each association exposes its own attributes.

Field PathTypeDescription
issue.project.*ProjectThe issue’s project
issue.author.*UserThe issue creator
issue.assigned_to.*UserThe current assignee
issue.status.*IssueStatusCurrent status
issue.priority.*IssuePriorityCurrent priority
issue.tracker.*TrackerIssue tracker
issue.category.*IssueCategoryIssue category
issue.fixed_version.*VersionTarget version
issue.parent.*IssueParent issue for subtasks
issue.root.*IssueRoot issue in the subtask tree
issue.first_comment.*JournalFirst comment or note on the issue
issue.last_comment.*JournalMost recent comment or note

Issue Custom Fields

Access any issue custom field by its numeric ID:

issue.cf_1
issue.cf_42

Custom 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.

FieldTypeDescription
initiator.idIntegerUser ID
initiator.loginStringLogin name
initiator.nameStringFull name
initiator.firstnameStringFirst name
initiator.lastnameStringLast name
initiator.mailStringEmail address
initiator.languageStringLanguage preference, such as "en" or "de"
initiator.adminBooleanWhether the user is an admin
initiator.statusIntegerUser status
initiator.created_onDateTimeAccount creation date
initiator.updated_onDateTimeAccount last updated
initiator.last_login_onDateTimeLast login time
initiator.urlStringUser profile URL
initiator.cf_<ID>VariesUser custom fields

today - Current Date

The today root is always available and returns the current date at the time the automation runs.

FieldTypeDescription
todayDateThe current date, usable directly in comparisons
today.yearIntegerCurrent year
today.monthIntegerCurrent month from 1 to 12
today.dayIntegerCurrent day of the month

Date Math Methods

MethodReturnsDescription
today.plus_days(N)DateAdd N days
today.minus_days(N)DateSubtract N days
today.plus_weeks(N)DateAdd N weeks
today.minus_weeks(N)DateSubtract N weeks
today.plus_months(N)DateAdd N months
today.minus_months(N)DateSubtract N months
today.plus_years(N)DateAdd N years
today.minus_years(N)DateSubtract N years
today.format("pattern")StringFormat 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 < today

now - Current Date and Time

The now root is always available and returns the current date and time at the time the automation runs.

FieldTypeDescription
now.yearIntegerCurrent year
now.monthIntegerCurrent month from 1 to 12
now.dayIntegerCurrent day
now.hourIntegerCurrent hour from 0 to 23
now.minIntegerCurrent minute from 0 to 59
now.secIntegerCurrent second from 0 to 59

DateTime Math Methods

MethodReturnsDescription
now.plus_days(N)DateTimeAdd N days
now.minus_days(N)DateTimeSubtract N days
now.plus_weeks(N)DateTimeAdd N weeks
now.minus_weeks(N)DateTimeSubtract N weeks
now.plus_months(N)DateTimeAdd N months
now.minus_months(N)DateTimeSubtract N months
now.plus_years(N)DateTimeAdd N years
now.minus_years(N)DateTimeSubtract N years
now.format("pattern")StringFormat 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.

FieldTypeDescription
comment.idIntegerJournal entry ID
comment.notesStringComment text
comment.private_notesBooleanWhether the comment is private
comment.created_onDateTimeWhen the comment was created
comment.updated_onDateTimeWhen the comment was last updated
comment.user.*UserThe user who wrote the comment
comment.updated_by.*UserThe user who last updated the comment

Examples:

comment.notes ~ "/approve"
comment.user.login != issue.author.login
comment.private_notes = false

time_entry - Time Entry

The time_entry root is available on time entry created and time entry updated triggers.

FieldTypeDescription
time_entry.idIntegerTime entry ID
time_entry.commentsStringTime entry description
time_entry.hoursFloatHours logged, defaults to 0
time_entry.spent_onDateDate the time was spent
time_entry.created_onDateTimeWhen the entry was created
time_entry.updated_onDateTimeWhen the entry was last updated
time_entry.user.*UserThe user the time is logged for
time_entry.author.*UserThe user who created the entry
time_entry.activity.*TimeEntryActivityThe activity type

Time Entry Activity Fields

FieldTypeDescription
time_entry.activity.idIntegerActivity ID
time_entry.activity.nameStringActivity name, such as “Development”
time_entry.activity.is_defaultBooleanWhether it is the default activity
time_entry.activity.activeBooleanWhether it is active
time_entry.activity.positionIntegerSort position

Examples:

time_entry.hours > 8
time_entry.activity.name = "Development"
time_entry.spent_on = today

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 >= 3

project - Current Project

The project root represents the issue’s project.

FieldTypeDescription
project.idIntegerProject ID
project.nameStringProject name
project.descriptionStringProject description
project.identifierStringURL-friendly project identifier
project.is_publicBooleanWhether the project is public
project.statusIntegerProject status
project.created_onDateTimeProject creation date
project.updated_onDateTimeProject last updated
project.urlStringProject URL
project.cf_<ID>VariesProject custom fields

Association Attribute Reference

IssueStatus

Accessed through issue.status.*.

FieldType
.idInteger
.nameString
.descriptionString
.is_closedBoolean
.positionInteger

IssuePriority

Accessed through issue.priority.*.

FieldType
.idInteger
.nameString
.is_defaultBoolean
.activeBoolean
.positionInteger

Tracker

Accessed through issue.tracker.*.

FieldType
.idInteger
.nameString
.descriptionString

IssueCategory

Accessed through issue.category.*.

FieldType
.idInteger
.nameString

Version

Accessed through issue.fixed_version.*.

FieldType
.idInteger
.nameString
.descriptionString
.statusString
.sharingString
.effective_dateDate
.created_onDateTime
.updated_onDateTime

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

MethodDescriptionExample
.upcaseConvert to uppercaseissue.subject.upcase = "BUG FIX"
.downcaseConvert to lowercaseissue.subject.downcase = "bug fix"
.stripRemove leading and trailing whitespaceissue.subject.strip = "trimmed"
.truncate(N)Truncate to N charactersissue.subject.truncate(10) = "Hello W..."

Integer Methods

MethodDescriptionExample
.plus(N)Add Nissue.done_ratio.plus(10) = 60
.minus(N)Subtract Nissue.done_ratio.minus(50) = 0

Float Methods

MethodDescriptionExample
.plus(N)Add Nissue.estimated_hours.plus(1.5) = 10
.minus(N)Subtract Nissue.estimated_hours.minus(2) = 6.5
.round(N)Round to N decimal placesissue.estimated_hours.round(0) = 9
.ceil(N)Round up to N decimal placesissue.estimated_hours.ceil(0) = 9
.floor(N)Round down to N decimal placesissue.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 > 100
issue.cf_12 >= "2024-01-01"
issue.cf_8 IN ("A", "B", "C")
issue.cf_3 = true
issue.cf_7 IS EMPTY

Custom fields are available on:

  • Issues: issue.cf_<ID>
  • Projects: project.cf_<ID> or issue.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:

FormatRQL Type
intInteger
floatFloat
dateDate
Everything elseString

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 NULL
custom_entity.cf_<ID> = "value"
custom_entity.cf_<ID> > 100

Custom 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 EMPTY

Detect Overdue Issues

Trigger: Scheduled or issue updated

issue.due_date < today AND issue.status.is_closed = false

Flag Stale Issues

Trigger: Scheduled

issue.updated_on < now.minus_days(30) AND issue.status.is_closed = false

Close 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 EMPTY

Match Private Issues by Same Author

Trigger: Issue updated

issue.is_private = true AND issue.author.id = initiator.id

Only Top-Level Issues

issue.parent IS EMPTY

Only Subtasks

issue.parent IS NOT EMPTY

Comment-Based Slash Command

Trigger: Comment added

comment.notes ~ "/close"

Guard Comment From Non-Author

Trigger: Comment added

comment.user.id != issue.author.id

Excessive Time Logged

Trigger: Time entry created

time_entry.hours > 8

Issues 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.id

Complex 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 EMPTY

Error Handling

Parse Errors

If a query has invalid syntax, the parser returns a descriptive error message and the automation rule cannot run.

Invalid QueryError
issue.status == "Open"Double = is not a valid operator
issue.status.name = OpenUnquoted string value; use "Open"
issue.status.name = "OpenUnterminated string literal
(issue.id > 0Missing closing parenthesis
issue.id > 0)Unexpected closing parenthesis
@@ garbageUnexpected character
issue.status.name =Expected value, got EOF
AND issue.id > 0Leading operator without left-hand condition
issue.id > 0 ANDTrailing operator without right-hand condition

Runtime Behavior

  • Unknown fields, such as issue.nonexistent_field, resolve to nil and do not crash the rule.
  • Nonexistent custom fields, such as issue.cf_999999, also resolve to nil safely.
  • Nil-safe navigation is supported. If any part of a nested field chain is nil, such as issue.assigned_to.name when there is no assignee, the entire field resolves to nil without error.

Formal Grammar

This simplified grammar shows the shape of valid RQL expressions:

expression := term ((AND | OR) term)*
term := '(' expression ')' | comparison
comparison := field operator value
field := IDENTIFIER
operator := '=' | '!=' | '>' | '>=' | '<' | '<='
| '~' | '!~'
| 'IS' | 'IS NOT'
| 'IN' | 'NOT IN'
value := STRING | NUMBER | IDENTIFIER | array
array := '(' value (',' value)* ')'
STRING := '"...' | "'..."
NUMBER := -?[0-9]+(\.[0-9]+)?
IDENTIFIER := [a-zA-Z_][a-zA-Z0-9_.]*

Tips and Best Practices

  1. Always quote string values. Use issue.status.name = "Open", not issue.status.name = Open.
  2. Use IS EMPTY and IS NOT EMPTY to check for missing values instead of comparing to "".
  3. Use IN for multi-value checks instead of chaining multiple OR conditions.
  4. Use parentheses when mixing AND and OR to make your intent clear.
  5. Use today and now with date math methods for dynamic date comparisons instead of hardcoding dates.
  6. Use field-to-field comparisons when the rule depends on relationships between values.
  7. 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.id
issue.start_date < issue.due_date