---
title: "Events"
description: "The events a routine can listen to, how each forge's webhooks map to them, and every field an event carries."
section: "routines"
url: "https://forkline.dev/docs/routines/events"
markdown: "https://forkline.dev/docs/routines/events.md"
source: "src/content/docs/routines/events.mdx"
---

# Events

> [!TIP]
> **Agent hint**
> Rules on this page that most often cause invalid routines:
>
> - Event names are **exact and case-sensitive**: `pr.opened`, not `PR.Opened` or `pull_request.opened`. List **1 to 8** events, each once.
> - The six **reserved names** in [Reserved events](#reserved-events) are rejected with "is not supported yet". Do not use `pr.labeled`, `pr.conflicted`, `ci.succeeded` and the like.
> - Every filter and every template variable must exist for **every listed event**. A `when` field needs only one listed event that carries it; on the others it is `null` (guard with `pr.number != null && ...`). When the events differ, write one routine per event shape. The [filter matrix](#which-filters-apply-to-which-event) shows what is allowed.
> - `ci.failed` has **no actor**: no `sender`, and the `self` and `bots` filters are refused for it.
> - `comment.created` carries a `pr` **or** an `issue` (the parent of the comment), never both. `ci.failed` carries a `pr` only when the CI run belongs to a pull request. A routine that needs the pull request (its prompt reads `pr.number` or `pr.headBranch`) must add `filters.when: 'pr.number != null'`; otherwise a run without a pull request starts with an empty `pr`, and `limits.maxRunsPerPr` does not apply to it.
> - Field names are the same everywhere (`pr.baseBranch`, `comment.mentionsBot`), with one exception: the slash command is `comment.command.name` in templates and `comment.commandName` in `when`.

An **event** is something that happened in a connected repository. Forkline turns the webhook of each forge (GitHub, GitLab, Forgejo) into one forge-neutral event, so the same routine works on all three. A routine lists the events it wants in `on.events`:

```yaml
on:
  events: [ci.failed, pr.updated]
```

This page lists the events, the exact webhook each one comes from, and the typed fields it carries. [Filters](https://forkline.dev/docs/routines/filters) decide which events start a run, and [Prompt templates](https://forkline.dev/docs/routines/templates) put the fields into the prompt. Routines started by a schedule or a custom webhook have no events, see [Schedule and webhook triggers](#schedule-and-webhook-triggers).

## The events

| Event | Fires when | Actor |
|-------|-----------|-------|
| `pr.opened` | A pull request (merge request on GitLab) was opened, reopened, or marked ready for review | the person who acted |
| `pr.updated` | New commits were pushed to a pull request | the person who pushed |
| `pr.merged` | A pull request was merged | the person who merged |
| `pr.closed` | A pull request was closed without being merged | the person who closed it |
| `issue.opened` | An issue was opened | the person who opened it |
| `comment.created` | A comment was added to an issue or a pull request | the commenter |
| `ci.failed` | A CI run failed or timed out | none, Forkline derives this event |
| `push` | Commits were pushed to a branch | the pusher |

The **actor** is the `sender` of the event: the person or bot whose action caused the webhook. The `self` and `bots` filters judge the actor. `ci.failed` has no actor because the forge reports a failed run, not a person.

## Forge mapping

Each cell names the webhook event the forge sends and the action values that map to the event. A webhook event or action that is not in the table is **dropped silently**: no routine sees it and no run is recorded.

| Event | GitHub | GitLab | Forgejo |
|-------|--------|--------|---------|
| `pr.opened` | `pull_request` with action `opened`, `reopened` or `ready_for_review` | `merge_request` with action `open` or `reopen` | `pull_request` with action `opened`, `reopened` or `ready_for_review` |
| `pr.updated` | `pull_request` with action `synchronize` | `merge_request` with action `update` **and** a changed commit (`oldrev` present); edits of the title, description or labels are ignored | `pull_request` with action `synchronized` (or `synchronize`) |
| `pr.merged` | `pull_request` with action `closed` and `merged: true` (or a `merged_at` time) | `merge_request` with action `merge`, or `close` with a `merged_at` time | `pull_request` with action `closed` and `merged: true` (or a `merged_at` time) |
| `pr.closed` | `pull_request` with action `closed`, not merged | `merge_request` with action `close`, not merged | `pull_request` with action `closed`, not merged |
| `issue.opened` | `issues` with action `opened` | `issue` with action `open` | `issues` with action `opened` |
| `comment.created` | `issue_comment` or `pull_request_review_comment`, action `created` | `note` on an Issue or a MergeRequest | `issue_comment` or `pull_request_comment`, action `created` |
| `ci.failed` | `workflow_run` with action `completed` and conclusion `failure` or `timed_out` | `pipeline` with status `failed` (merge request pipelines included) | `action_run_failure` with run status `failure`, or `workflow_run` with action `completed` and conclusion `failure` or `timed_out` |
| `push` | `push` to a branch (`refs/heads/...`) | `push` to a branch | `push` to a branch |

Edge cases that change what an event means:

- **Reopen and ready for review count as `pr.opened`.** A routine on `pr.opened` runs again when a pull request is reopened, and when a draft is marked ready for review (GitHub and Forgejo). A pull request opened as a draft also fires `pr.opened` once, with `pr.draft` set to `true`; the default `draft: exclude` filter skips it, and the later ready-for-review fires the event again with `pr.draft` set to `false`.
- **`pr.updated` means new commits**, not edits. Changing the title, description, labels or assignees does not fire it on any forge.
- **Merge versus close.** A merged pull request fires `pr.merged` only; a pull request closed without merging fires `pr.closed` only. Listing both gives the routine every end of a pull request.
- **Comments.** On GitHub a comment on a pull request conversation arrives as `issue_comment` and carries the pull request as `pr` (without its branches and head commit, see the field notes); a comment on an inline review arrives as `pull_request_review_comment` and carries the full `pr`. A comment on an issue carries `issue`. GitLab `note` events on commits and snippets are dropped. Edited and deleted comments are dropped.
- **Push means a branch push.** A tag push, a branch deletion and a push whose new commit is all zeros are dropped. On GitLab, a `tag_push` is dropped as well.
- **`ci.failed`** fires for failed and timed-out runs only. Cancelled, skipped, neutral and successful runs, GitHub `check_run`, `check_suite` and `status` events, and Forgejo commit statuses are dropped. Forkline starts one routine run per failed run or pipeline; it does not wait to collect several failures of one commit.
- **Labels, assignment and review requests** (`labeled`, `assigned`, `review_requested` and the like) are dropped; the events for them are [reserved](#reserved-events).
- **Draft and fork flags** come from the forge payload. Forgejo reports no draft flag on some versions, so Forkline treats a title starting with `Draft:`, `[Draft]`, `(Draft)`, `WIP:`, `[WIP]` or `(WIP)` as a draft. GitLab merge-request drafts use the `draft` flag (or the older `work_in_progress`). Toggling draft status without new commits is not an event on GitLab.

## What each event carries

Every event carries `repository`. The other roots depend on the event.

| Event | `sender` | `pr` | `issue` | `comment` | `ci` | `push` |
|-------|----------|------|---------|-----------|------|--------|
| `pr.opened`, `pr.updated`, `pr.merged`, `pr.closed` | yes | yes | no | no | no | no |
| `issue.opened` | yes | no | yes | no | no | no |
| `comment.created` | yes | when the comment is on a pull request | when the comment is on an issue | yes | no | no |
| `ci.failed` | **no** | when the run belongs to a pull request | no | no | yes | no |
| `push` | yes | no | no | no | no | yes |

"Carries" means filled in. A root that an event does not carry is absent: it renders empty in a template and is `null` or an empty list in `when`.

## Fields

All field names are camelCase and are the same in [templates](https://forkline.dev/docs/routines/templates) and in the [`when` condition](https://forkline.dev/docs/routines/filters#when-conditions). Values are bounded when the event is read: short strings (logins, branches, labels, refs, SHAs) at most 1,024 bytes, titles 1,024 bytes, bodies 64 KiB, URLs 4,096 bytes, paths 512 bytes, at most 100 labels, 100 `ci.failures` and 3,000 push paths. Longer text is cut at a character boundary.

### `repository` (every event)

| Field | Type | May be empty | Example |
|-------|------|--------------|---------|
| `repository.fullName` | string | no | `acme/api` (GitLab: the nested path, `group/sub/api`) |
| `repository.owner` | string | no | `acme` |
| `repository.name` | string | no | `api` |
| `repository.url` | string | no | `https://github.com/acme/api` |
| `repository.defaultBranch` | string | event runs: no, it comes from the webhook; schedule and webhook runs: yes, until Forkline has synced the repository | `main` |
| `repository.private` | boolean | no | `false` (GitLab: internal repositories count as private) |

### `sender` (every event except `ci.failed`)

| Field | Type | May be empty | Example |
|-------|------|--------------|---------|
| `sender.login` | string | only when the forge sent no sender | `alice` |
| `sender.isBot` | boolean | no | `true` for `renovate[bot]` |

`sender.isBot` and `pr.authorIsBot` are `true` when the forge marks the account as a bot (GitHub and Forgejo user type `Bot`), or the login ends in `[bot]`, `_bot` or `-bot` (any case), or looks like a GitLab project or group token bot (`project_12_bot_ab12`, `group_3_bot_zz`). On `push` the sender is the forge's sender, or the pusher when there is none.

### `pr` (`pr.*` events, and sometimes `comment.created` and `ci.failed`)

| Field | Type | May be empty | Example |
|-------|------|--------------|---------|
| `pr.number` | integer | no | `412` |
| `pr.title` | string | see notes | `Add retry to the uploader` |
| `pr.body` | string | yes (no description) | `Fixes #88` |
| `pr.author` | string | see notes | `alice` |
| `pr.authorIsBot` | boolean | no (`false` on GitLab except when the merge request is first opened) | `false` |
| `pr.baseBranch` | string | see notes | `main` |
| `pr.headBranch` | string | see notes | `feat/retry` |
| `pr.headSha` | string | see notes | `9fceb02d0ae598e95dc970b74767f19372d61af8` |
| `pr.labels` | list of strings | yes (empty list) | `[needs-review, backend]` |
| `pr.draft` | boolean | no | `false` |
| `pr.fork` | boolean | no | `true` when the head repository differs from the base repository |
| `pr.url` | string | see notes | `https://github.com/acme/api/pull/412` |

Notes:

- **Comment on a pull request** (`comment.created`): on GitHub and Forgejo, `issue_comment` events do not include the branches, so `pr.baseBranch`, `pr.headBranch` and `pr.headSha` are empty and `pr.draft` and `pr.fork` are `false`. Review comments (`pull_request_review_comment`, `pull_request_comment`) carry all fields. The agent can read the branch from the forge when it needs it.
- **`ci.failed` without a pull request** (a run on a branch that has no open pull request, or on GitLab any pipeline that is not a merge request pipeline): there is no `pr` at all. `pr.number` is `null` in `when` and empty in a template, so `when: 'pr.number != null'` is false and skips the event with outcome `filtered` at stage `when`. Put that guard on every `ci.failed` routine that is about a pull request. Without a pull request there is no pull request run cap (`maxRunsPerPr`), and the concurrency group key is the repository (a `ci.failed` run without a pull request has neither a pull request nor an issue number).
- **`ci.failed` on a pull request**: on GitHub and Forgejo, only `pr.number`, `pr.headSha` and, on GitHub, `pr.baseBranch` and `pr.headBranch` are filled (on Forgejo the branches are usually empty). `pr.title`, `pr.body`, `pr.author` and `pr.labels` are empty, `pr.draft` and `pr.fork` are `false`, and `pr.url` is built from the repository URL, so do not rely on it. On GitLab, only a merge request pipeline has a `pr`. It fills `pr.number` (the merge request iid), `pr.title`, `pr.url`, `pr.headSha`, `pr.baseBranch`, `pr.headBranch` and `pr.fork` (`true` when the source and target projects differ). `pr.body`, `pr.author` and `pr.labels` are empty and `pr.draft` is `false`. A pipeline that runs in a project other than the merge request's target project (a fork's own pipeline) has no `pr`.
- **GitLab** reports the merge-request author only when it is opened (`open`, not `reopen`), so `pr.author` is empty and `pr.authorIsBot` is `false` on a reopened merge request, on `pr.updated`, `pr.merged` and `pr.closed`, and for comments on a merge request. `!pr.authorIsBot` therefore does not skip bot merge requests there; see [Authors and labels](https://forkline.dev/docs/routines/filters#authors-and-labels).
- `pr.fork` is `true` for a pull request whose head repository is a different repository (or was deleted). GitLab compares the source and target project.

### `issue` (`issue.opened`, and `comment.created` on an issue)

| Field | Type | May be empty | Example |
|-------|------|--------------|---------|
| `issue.number` | integer | no | `88` |
| `issue.title` | string | no | `Uploader crashes on empty files` |
| `issue.body` | string | yes | `Steps to reproduce ...` |
| `issue.author` | string | yes on GitLab comments | `bob` |
| `issue.labels` | list of strings | yes (empty list) | `[bug]` |
| `issue.url` | string | no | `https://github.com/acme/api/issues/88` |

### `comment` (`comment.created`)

| Field | Type | May be empty | Example |
|-------|------|--------------|---------|
| `comment.id` | integer | no | `1840221` |
| `comment.body` | string | yes | `/fix-ci please` |
| `comment.author` | string | no | `alice` |
| `comment.url` | string | no | `https://github.com/acme/api/pull/412#issuecomment-1840221` |
| `comment.mentionsBot` | boolean | no | `true` for `@forkline please review` |
| `comment.command` | object with `name` and `args` | name and args are empty strings when there is no command | `{ name: "fix-ci", args: "please" }` |

- **Slash command.** Forkline looks at the **first line** that starts, after optional spaces, with `/`, a letter, and then letters, digits, `-` or `_`, followed by a space or the end of the line. Lines such as `/usr/bin` or `/1 not` are skipped. `comment.command.name` is the name in lower case without the slash (`/Fix-CI` gives `fix-ci`); `comment.command.args` is the rest of that line, trimmed, at most 1,024 bytes. Later command lines are ignored. A line inside a code block counts like any other line.
- **Mentions.** `comment.mentionsBot` is `true` when the text contains `@forkline` or `@forkline-bot` (any case) as a whole word. It is not triggered by an e-mail address (`a@forkline.dev`) or a longer name (`@forklinex`).
- **Flat names in `when`.** In a template, use `comment.command.name` and `comment.command.args`. In a `when` condition the same values are the flat strings `comment.commandName` and `comment.commandArgs`; `comment.command` does not exist there.

### `ci` (`ci.failed`)

| Field | Type | May be empty | Example |
|-------|------|--------------|---------|
| `ci.name` | string | no | `CI` (GitLab: `pipeline` unless the pipeline has a name) |
| `ci.runId` | string | no | `10847722115` |
| `ci.url` | string | no | `https://github.com/acme/api/actions/runs/10847722115` |
| `ci.conclusion` | string | no | `failure` or `timed_out` (GitHub, Forgejo); `failed` (GitLab) |
| `ci.headSha` | string | no | `9fceb02d0ae598e95dc970b74767f19372d61af8` |
| `ci.branch` | string | yes, on Forgejo when the run belongs to a pull request | `feat/retry` |
| `ci.failures` | list of objects | yes on GitLab (no failed job reported); never on GitHub and Forgejo | see below |

Each entry of `ci.failures` has exactly four fields: `name`, `runId`, `url` and `conclusion`. There are **no logs**: the agent fetches logs itself, for example with the `git-api` tool in the runner.

- **GitHub and Forgejo**: the webhook does not list jobs, so `ci.failures` has **one** entry, the failed run itself.
- **GitLab**: `ci.failures` lists the failed jobs of the pipeline (up to 100), without jobs that are allowed to fail. Each job has `conclusion` `failed` and a URL of the form `{repository}/-/jobs/{id}`.
- `ci.branch` is the branch the run was on. On GitLab it is the pipeline's ref, which can be a tag name.

### `push` (`push`)

| Field | Type | May be empty | Example |
|-------|------|--------------|---------|
| `push.ref` | string | no | `refs/heads/main` |
| `push.branch` | string | no | `main` |
| `push.before` | string | no | `0f5e9d7c1b2a` (all zeros for a new branch) |
| `push.after` | string | no | `9fceb02d0ae598e95dc970b74767f19372d61af8` |
| `push.forced` | boolean | no | `false` (always `false` on GitLab) |
| `push.commitCount` | integer | no | `3` |
| `push.files.added` | list of strings | yes | `[docs/guide.md]` |
| `push.files.modified` | list of strings | yes | `[src/main.rs]` |
| `push.files.removed` | list of strings | yes | `[old.txt]` |
| `push.filesTruncated` | boolean | no | `true` when the lists are incomplete |

The three file lists hold the paths from every commit in the webhook, each path once per list. `push.filesTruncated` is `true` when the forge listed fewer commits than were pushed, or when the push has more than 3,000 paths: the lists are then a prefix of the truth. In a `when` condition each list holds at most 500 paths, and `push.filesTruncated` is then `true`; the `paths` filter sees all of them.

### Fields only templates have

Templates also read `event.name`, `event.forge` (`github`, `gitlab` or `forgejo`), `event.deliveryId` (the forge's delivery ID, at most 200 bytes) and `receivedAt`; `when` cannot. See [Prompt templates](https://forkline.dev/docs/routines/templates).

## Which filters apply to which event

Each filter applies to a fixed set of events, and **every filter you set must apply to every event you list**. This table is generated from the validator: `yes` means the filter applies.

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

Combining events narrows the choice to the columns that are `yes` for all of them. For example, `[pr.opened, push]` allows `branches`, `authors`, `bots`, `self` and `when`; `[pr.opened, ci.failed]` allows `branches` and `when` only. See [Filters](https://forkline.dev/docs/routines/filters) for what each filter does, and the [invalid examples there](https://forkline.dev/docs/routines/filters#examples-that-fail-the-one-rule) for the error.

## Reserved events

These names are planned. They are **rejected when you save**, so a routine cannot be written against them yet.

| Name | Planned meaning |
|------|-----------------|
| `pr.conflicted` | A pull request has a merge conflict with its base branch |
| `ci.succeeded` | A CI run passed |
| `pr.labeled` | A label was added to a pull request |
| `issue.labeled` | A label was added to an issue |
| `issue.assigned` | An issue was assigned |
| `pr.review_requested` | A review was requested on a pull request |

The error is `422` at `/on/events/{index}` with the message `` `pr.labeled` is not supported yet `` (the name you wrote). Other event-list errors:

| Mistake | Pointer | Message |
|---------|---------|---------|
| Misspelled or unknown name | `/on/events/{index}` | `` Unknown event `pr.open`. Did you mean `pr.opened`? Events: `pr.opened`, `pr.updated`, `pr.merged`, `pr.closed`, `issue.opened`, `comment.created`, `ci.failed`, `push`. `` |
| Same name twice | `/on/events/{index}` | `` `push` is listed twice `` |
| Empty list or more than 8 | `/on/events` | `list between 1 and 8 events` |

Until a reserved event exists, approximate it:

```yaml
# pr.labeled is not available: run on every update and test the label in `when`
filters:
  when: '"needs-review" in pr.labels'
```

```yaml routine-invalid pointer=/on/events/0
name: label-review
description: A reserved event name is refused
on:
  events: [pr.labeled]
repositories:
  include: all
agent:
  prompt: Review pull request {{ pr.number }}.
```

```yaml routine-invalid pointer=/on/events/1
name: misspelled-event
description: Event names are exact
on:
  events: [pr.opened, pr.open]
repositories:
  include: all
agent:
  prompt: Review pull request {{ pr.number }}.
```

## Schedule and webhook triggers

A routine can be started by something other than an event: `on.schedule` (a cron expression or a single time) or `on.webhook` (a custom webhook URL). These have no forge event, so there is no `sender`, no `pr`, no `issue`, no `comment`, no `ci` and no `push`.

| Trigger | What the prompt can read |
|---------|--------------------------|
| `on.schedule` | `repository.*`, `event.name` (`schedule`), `schedule.scheduledAt`, `receivedAt` (the scheduled time) |
| `on.webhook` | `repository.*`, `event.name` (`webhook`), `receivedAt`, and the JSON body of the call as `payload.*` (non-JSON bodies as `payload.raw_body`) |

Run now on an **event** routine replays a recent stored event instead; see [Run now](https://forkline.dev/docs/routines/testing-and-activity#run-now).

The source of a run is `event` (a forge webhook), `schedule`, `webhook` (a call to the custom webhook URL) or `manual` (Run now); a `manual` run gives the prompt the same data as a webhook call. Both start one run per selected repository. The only filter they accept is `when`, and it can read `repository` (and, on a webhook routine, `payload`). See [Prompt templates](https://forkline.dev/docs/routines/templates) for the variables and [Repository selection](https://forkline.dev/docs/routines/repositories) for how a run fans out over repositories.

```yaml routine
name: weekly-dependency-update
description: Update dependencies every Monday morning
on:
  schedule:
    cron: "0 6 * * 1"
    timezone: Europe/Berlin
repositories:
  include:
    - name: "acme/*"
filters:
  when: '!repository.private'
agent:
  prompt: |
    Update the dependencies of {{ repository.fullName }}.
    This run was scheduled for {{ schedule.scheduledAt }}.
```

## Recent events and privacy

Forkline records a short summary of every event it receives, so you can see what arrived and what each routine decided. The summary is **one line of metadata** per event (titles are cut to 200 characters, and a summary keeps at most 20 labels and 10 CI failures): the event name, the forge, the repository, the actor, the numbers, titles, branches and labels of the pull request or issue, the comment's author and command name, the CI conclusion and failed run names, and the push ref and commit count. It never contains pull request or issue bodies, comment text, command arguments, file lists, logs or the webhook payload.

**Event summaries (up to 4 KiB of metadata, no bodies or comment text) are kept for 7 days, for every event of every connected repository, and each owner keeps at most 5,000.** Older ones are deleted. Over 4 KiB, labels and failures are dropped from the end of the summary and it is marked truncated. See [Testing and activity](https://forkline.dev/docs/routines/testing-and-activity) for how to browse recent events, test a routine against one, and backtest it.

## Examples

A routine for failed CI on pull requests, written for the one event whose fields it reads:

```yaml routine
name: fix-failed-ci
description: Fix CI when a pull request run fails
on:
  events: [ci.failed]
repositories:
  include: all
filters:
  branches:
    exclude: [main]
  when: 'ci.failures.size() > 0'
agent:
  prompt: |
    The run {{ ci.name }} failed on {{ ci.branch }} ({{ ci.headSha }}).
    {% for f in ci.failures %}Failed: {{ f.name }} {{ f.url }}
    {% endfor %}
    Fetch the logs, fix the failure and push to the branch.
```

A routine that reacts to a slash command on pull requests and issues alike:

```yaml routine
name: fix-command
description: Run on the /fix command
on:
  events: [comment.created]
repositories:
  include: all
filters:
  command:
    names: [fix]
agent:
  prompt: |
    {{ comment.author }} asked for a fix: {{ comment.command.args }}
    Comment: {{ comment.url }}
```
