Skip to content

Expressions

The CEL dialect of Loop conditions and prompt interpolation, the item and repo variables for each trigger, and the functions and macros available.

Loop files use expressions in two places:

  • trigger.if selects Items. It must produce a bool.
  • Each {{ ... }} in the Loop body inserts a value into the task. It must produce a scalar: string, int, uint, double, bool, or null.

Expressions use CEL, the Common Expression Language. The @usine/expression package evaluates them with @marcbachmann/cel-js 8.0.0 (marcbachmann-cel.ts).

The environment is typed. When a Loop file loads, usine type-checks every expression against the variables of its trigger’s Stream, and quarantines the Loop on an unknown field, a type mismatch, or a wrong result type. Evaluation happens later, against each Item’s payload or each Tick.

Every expression can use two variables, repo and item. Their types come from the payload slices in payload-slices.ts, and the values come from GitHub’s GraphQL API with GitHub’s field names (queries.ts).

The managed repository the Item or Tick belongs to (repositoryContext).

Field Type Description
repo.name string The full owner/name, as written in usine.yaml.
repo.owner string The owner part of the name.
repo.url string https://github.com/<owner>/<name>.

The issue payload slice (IssuePayload). A field marked nullable can be null on a given Item; see fields that can be null.

Field Type Nullable Description
number int The issue number.
title string
body string The Markdown body.
url string
state string OPEN or CLOSED.
stateReason string yes GitHub’s state reason, such as COMPLETED or NOT_PLANNED.
createdAt string ISO 8601 UTC timestamp. Convert with timestamp() to compare.
updatedAt string ISO 8601 UTC timestamp.
closedAt string yes ISO 8601 UTC timestamp.
author.login string author author is null for a deleted account.
issueType.name string issueType issueType is null when the issue has no type.
milestone.title string milestone milestone is null when the issue has no milestone.
labels.totalCount int
labels.nodes[].name string The first 50 labels.
assignees.totalCount int
assignees.nodes[].login string The first 10 assignees.
comments.totalCount int
comments.nodes[] list The last 20 comments: author.login (author nullable), body, createdAt.
closedByPullRequestsReferences.totalCount int
closedByPullRequestsReferences.nodes[] list The first 10 pull requests that close the issue, closed ones included: number, state, isDraft, url.

The pull-request payload slice (PullRequestPayload).

Field Type Nullable Description
number int The pull request number.
title string
body string
url string
state string OPEN, CLOSED, or MERGED.
isDraft bool
merged bool
createdAt string ISO 8601 UTC timestamp.
updatedAt string ISO 8601 UTC timestamp.
mergedAt string yes ISO 8601 UTC timestamp.
author.login string author author is null for a deleted account.
baseRefName string The target branch.
headRefName string The source branch.
headRefOid string The head commit SHA. Attempts of pr Loops start from this commit.
additions int
deletions int
changedFiles int
reviewDecision string yes APPROVED, CHANGES_REQUESTED, or REVIEW_REQUIRED; null when the branch requires no review.
mergeable string MERGEABLE, CONFLICTING, or UNKNOWN. GitHub computes it lazily, so the first poll after the base branch changes can return UNKNOWN.
mergeStateStatus string GitHub’s merge state, such as CLEAN, BLOCKED, BEHIND, or DIRTY.
statusCheckRollup.state string statusCheckRollup The combined check state, such as SUCCESS, FAILURE, or PENDING. statusCheckRollup is null when the head commit has no checks.
labels.totalCount int
labels.nodes[].name string The first 50 labels.
reviewRequests.totalCount int
reviewRequests.nodes[].requestedReviewer.login string see below The first 10 review requests.
reviews.totalCount int
reviews.nodes[] list The last 20 reviews: author.login (author nullable), state, body, submittedAt (nullable), commit.oid (commit nullable).
reviewThreads.totalCount int
reviewThreads.nodes[] list The first 50 threads: isResolved, isOutdated, path, and comments with totalCount and the first 10 nodes, each with author.login (author nullable) and body.
comments.totalCount int
comments.nodes[] list The last 20 comments: author.login (author nullable), body, createdAt.
closingIssuesReferences.totalCount int
closingIssuesReferences.nodes[] list The first 10 issues the pull request closes: number, state.

requestedReviewer is null when the reviewer is a deleted account. For a team review request it is an empty object without login, and reading login fails evaluation.

A schedule has no GitHub Item. item contains the Tick (tickPayload).

Field Type Description
item.tick.scheduledAt string The time the Tick was scheduled for, in UTC, such as 2026-10-19T07:00:00.000Z.
item.tick.scheduledAtLocal string The same instant in the trigger’s time zone, such as 2026-10-19T09:00:00.000+02:00[Europe/Paris].
item.tick.cron string The trigger’s cron.
item.tick.tz string The trigger’s tz.

The CEL environment declares every field with a non-null type, but GitHub returns null for the nullable fields listed above. On an Item where such a field is null:

  • Reading the field, or any field below it, fails evaluation: item.milestone.title == "v1" fails with Field 'milestone' is not of type 'Milestone', got 'null'.
  • Comparing it with null fails the type check when the file loads: item.milestone != null gives no such overload: Milestone != null.
  • has(item.milestone) fails evaluation too.

The only guard is evaluation order. CEL’s && returns false when either side is false, even if the other side fails, and || returns true when either side is true. Pair a nullable field with a condition that is false on every Item where the field is null:

loops/merged-follow-up.md
trigger:
on: pr
if: item.merged && timestamp(item.mergedAt) > timestamp("2026-10-01T00:00:00Z")

Fields such as reviewDecision and milestone have no such companion condition, so an expression that reads one fails on every Item where it’s null.

A failure at evaluation time doesn’t quarantine the Loop, because the file already passed its checks:

  • if of an issue or pr Loop. usine stops the repository’s poll cycle for that cycle, and the poll cursor doesn’t advance (polling.ts). The next cycle fetches the same changes and gets the same error, until the Item or the Loop changes. Meanwhile, usine might not evaluate other changed Items of that repository.
  • if of a schedule Loop. The Tick fails for that repository, and usine evaluates it again at the next wake.
  • The re-check before a queued Run opens. usine logs a warning and the Run opens as if the check had passed (TriggerCondition.holds).
  • A {{ ... }} interpolation. See when rendering fails.

The environment registers only the types and variables above. It enables no optional syntax and adds no functions to cel-js’s standard set.

  • Comparison: ==, !=, <, <=, >, >=. You can compare an int with a double.
  • Logic: &&, ||, !, and the conditional condition ? a : b.
  • Arithmetic: +, -, *, /, %. + also concatenates strings and lists.
  • Membership: value in list.
  • Literals: integers, doubles, strings in "..." or '...', true, false, null, lists [...], and maps {...}. A list or map literal must contain values of one type.
Macro Result Example
list.exists(x, predicate) bool item.labels.nodes.exists(label, label.name == "ready")
list.all(x, predicate) bool item.reviewThreads.nodes.all(t, t.isResolved)
list.exists_one(x, predicate) bool item.assignees.nodes.exists_one(a, a.login == "octocat")
list.filter(x, predicate) list item.reviews.nodes.filter(r, r.state == "COMMENTED")
list.map(x, expression) list item.labels.nodes.map(label, label.name)
list.map(x, predicate, expression) list item.reviews.nodes.map(r, r.state == "COMMENTED", r.body)
has(object.field) bool Fails on a null object; see above.
cel.bind(name, value, expression) any cel.bind(n, size(item.labels.nodes), n > 0 && n < 5)
Function Description
size(x), x.size() Length of a string, list, or map.
s.contains(t), s.startsWith(t), s.endsWith(t) Substring tests.
s.matches(re) Regular expression test, in JavaScript RegExp syntax. Inline flags such as (?i) aren’t supported; write [Cc][Ss][Vv] or lowercase first.
s.lowerAscii(), s.upperAscii(), s.trim() Case and whitespace.
s.indexOf(t), s.lastIndexOf(t), s.substring(start, end) Positions and slices; indexes start at 0.
s.split(separator) List of strings.
list.join(), list.join(separator) Joins a list of strings.
string(x), int(x), uint(x), double(x), bool(x), bytes(x), dyn(x) Conversions.
type(x) The type of a value.
timestamp(s) Parses an ISO 8601 string, such as item.updatedAt.
duration(s) Parses a duration such as "72h", "30m", or "1h30m".
t.getFullYear(), t.getMonth(), t.getDate(), t.getDayOfMonth(), t.getDayOfWeek(), t.getDayOfYear(), t.getHours(), t.getMinutes(), t.getSeconds(), t.getMilliseconds() Parts of a timestamp, in UTC, or in the time zone passed as argument. getMonth and getDayOfMonth start at 0, getDate at 1, and getDayOfWeek is 0 on Sunday.
d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds() Parts of a duration.

Subtracting two timestamps gives a duration, and you can compare two durations. No function returns the current time. A schedule Loop can use item.tick.scheduledAt instead.

When an instance loads, usine collects the string literals that a Loop’s if compares with the name of a value under item.labels, through ==, !=, in, and the exists, all, exists_one, filter, and map macros (relatedStringLiterals). A literal that isn’t declared under labels in usine.yaml produces the warning Loop trigger references an undeclared GitHub label, and the Loop still loads.

An issue is ready and no open pull request closes it yet:

loops/start-dev.md
if: >-
item.state == "OPEN" &&
item.labels.nodes.exists(label, label.name == "ready-for-agent") &&
!item.closedByPullRequestsReferences.nodes.exists(pr, pr.state == "OPEN")

A pull request is ready for review and no outcome label is present:

loops/review-pr.md
if: >-
item.state == "OPEN" &&
!item.isDraft &&
item.labels.nodes.exists(label, label.name == "ready-for-review") &&
!item.labels.nodes.exists(label, label.name == "review-passed") &&
!item.labels.nodes.exists(label, label.name == "ready-to-merge")

Fewer than three reviews contain the reviewer Agent’s signature:

loops/act-on-findings.md
if: >-
item.state == "OPEN" &&
item.labels.nodes.exists(label, label.name == "agent-review-findings") &&
size(item.reviews.nodes.filter(review, review.body.contains("usine agent: reviewer"))) < 3

Review passed, or three review rounds didn’t converge:

loops/finish-pr.md
if: >-
item.state == "OPEN" &&
!item.isDraft &&
!item.labels.nodes.exists(label, label.name == "ready-to-merge") &&
(
item.labels.nodes.exists(label, label.name == "review-passed") ||
(
item.labels.nodes.exists(label, label.name == "agent-review-findings") &&
size(item.reviews.nodes.filter(review, review.body.contains("usine agent: reviewer"))) >= 3
)
)

A pull request marked for merge has a conflicting branch:

loops/rebase-pr.md
if: >-
item.state == "OPEN" &&
!item.isDraft &&
item.mergeable == "CONFLICTING" &&
item.labels.nodes.exists(label, label.name == "ready-to-merge")

A schedule that skips weekends in its own time zone:

loops/daily-digest.md
if: >-
timestamp(item.tick.scheduledAt).getDayOfWeek(item.tick.tz) != 0 &&
timestamp(item.tick.scheduledAt).getDayOfWeek(item.tick.tz) != 6

Prompt interpolations from the same Loops:

loops/rebase-pr.md
Pull request #{{ item.number }} is marked `ready-to-merge` but GitHub reports its branch `{{ item.headRefName }}` as conflicting with `{{ item.baseRefName }}`.