---
title: "Filters"
description: "Every routine filter with its values, defaults, the events it applies to, glob rules, trigger policy, evaluation order and the when condition."
section: "routines"
url: "https://forkline.dev/docs/routines/filters"
markdown: "https://forkline.dev/docs/routines/filters.md"
source: "src/content/docs/routines/filters.mdx"
---

# Filters

> [!TIP]
> **Agent hint**
> Rules on this page that most often cause invalid routines or silent skips:
>
> - **A filter must apply to every listed event**, or the routine is refused with `422` at `/filters/{name}`: `` `command` does not apply to pr.opened; every filter must apply to every listed event, so split the routine ``. Check the [matrix](#which-filters-apply-to-which-event). Schedule and webhook routines accept only `when`.
> - **Defaults skip a lot.** With no `filters` at all, a routine already skips drafts, forks, other bots and Forkline's own activity. Set `draft: include`, `fork: include`, `bots: include` or `self: include` when you want those events.
> - `include` is **any of**, `exclude` is **none of**, and `exclude` wins. A list must have 1 to 50 entries, each up to 256 bytes; omit an empty list.
> - `self` and `bots` judge the **actor** (`sender`, the account that caused the event), not the author of the pull request. `self` is Forkline's own bot, `bots` is every other bot, and both default to `exclude`.
> - To skip pull requests **written by** a bot, use `when: '!pr.authorIsBot'` on `pr.*` events (on GitLab only when the merge request is first opened, see [Authors and labels](#authors-and-labels)); `bots: exclude` does not do that. To skip only Forkline's own pushes and comments and still run for other bots, set `bots: include` and leave `self` at `exclude`. See the [recipes](#who-to-skip-recipes).
> - A routine runs at most `limits.maxRunsPerPr` times for one pull request, **default 3**, replaced runs included. A routine on `pr.updated` that should run on every commit needs a higher value (at most 50); see [Concurrency and limits](https://forkline.dev/docs/routines/concurrency-and-limits#run-limits).
> - `branches` means a different branch per event: the **base** branch for pull requests, the **pushed** branch for `push`, the **run's** branch for `ci.failed`.
> - Branch and path globs are case-sensitive and only support `*`, `?` and `**`. Labels and authors are plain names, case-insensitive, no globs.
> - `comment.command.name` in a template is `comment.commandName` in `when`.

Filters decide whether an event starts a run. All of them must pass. They go under `filters` in the routine file, and every key is optional:

```yaml
filters:
  branches: { include: [main, "release/*"] }
  labels: { exclude: [no-review] }
  draft: exclude
```

## Which filters apply to which event

Every filter you set must apply to **every** event you list in `on.events`. The table is generated from the validator.

| Event | `branches` | `paths` | `labels` | `authors` | `draft` | `fork` | `bots` | `self` | `command` | `when` |
|-------|-----------|---------|----------|-----------|---------|--------|--------|--------|-----------|--------|
| `pr.opened` | yes | no | yes | yes | yes | yes | yes | yes | no | yes |
| `pr.updated` | yes | no | yes | yes | yes | yes | yes | yes | no | yes |
| `pr.merged` | yes | no | yes | yes | yes | yes | yes | yes | no | yes |
| `pr.closed` | yes | no | yes | yes | yes | yes | yes | yes | no | yes |
| `issue.opened` | no | no | yes | yes | no | no | yes | yes | no | yes |
| `comment.created` | no | no | yes | yes | no | no | yes | yes | yes | yes |
| `ci.failed` | yes | no | no | no | no | no | no | no | no | yes |
| `push` | yes | yes | no | yes | no | no | yes | yes | no | yes |

When the events differ, write one routine per kind of event. The error for a misfit lists the events that do not fit:

| Case | Pointer | Message |
|------|---------|---------|
| A filter that some listed event does not support | `/filters/{name}` | `` `{name}` does not apply to {events}; every filter must apply to every listed event, so split the routine `` |
| A filter other than `when` on a schedule or webhook routine | `/filters/{name}` | `` `{name}` only applies to routines started by events `` |

## All filters

| Filter | Value | Default | What it does |
|--------|-------|---------|--------------|
| `branches` | `{ include?: [glob], exclude?: [glob] }` | not set | The branch of the event matches the globs, see [Branches](#branches) |
| `paths` | `{ include?: [glob], exclude?: [glob] }` | not set | At least one changed file matches, `push` only, see [Paths](#paths) |
| `labels` | `{ include?: [name], exclude?: [name] }` | not set | Labels of the pull request or issue |
| `authors` | `{ include?: [name], exclude?: [name] }` | not set | Author of the pull request, issue or comment, or the pusher |
| `draft` | `exclude`, `include` or `only` | `exclude` | Draft pull requests |
| `fork` | `exclude`, `include` or `only` | `exclude` | Pull requests from a fork |
| `bots` | `exclude`, `include` or `only` | `exclude` | Events caused by other bot accounts |
| `self` | `exclude`, `include` or `only` | `exclude` | Events caused by Forkline's own bot |
| `command` | `{ names?: [string], mention?: boolean }` | not set | A slash command or a mention in a comment |
| `when` | CEL expression string | not set | A condition over the typed event fields, see [`when` conditions](#when-conditions) |

`branches`, `paths`, `labels` and `authors` need at least one of `include` and `exclude` (`` set `include`, `exclude` or both ``).

### Include and exclude

Used by `branches`, `paths`, `labels` and `authors`:

- A value passes when it matches **no** `exclude` entry **and** (there is no `include`, or it matches **at least one** `include` entry). `include` is any-of, `exclude` is none-of, and `exclude` wins when both match.
- Each list has 1 to 50 entries. An empty list is an error (`` must not be empty, remove it instead `` at `/filters/{name}/include`), and so is a list of more than 50 (`` at most 50 entries, this has 51 ``). Each entry is 1 to 256 bytes and not blank (`must not be empty`, `is longer than 256 bytes`, pointer `/filters/{name}/include/{index}`).
- `labels` and `authors` compare plain names, **ignoring case**, with no globs. `exclude: [no-review]` blocks a pull request that carries the label `No-Review`.
- `branches` and `paths` use [globs](#glob-syntax), which are case-sensitive.

### The yes/no filters: `draft`, `fork`, `bots`, `self`

Each takes one of three values, and the default for all four is `exclude`, **also when `filters` is omitted**:

| Value | Meaning |
|-------|---------|
| `exclude` | Skip the event when the property is true. This is the default |
| `include` | Ignore the property: draft or not, fork or not, bot or not |
| `only` | Run only when the property is true |

| Filter | The property | Read from |
|--------|--------------|-----------|
| `draft` | the pull request is a draft | `pr.draft` |
| `fork` | the pull request comes from a fork | `pr.fork` |
| `bots` | the actor is a bot account, other than Forkline's own | `sender.isBot` |
| `self` | the actor is Forkline's own bot | `sender` |

#### `self` and `bots`: the actor, not the author

Both look at the **actor**, the `sender` of the event: whoever caused it by pushing, commenting, opening or merging. A pull request authored by `renovate[bot]` but pushed to by a person has a human actor. To filter on who wrote the pull request, use [`authors`](#authors-and-labels) or `when: '!pr.authorIsBot'`.

| Actor | `self: exclude`, `bots: exclude` (defaults) |
|-------|---------------------------------------------|
| A person | runs |
| Another bot (`renovate[bot]`, `dependabot[bot]`, a GitLab token bot) | skipped by `bots` |
| Forkline's own bot (`forkline[bot]`, `@forkline-bot`) | skipped by `self` |

- `self` identifies the bot Forkline posts as on that connector: the connector's configured automation account, and, always, the names `forkline` and `forkline-bot` (on GitHub also `forkline[bot]` and `forkline-bot[bot]`), ignoring case. Without this filter a routine would answer its own comments and loop. Use `self: include` to let a routine react to Forkline's own comments, or `self: only` to run only on them.
- `bots` counts only **other** bots: Forkline's own bot is judged by `self` alone. `bots: only` runs on bot-caused events other than Forkline's, such as dependency updates.
- On GitLab and Forgejo connectors set up with a person's OAuth login and no automation account, Forkline posts as that person, so its own actions cannot be told apart from theirs.
- `ci.failed` has no actor, so `self` and `bots` do not apply to it and are refused with it.

#### Who to skip: recipes

| Goal | Write |
|------|-------|
| Skip pull requests **authored by** a bot (Renovate, Dependabot) | `when: '!pr.authorIsBot'` on `pr.*` events (on GitLab only when the merge request is first opened, see [Authors and labels](#authors-and-labels)) |
| Skip events **caused by** other bots, keep people and Forkline | `bots: exclude` (default) and `self: include` |
| Skip only Forkline's own activity, keep other bots | `bots: include` and leave `self: exclude` (default) |
| Run for everybody, including Forkline's own comments | `bots: include` and `self: include` (add a loop guard such as `maxRunsPerPr`) |
| Run only for bots other than Forkline | `bots: only` |

```yaml routine
name: review-human-prs
description: Review pull requests that were not written by a bot
on:
  events: [pr.opened, pr.updated]
repositories:
  include: all
filters:
  when: '!pr.authorIsBot'
agent:
  prompt: |
    Review pull request #{{ pr.number }} in {{ repository.fullName }}.
```

```yaml routine
name: check-pushes-except-forkline
description: Look at pushes to main from people and other bots, but not from Forkline itself
on:
  events: [push]
repositories:
  include: all
filters:
  branches:
    include: [main]
  bots: include
agent:
  prompt: |
    {{ sender.login }} pushed {{ push.commitCount }} commits to {{ push.branch }} in {{ repository.fullName }}. Look for anything that needs follow-up.
```

The second routine runs for `renovate[bot]` (`bots: include`) and skips `forkline[bot]` (`self` stays `exclude`). With the default `bots: exclude` it would skip both.

#### `draft` and `fork`

They apply to the four `pr.*` events. `draft: exclude` skips draft pull requests; on GitHub and Forgejo a pull request that later leaves draft fires `pr.opened` again and then runs. Use `draft: include` to review drafts too, and `fork: only` to run only on pull requests from forks. GitHub and Forgejo flag a pull request as a fork when the head repository differs from the base repository; GitLab compares source and target project.

### Branches

`branches` matches one branch, and **which one depends on the event**:

| Event | The branch it matches | Field |
|-------|-----------------------|-------|
| `pr.opened`, `pr.updated`, `pr.merged`, `pr.closed` | the **base** branch: where the pull request merges into | `pr.baseBranch` |
| `push` | the **pushed** branch | `push.branch` |
| `ci.failed` | the branch of the run, or the pull request's **head** branch when the run has none | `ci.branch`, else `pr.headBranch` |

To filter pull requests by their head branch, use `when: 'pr.headBranch.startsWith("feat/")'`.

```yaml routine
name: review-release-prs
description: Review pull requests that target main or a release branch
on:
  events: [pr.opened, pr.updated]
repositories:
  include: all
filters:
  branches:
    include: [main, "release/*"]
    exclude: ["release/legacy-*"]
agent:
  prompt: |
    Review pull request #{{ pr.number }} ({{ pr.title }}) into {{ pr.baseBranch }}.
```

### Paths

`paths` applies to `push` only. The file list of a push is every added, modified and removed path.

- The push passes when **at least one** changed file passes the include/exclude test. With only `exclude`, the push runs unless **every** changed file is excluded.
- Paths are relative to the repository root, without a leading `/`, and use `/` as separator. Globs are case-sensitive.
- **An incomplete file list passes.** When the forge sent no file list, or `push.filesTruncated` is `true` (the forge listed fewer commits than were pushed, or the push has more than 3,000 paths, each path at most 512 bytes), Forkline cannot rule the push out and runs the routine. A run that passed `paths` this way says so in its reason.

```yaml routine
name: docs-on-push
description: Update the docs when source files change on main
on:
  events: [push]
repositories:
  include: all
filters:
  branches:
    include: [main]
  paths:
    include: ["src/**", "**/*.proto"]
    exclude: ["src/**/*_test.go"]
agent:
  prompt: |
    {{ push.commitCount }} commits were pushed to {{ push.branch }}.
    Bring the docs in line with the changes in {{ push.after }}.
```

### Authors and labels

| Filter | Compared with | Notes |
|--------|---------------|-------|
| `authors` on `pr.*` | `pr.author` | The author of the pull request, not the person who pushed or merged |
| `authors` on `issue.opened` | `issue.author` | |
| `authors` on `comment.created` | `comment.author` | |
| `authors` on `push` | `sender.login` | The pusher |
| `labels` on `pr.*` | `pr.labels` | `include` passes when **any** label is listed, `exclude` when **none** is |
| `labels` on `issue.opened` | `issue.labels` | |
| `labels` on `comment.created` | the labels of the pull request when the comment is on one, otherwise of the issue | |

GitLab names a merge request's author only when it is first opened. On GitLab, a reopened merge request (`pr.opened`), `pr.updated`, `pr.merged` and `pr.closed` have an empty `pr.author` and `pr.authorIsBot` is `false`: `authors.include` never matches them, `authors.exclude` always passes, and `!pr.authorIsBot` does not skip bot merge requests. On GitLab, also test the bot's branch prefix: `when: '!pr.authorIsBot && !pr.headBranch.startsWith("renovate/")'`.

```yaml routine
name: triage-bugs
description: Look at new issues labelled bug from outside the team
on:
  events: [issue.opened]
repositories:
  include: all
filters:
  labels:
    include: [bug, regression]
    exclude: [wontfix]
  authors:
    exclude: [alice, bob]
agent:
  prompt: |
    Triage issue #{{ issue.number }}: {{ issue.title }}
```

### Command

`command` applies to `comment.created`. It passes when **either** condition holds (they combine as OR):

- `names`: the comment's slash command is one of the names. 1 to 20 names, each starting with a letter, then letters, digits, `-` or `_`, up to 64 characters, **written without the leading `/`**, each listed once. Names are case-insensitive: `names: [fix-ci]` matches `/Fix-CI`.
- `mention: true`: the comment mentions `@forkline` or `@forkline-bot`.

Set `names`, `mention: true` or both (`` set `names` or `mention: true` ``).

What counts as a command: the **first** line of the comment that starts, after optional spaces, with `/` and a letter, followed by letters, digits, `-` or `_`, and then a space or the end of the line. Only that first command line is considered, so a comment `/retry` followed by `/fix-ci` runs as `retry`. Lines such as `/usr/bin` or `/1 go` are not commands. Everything after the name on that line, trimmed, is `comment.command.args` (at most 1,024 bytes) in templates and `comment.commandArgs` in `when`. A mention counts anywhere in the comment, as a whole word, in any case; `a@forkline.dev` and `@forklinex` do not count.

Because the slash must start the line, a comment such as `@forkline /fix-ci` does **not** parse as the command `fix-ci` — the line begins with `@forkline`, not `/`, so no command is extracted. With `names: [fix-ci]` alone the filter would not pass. Set `mention: true` as well: the mention path (`comment.mentionsBot`) is independent of the command parse, so `@forkline /fix-ci` passes via the mention even though the command name is not recognised. The two conditions are OR: a comment that starts with `/fix-ci` and has no mention passes via `names`; a comment that mentions the bot with no slash command passes via `mention: true`; a comment that does both passes via either path.

`command` only filters comments. Who may issue the command is decided by the [trigger policy](#trigger-policy). A command in the description of a new pull request or issue is not supported.

```yaml routine
name: fix-on-command
description: Fix CI when a trusted person comments /fix-ci or mentions Forkline
on:
  events: [comment.created]
repositories:
  include: all
filters:
  command:
    names: [fix-ci, retry]
    mention: true
  labels:
    exclude: [do-not-touch]
agent:
  prompt: |
    {{ comment.author }} wrote on {{ comment.url }}:
    {{ comment.body }}
    Command: {{ comment.command.name }} {{ comment.command.args }}
```

## Glob syntax

`branches` and `paths` entries, and repository names in selectors, are globs. The rules are exactly these:

| Pattern part | Matches |
|--------------|---------|
| `*` | any run of characters (including none) **within one segment**: it never crosses a `/` |
| `?` | exactly one character within one segment |
| `**` as a whole segment | any number of segments, including none |
| anything else | itself, literally |

- Segments are separated by `/`. A pattern must match the **whole** value.
- Globs are **case-sensitive** for branches and paths, and case-insensitive for repository names.
- There are no character classes (`[abc]`), no braces (`{a,b}`), no `!` negation and no escaping. Use `exclude` for a negation. `**` inside a longer segment (`a**b`) acts like `*`.
- A pattern is 1 to 256 bytes (for repository `name` patterns: `the pattern is empty`, `the pattern is longer than 256 bytes`; for `branches` and `paths` entries: `must not be empty`, `is longer than 256 bytes`).

| Pattern | Matches | Does not match |
|---------|---------|----------------|
| `main` | `main` | `Main`, `main2` |
| `release/*` | `release/1.0` | `release/1.0/rc`, `release` |
| `release/**` | `release/1.0/rc`, `release` | `hotfix/1` |
| `*` | `main` | `feat/x` |
| `v?.0` | `v1.0` | `v10.0` |
| `src/**` | `src/a/b.rs`, `src` | `lib/a.rs` |
| `src/**/*.rs` | `src/c.rs`, `src/a/b/c.rs` | `src/c.toml`, `lib/c.rs` |
| `**/Cargo.toml` | `Cargo.toml`, `crates/x/Cargo.toml` | `Cargo.lock` |

## Trigger policy

Forkline checks **who** may start a run for events that a person triggers as a command. The trigger policy is that check.

- **Which events.** `comment.created` only. Opening a pull request or issue, pushing and CI failures are not checked, so use [`authors`](#authors-and-labels) if you need to restrict them.
- **Which actors.** Every actor except the connector's own automation identity on your repository: its configured automation account, and on GitHub the app bot (`forkline[bot]`, `forkline-bot[bot]`). The plain logins `forkline` and `forkline-bot` count as `self` for the filters, but a comment from them is still checked against the policy.
- **Where it is set.** It is a setting of each **repository**, not of the routine, and it does not appear in the routine file. Change it under Repositories, then the repository's settings, then Trigger Policy, or with the `trigger_policy` and `allowed_users` fields of the repository API. See [Trigger policies](https://forkline.dev/docs/automation/trigger-policies).
- **Values.**

| Policy | Who may start a run |
|--------|---------------------|
| `collaborators` (default) | The owner of the connector, or a person with write access to the repository (checked on the forge for the person who wrote the comment) |
| `owner_only` | Only the owner of the connector (matched by git username) |
| `allowlist` | Only the usernames in the repository's allowed users, ignoring case |
| `anyone` | Everybody who can comment |

- **When it runs.** After every filter and `when` have passed and before the prompt is rendered, so a denied person never costs a render or a task. The same policy applies to Forkline's built-in automations.
- **A denied actor** gives outcome `filtered` at stage `trigger_policy`, with a reason such as `` `mallory` isn't allowed to trigger routines on `acme/api` (trigger policy: `collaborators`). `` If the forge cannot be asked whether the person has write access (it answers with an error or is unreachable), the person is denied the same way. The outcome is `error` at stage `trigger_policy` only when the repository or its connector cannot be loaded, and no run is started.

## Evaluation order

For each event and each routine that lists it, Forkline runs these stages in order. The **first** stage that blocks ends the run for that routine and repository, and its name is the `stage` of the outcome you see in [Testing and activity](https://forkline.dev/docs/routines/testing-and-activity). (A test evaluates every filter and `when`, even after one blocks, and reports all of them. It does not evaluate `overload`, `fan_out`, `event_key`, `concurrency`, `run_limit`, `credits` or `submit`; a backtest also skips `trigger_policy`.)

`overload → fan_out → event → repository → self_guard → bots → draft → fork → branches → labels → authors → command → paths → when → trigger_policy → template → event_key → concurrency → run_limit → credits → submit`

| Stage | Reads | Blocks with |
|-------|-------|-------------|
| `self_guard` | `filters.self` and the actor | `filtered` |
| `bots` | `filters.bots` and `sender.isBot` | `filtered` |
| `draft` | `filters.draft` and `pr.draft` | `filtered` |
| `fork` | `filters.fork` and `pr.fork` | `filtered` |
| `branches` | `filters.branches`, see [Branches](#branches) | `filtered` |
| `labels` | `filters.labels` | `filtered` |
| `authors` | `filters.authors` | `filtered` |
| `command` | `filters.command` | `filtered` |
| `paths` | `filters.paths` | `filtered` |
| `when` | `filters.when` | `filtered` when false; `condition_error` when it cannot be evaluated |
| `trigger_policy` | the repository's [trigger policy](#trigger-policy) (`comment.created` only) | `filtered`; `error` when the check fails |
| `template` | `agent.prompt`, `agent.ref` | `template_error` |
| `event_key` | the delivery of the event | `deduplicated`: the forge delivered it again |
| `concurrency` | `concurrency` (see [Concurrency and limits](https://forkline.dev/docs/routines/concurrency-and-limits)) | `skipped_active`, `concurrency_limited`; an older run is `superseded` |
| `run_limit` | `limits.maxRunsPerPr` | `run_limit` |
| `credits` | your credit balance | `credit_exhausted` |
| `submit` | the owner's pending-run cap | `concurrency_limited` when the account has too many pending runs (otherwise the run is created: `task_created`) |

- The first four stages (`overload`, `fan_out`, `event`, `repository`) are not filters: `overload` drops events when Forkline is overloaded, `fan_out` limits schedule and webhook routines to 200 repositories, and `event` and `repository` appear only in tests and backtests, which check that the event and repository match the routine.
- The filter stages that your routine does not set still run with their defaults: `self_guard`, `bots`, `draft` and `fork` always check (default `exclude`); the others run only when the filter is set. A stage that does not apply to the event (for example `self_guard` on `ci.failed`) records `not_applicable`; a test or backtest reports `not_evaluated` for a `when` or `paths` that reads a field the stored event does not carry.
- Each stage records what it saw in the trace: the `field` (for example `pr.baseBranch`), the observed value, the rule and a result of `pass`, `filtered`, `error`, `not_applicable` or `not_evaluated`.

## `when` conditions

`when` is one [CEL](https://cel.dev) expression that returns a boolean. It reads the **typed fields of the event**, and on a webhook routine also the request body as `payload`. It runs after the other filters. `false` ends the run with outcome `filtered` at stage `when`.

```yaml
filters:
  when: 'pr.title.startsWith("feat") || "needs-review" in pr.labels'
```

### Fields

The roots and fields are the ones on the [Events](https://forkline.dev/docs/routines/events#fields) page, with the same camelCase names. Roots are `repository`, `sender`, `pr`, `issue`, `comment`, `ci` and `push`. There is no `event`, `receivedAt` or `schedule` in `when`. `payload` exists only on a webhook routine, see [Webhook routines](#schedule-and-webhook-routines).

| Root | Fields |
|------|--------|
| `repository` | `fullName`, `owner`, `name`, `url`, `defaultBranch`, `private` |
| `sender` | `login`, `isBot` |
| `pr` | `number`, `title`, `body`, `author`, `authorIsBot`, `baseBranch`, `headBranch`, `headSha`, `labels`, `draft`, `fork`, `url` |
| `issue` | `number`, `title`, `body`, `author`, `labels`, `url` |
| `comment` | `id`, `body`, `author`, `url`, `commandName`, `commandArgs`, `mentionsBot` |
| `ci` | `name`, `runId`, `url`, `conclusion`, `headSha`, `branch`, `failures` (each with `name`, `runId`, `url`, `conclusion`) |
| `push` | `ref`, `branch`, `before`, `after`, `forced`, `commitCount`, `filesTruncated`, `files.added`, `files.modified`, `files.removed` |

- **The command is flat in `when`.** Use `comment.commandName` and `comment.commandArgs` (strings, empty when the comment has no command). `comment.command.name` is only valid in templates; in `when` it is refused as `` `comment.command` is not a field, available fields are ... ``.
- **A root exists only for events that carry it.** If every listed event carries a root, its fields have plain types. If only some do (`pr` with `[pr.opened, issue.opened]`, or `pr` with `comment.created`), its scalar fields can be `null` and its lists are empty when the root is absent. If **no** listed event carries it, saving fails.
- **`pr.*` on `comment.created` needs a guard.** The comment's parent is a pull request or an issue, so `pr` is absent for a comment on an issue and a bare `!pr.authorIsBot` saves but ends with `condition_error` on such a comment. Write `pr.number != null && !pr.authorIsBot` (false on an issue, so the routine is filtered), or list only `pr.*` events when the routine is about pull requests.
- Lists are bounded: 100 labels, 100 `ci.failures`, and 500 paths in each `push.files` list (`push.filesTruncated` says when there were more).

### Operators and functions

Operators: `==`, `!=`, `<`, `<=`, `>`, `>=`, `&&`, `||`, `!`, `+` (numbers, strings and lists), `-`, `*`, `/`, `%`, unary `-`, indexing, `in`, and `cond ? a : b`. Allowed functions:

| Function | Example |
|----------|---------|
| `size()` | `size(push.files.added) > 0`, `pr.labels.size() == 0` |
| `contains()` | `issue.title.contains("crash")` |
| `startsWith()`, `endsWith()` | `pr.headBranch.startsWith("feat/")`, `push.files.added.exists(f, f.endsWith(".sql"))` |
| `matches()` | `pr.title.matches("^(feat|fix):")` (an RE2 pattern as a string literal; unanchored, so anchor with `^` and `$`, and at most 256 bytes) |
| `string()`, `int()`, `dyn()` | `string(pr.number) == "412"` |
| `has()` | `has(push.files)` |
| `all()`, `exists()`, `exists_one()`, `map()`, `filter()` | `pr.labels.exists(l, l.startsWith("team/"))` |

Anything else (`lowerAscii()`, `replace()`, optional access `?.`) is refused.

- Both sides of `==` must have the same type. A side may be `null` only when the other is a field that can be `null` (a root only some listed events carry); `pr.title != null` on `[pr.opened]` is refused. `&&`, `||`, `!` and `?:` need booleans. The whole expression must be a boolean.
- Negate membership with parentheses: `!("x" in pr.labels)`. Written `!"x" in pr.labels` it means `(!"x") in pr.labels` and is refused.
- To test a possibly-absent value, compare with `null` and short-circuit: `pr.title != null && pr.title.startsWith("feat")`. `has(pr.number)` is `true` even when the value is `null`.
- Limits: 4,096 bytes, 16 levels of nesting, 512 expression nodes, and 256 bytes for any one `matches()` pattern.
- **Cost budget: 50,000 units** (about 12 nanoseconds each, so well under a millisecond), estimated at save time for the worst case under the size bounds above. `exists()` and the other macros multiply the cost of their body by the size of the list, and a regular expression has a cost of its own. Prefer `startsWith()` and `contains()` to `matches()`, and do not compare or concatenate whole lists.

### Errors

At save time the validator type-checks `when` and returns `422` at `/filters/when`:

| Mistake | Message |
|---------|---------|
| Empty string | `must not be empty` |
| Syntax error | `` invalid expression `pr.title ==`: {detail} `` |
| A root no listed event carries | `` `pr` is not available: the routine's events (push) never carry it `` |
| On a schedule or webhook routine | `` `pr` is not available: this routine is not started by an event, only `repository` is `` (a webhook routine also has `payload`) |
| Unknown root | `` `foo` is not a variable, use `repository`, `sender`, `pr`, `issue`, `comment`, `ci` or `push` (the ones your events carry), or `payload` in a webhook routine `` |
| `payload` on an event or schedule routine | `` `payload` is not available: only a webhook routine has a request body. Event and schedule routines can read `repository` and the roots their events carry `` |
| Unknown field | `` `comment.command` is not a field, available fields are `comment.id`, `comment.body`, ... `` |
| Not a boolean | `a filter must evaluate to a boolean, but this one is int` |
| Mixed types | `` `==` compares values of the same type, not string and int `` |
| Function not allowed | `` `.lowerAscii()` is not a supported function, or takes other arguments; filters may use size(), contains(), ... `` |
| Too long | `filter is {n} bytes, the limit is 4096` |
| Too deep | `filter nests expressions more than 16 levels deep` |
| Too many nodes | `filter is too large, it may use at most 512 expressions` |
| `matches()` pattern too long | `` the regular expression is {n} bytes, the limit is 256; use a shorter pattern `` |
| Over the cost budget | `the filter's estimated worst-case cost {n} exceeds the budget of 50000 by a factor of {f}; macros such as exists() multiply the cost of their body ...` |

At run time a `when` that cannot be evaluated, for example because it calls a method on a `null` field (`pr.title.startsWith("x")` on a comment that is on an issue), ends with outcome `condition_error` at stage `when` and the message `` it reads a field that is empty for this event, so `{source}` can't be checked. Test the field first, for example `pr.number != null && …` `` (or, for any other failure, `` `{source}` could not be evaluated for this event. ``). In a webhook routine, a condition that reads a payload field the body does not have says so, for example `` the payload has no `level` field. Guard optional fields with `has(payload.level)`. ``, and any other failure reads `` `{source}` could not be evaluated against this payload. ``. The message names the missing key. It also names a guard only when that is certain: the key must appear exactly once in the whole filter, as a plain read such as `payload.a.b` (field names and constant indexes only, starting at `payload`), and the filter must not use an index that is not a string or an integer constant, such as `payload.m[payload.k]` or `payload.m[true]`. The guard is `has(payload.a.b)` for plain names, or `"user-agent" in payload` for a key that is not a plain name. If the key appears more than once, or is selected on a macro variable (such as `i.name` in `exists(i, ...)`), or on the result of a conditional, a list or map literal, or a function call, the message does not name a guard. Otherwise it says to guard with `has()`, or with `in` for a key that is not a plain name. `has()` on a body that is not an object (an array, or `data: null`) is a `condition_error`, not `false`. **No run is started**, and other routines are not affected. A result that is not a boolean is also a `condition_error`.

### Schedule and webhook routines

On a schedule routine `when` can read `repository` only. A webhook routine can also read `payload`, see below. Forkline evaluates `when` once per selected repository at stage `when`. A repository for which it is `false` is skipped, so `when: '!repository.private'` runs a scheduled routine on public repositories only.

### Reading the webhook payload with `payload`

On a webhook routine, `payload` is the JSON body of the call, so a routine can ignore alerts that do not matter. Run now with a payload (see [Testing and activity](https://forkline.dev/docs/routines/testing-and-activity#run-now)) evaluates it the same way.

```yaml
filters:
  when: 'payload.level == "error" && payload.environment in ["prod", "production"]'
```

Other shapes: `payload.tags.exists(t, t == "urgent")`, `payload.count > 3` (a JSON number compares with whole numbers whether or not it has a decimal point; arithmetic such as `%` needs a whole number). Guard fields a sender may leave out with `has()`: `has(payload.level) && payload.level == "error"`. A body that is not JSON arrives as `payload.raw_body`. A JSON body that is an array is `payload` itself: read its elements with `payload[0]`, and `payload.x` on it is a `condition_error`. `when` cannot read a body that is a JSON string, number or `null`. Read it in the prompt instead.

Forkline cannot check payload field names when you save, because the body is only known at run time. A typo shows up as a `condition_error` on a run, or on a dry run with an example body.

To keep a condition fast, Forkline checks **the fields `when` reads** against these limits before it evaluates the condition. The rest of the body is not counted, so a large body such as a Sentry alert with a full stack trace can be filtered on `payload.level`.

| Limit on the fields `when` reads | Value |
|-------|-------|
| Weight of all read fields together: one per value and per key, plus one per 64 bytes of text | 4,096 |
| Nesting depth, counted from `payload` | 16 |
| Items in any one array or object | 1,000 |
| Text of strings and keys, all read fields together | 64 KiB |

What counts as read:

- **A field you compare, use as the list of an `in` test (`x in payload.list`), pass to `size()` or a string method, or run a macro such as `exists()` over** counts with everything under it: `payload.tags.exists(t, t == "urgent")` counts the whole `tags` list.
- **A field you only test with `has()`** counts as one value: `has(payload.data.event.exception)` does not count the exception.
- **A key you test with `in`** (`"user-agent" in payload`) counts as one value, like `has()`. On an array, `in` compares elements, so the whole array counts.
- **A constant index** counts that element, plus one per element before it: `payload.items[0].id` counts `id`. An index of 1,000 or more into a list that has that element is over the item limit: ``payload exceeds filter limits: `payload.items[1500]` is past the 1000-item limit``. Use `payload["user-agent"]` for keys that are not plain names, and test such a key with `"user-agent" in payload` (`has()` takes only plain names).
- **A computed index** counts the whole value it indexes: `payload.byEnv[payload.env]` counts all of `payload.byEnv`.
- **A field read twice** counts once.

When the fields read go over a limit, the run ends with outcome `condition_error` at stage `when`, the reason names the field, and nothing runs. For example, `payload.data.event.exception.values.exists(v, v.type == "TypeError")` reads the whole `values` list of a Sentry event, and a list with many frames gives ``payload exceeds filter limits: `payload.data.event.exception.values` takes the fields `when` reads past 4096 values and keys (long text counts as several)``. Read a smaller field (the event's `level` or `title` rather than its frames), or make the decision in the prompt, where templates see the whole body. A `when` that does not read `payload` is not affected.

```yaml routine
name: flaky-test-hunt
description: Look into pull requests that touch tests and are not from bots
on:
  events: [pr.opened, pr.updated]
repositories:
  include: all
filters:
  draft: include
  bots: exclude
  when: 'pr.labels.exists(l, l == "tests") || pr.title.contains("flaky")'
agent:
  prompt: |
    Check pull request #{{ pr.number }} for flaky tests.
```

```yaml routine
name: review-forkline-prs
description: Review the pull requests that Forkline itself opened
on:
  events: [pr.opened]
repositories:
  include: all
filters:
  self: only
  fork: exclude
agent:
  prompt: |
    Forkline opened pull request #{{ pr.number }}. Review it.
```

```yaml routine
name: dependency-bots
description: Look at pull requests opened by dependency bots
on:
  events: [pr.opened]
repositories:
  include: all
filters:
  bots: only
  authors:
    include: ["renovate[bot]", "dependabot[bot]"]
agent:
  prompt: |
    Check the changelog of the dependency bump in pull request #{{ pr.number }}.
```

```yaml routine
name: schedule-public-only
description: Weekly audit of public repositories
on:
  schedule:
    cron: "0 8 * * 1"
repositories:
  include: all
filters:
  when: '!repository.private && repository.name.startsWith("api-")'
agent:
  prompt: |
    Audit {{ repository.fullName }}.
```

## Examples that fail the one rule

A filter that does not apply to every listed event fails at its own pointer, whichever event is the misfit.

```yaml routine-invalid pointer=/filters/command code=filter_not_applicable
name: command-on-prs
description: command only applies to comment.created
on:
  events: [pr.opened, comment.created]
repositories:
  include: all
filters:
  command:
    names: [review]
agent:
  prompt: Review it.
```

Fix: write two routines, one for `pr.opened` and one for `comment.created`.

```yaml routine-invalid pointer=/filters/paths code=filter_not_applicable
name: paths-on-prs
description: paths only applies to push
on:
  events: [push, pr.updated]
repositories:
  include: all
filters:
  paths:
    include: ["src/**"]
agent:
  prompt: Check the change.
```

```yaml routine-invalid pointer=/filters/self code=filter_not_applicable
name: self-on-ci
description: ci.failed has no actor
on:
  events: [ci.failed]
repositories:
  include: all
filters:
  self: exclude
agent:
  prompt: Fix {{ ci.name }}.
```

```yaml routine-invalid pointer=/filters/draft code=filter_not_applicable
name: draft-on-issues
description: draft only applies to pull request events
on:
  events: [pr.opened, issue.opened]
repositories:
  include: all
filters:
  draft: include
agent:
  prompt: Look at it.
```

```yaml routine-invalid pointer=/filters/labels/include
name: empty-label-list
description: an empty list is an error, remove it instead
on:
  events: [pr.opened]
repositories:
  include: all
filters:
  labels:
    include: []
agent:
  prompt: Review it.
```

```yaml routine-invalid pointer=/filters/when
name: pr-field-on-push
description: no listed event carries pr
on:
  events: [push]
repositories:
  include: all
filters:
  when: 'pr.title.startsWith("feat")'
agent:
  prompt: Check the push.
```

```yaml routine-invalid pointer=/filters/branches code=filter_not_applicable
name: branches-on-schedule
description: a schedule routine accepts only when
on:
  schedule:
    cron: "0 6 * * 1"
repositories:
  include: all
filters:
  branches:
    include: [main]
agent:
  prompt: Audit it.
```
