Skip to content

Latest commit

 

History

History
249 lines (204 loc) · 11.2 KB

cel-triggers.md

File metadata and controls

249 lines (204 loc) · 11.2 KB

CEL-based agent triggers

The triggers: config key lets an operator declare CEL (Common Expression Language) rules that decide when an agent should be kicked for an incoming event — issue opened, issue labeled, PR opened, a comment posted, and so on — without writing code.

triggers:
  - name: bug-triage
    expr: event.kind == "issue.opened" && hasLabel(event.labels, "bug")
    agent: triager
    priority: 10

This is implemented by pkg/celtrigger.

How it composes with existing triggering

triggers: is additive. It runs alongside — not in place of — hive's built-in label/governor triggering: the governor's eval cycle evaluates the compiled rule set against each enumerated actionable item and unions the matched agent names into the due-agents set, after its own pause/budget/ on-demand gates already apply. An empty (or absent) triggers: list compiles to a valid engine that never matches anything, so leaving it out is byte-identical to today's behavior — nothing about default triggering changes because this feature exists.

The TriggerRule schema

Each entry under triggers: is one TriggerRule (pkg/config/config.go):

YAML field Type Required Notes
name string yes (in practice) Human-readable identifier for logs/diagnostics. Not enforced non-empty by the schema, but an empty name is rendered as rule[<index>] in error messages, so name your rules.
expr string yes The CEL expression. Must compile and must evaluate to a bool. An empty expression is a compile-time error.
agent string yes The agent to trigger when expr evaluates true. A rule with an empty agent compiles fine but is silently skipped when collecting matched agents.
priority int no (default 0) Orders competing rules when several match the same event — higher sorts first. Ties preserve declaration order.

Source: TriggerRule struct, pkg/config/config.go (Name, Expr, Agent, Priority fields with their yaml:"..." tags), and pkg/celtrigger/wire.go's CompileFromConfig, which maps each TriggerRule 1:1 onto a celtrigger.Rule.

What the CEL expression binds to

Every rule is evaluated against exactly one variable: event, a celtrigger.NormalizedEvent — hive's forge-neutral representation of a source-control event. event is the only activation exposed to the expression; there is no other variable, no access to config, no access to agent state.

The reachable fields, taken from the cel:"..." struct tags on NormalizedEvent (pkg/celtrigger/celtrigger.go), are:

CEL field Go type Meaning
event.kind string Event kind — see the Kind* constants below.
event.repo string Repository spelling from project.repos, e.g. "name" or "org/name"; use the configured spelling, not necessarily the qualified display label.
event.labels list of string Labels on the issue/PR.
event.title string Issue/PR title.
event.author string Login/handle of the actor that produced the event.
event.body string Issue/PR/comment body.
event.is_draft bool Whether a PR is a draft.
event.number int Issue/PR number.
event.state string Current state, e.g. "open"/"closed".
event.base_branch string Target branch for a PR.
event.head_branch string Source branch for a PR.
event.assignees list of string Currently-assigned logins.
event.comment string Body of the triggering comment (for comment.created).

Field access is type-checked at compile time against this registered native type — an expression that references a field not in this table (e.g. event.does_not_exist) is rejected at Compile, not left to fail silently at runtime.

event.kind takes one of these forge-neutral values (pkg/celtrigger/celtrigger.go):

issue.opened
issue.labeled
issue.closed
issue.reopened
pr.opened
pr.labeled
pr.ready_for_review
pr.closed
pr.merged
comment.created

issue.opened and pr.opened are edge-triggered: the eval cycle emits them once per issue/PR (keyed repo#number) per hive process, the first time the item is enumerated, not on every cycle while it stays actionable. The seen-set is in memory, so a hive restart re-emits currently actionable items once.

Run the scanner for newly seen GitHub issues

Open Settings → Features → Work intake & issue lifecycle → Event triggers, or use the Event triggers button in Agent → Configuration → Channels. Owner access is required. Choose Run scanner when an issue is first seen, enter a rule name, select the existing scanner agent and one managed repository, then use Save Event trigger. No YAML editing is required. The repository picker displays qualified labels but saves the exact configured repository spelling used by evaluation; there is no all-repositories default.

Existing rules can be edited with Advanced CEL, preserving their expression, order, target and priority unless explicitly changed. Higher priority sorts first; ties retain declaration order. There is no enabled field: remove a rule to stop its routing. These operations do not change unrelated settings or default governor behavior.

The editor validates the complete rule list, including CEL compilation and a boolean result, before persistence. New or edited rules must name an existing agent. Save errors keep the draft visible; a revision conflict requires reloading the latest rules instead of overwriting another operator's changes. Reload (discard draft) explicitly discards the pending edit. This editor has its own save button; the dialog's Save & close does not save its draft. Successful saves are durable and apply on the next event evaluation, with no restart required; saving does not immediately kick an agent. Updating rules does not reset already-seen item history.

Agent → Configuration → Channels is not a GitHub webhook installer. Only the governor kick channel has a supported runtime; the old webhook, discord, schedule, and bead channel runtimes were removed. For file-based configuration, the equivalent is a top-level rule in hive.yaml (keep existing rules):

triggers:
  - name: scanner-new-issue
    expr: event.kind == "issue.opened" && event.repo == "org/repo"
    agent: scanner

Replace org/repo with the exact spelling from project.repos and ensure the scanner is enabled. Normal pause, budget, and on-demand gates still apply. This means first seen actionable issue, not exclusively an issue created after the rule was installed: existing actionable issues match once, and restart replays them once as described above.

For prompt delivery rather than waiting for reconciliation, use the existing signed GitHub webhook receiver and subscribe to issues: operator setup. Hosted hives normally receive the GitHub App's signed deliveries through the hub relay; stand-alone hives use POST /api/webhook/github. The receiver requires GITHUB_WEBHOOK_SECRET and a matching HMAC signature. Saving a channel or adding a CEL rule neither registers a webhook nor configures its secret. Delivery wakes an early governor evaluation subject to debounce/minimum-gap pacing; it does not bypass agent execution gates.

Available functions

Besides the standard CEL string/list operators (in, .contains(...), .startsWith(...), .matches(...), &&, ||, !, ==, comprehensions like .exists(...)/.all(...)), one custom helper is registered:

  • hasLabel(event.labels, "bug") — convenience for "bug" in event.labels; returns false (not an error) for non-list or non-string arguments.

No other custom functions exist. Do not invent one — an expression calling an unregistered function is a compile-time error.

Evaluation cost budget

Each rule's evaluation is bounded (maxEvalCost = 10_000 in pkg/celtrigger/celtrigger.go) to stop pathological nested comprehensions (e.g. a triple-nested .all() over labels) from burning runtime per event. A rule that exceeds the budget is treated as no-match for that evaluation (not an error, not a crash) and logs a warning naming the rule and the budget so you can diagnose why it stopped firing. Realistic operator rules — field checks, string predicates, a label .exists(...)/.contains(...) — comfortably fit inside the budget.

Fail-closed semantics

This is a safety property, stated plainly:

  • Compile time (config load): every rule's expr is parsed, type-checked, and confirmed to return bool. If any rule in triggers: is malformed — a syntax error, a reference to a field that doesn't exist, a call to an unknown function, an expression that returns something other than bool, or an empty expression — the entire config load fails with an error. No partial engine is produced; the bad rule cannot reach a running fleet.
  • Runtime (event evaluation): if a compiled rule still errors while evaluating a specific event (including exceeding the cost budget above), that is treated as "no match," not a crash and not an error propagated to the caller. One misbehaving rule cannot take down evaluation for the other rules or for the event pipeline.

In short: a bad rule is caught early and loudly (at config load); a rule that merely fails to match at runtime does so quietly and safely.

Worked example

This rule set compiles and matches, verified against pkg/celtrigger/wire_test.go's TestCompileFromConfig_ValidAndMatch:

triggers:
  - name: bug
    expr: hasLabel(event.labels, "bug")
    agent: triager
    priority: 10
  - name: pr
    expr: event.kind == "pr.opened"
    agent: reviewer
    priority: 5

An issue.labeled event carrying the label bug matches the bug rule and kicks triager. A more selective example, combining a kind check, a non-draft check, and a target-branch check — verified against pkg/celtrigger/celtrigger_test.go's TestMatch_BooleanCombos:

triggers:
  - name: ready-pr
    expr: event.kind == "pr.opened" && !event.is_draft && event.base_branch == "main"
    agent: reviewer

This matches a non-draft PR opened against main, and does not match a draft PR against the same branch.

A slash-command-style comment trigger — verified against TestMatch_CommentEvent:

triggers:
  - name: slash-cmd
    expr: event.kind == "comment.created" && event.comment.startsWith("/hive")
    agent: cmd

Relationship to hook predicates (when:)

State-triggered hooks use the same CEL engine and the same fail-closed posture for their optional when: predicate, but bind a different, unrelated variable (t, the transition payload) — see Predicates (when:) in that doc. triggers: and hooks' when: are two separate surfaces that happen to share an evaluation engine: triggers: decides whether to kick an agent for a normalized source-control event, while hooks' when: decides whether to fire an action on a state transition. Don't mix event.* fields into a hook's when: or t.* fields into a triggers: rule — the field sets are disjoint and referencing the wrong one is a compile error.