From defe44de34973475f50e6321ebde2422e42bf95e Mon Sep 17 00:00:00 2001 From: Konstantin Kolesnyak Date: Thu, 6 Aug 2026 18:10:09 +0200 Subject: [PATCH] github source: reason and CI-branch guardrails + drop-reason logging - allow_reasons/deny_reasons filter on GitHub's notification reason (deny comment/subscribed keeps mentions and review requests while dropping watch churn) - deny_ci_branches drops workflow-run noise on branches that aren't yours (deny-only: ci_branch is empty for non-CI notifications, so an allow list would fail closed and drop the whole feed) - InboxFilter.rejects() + capped per-record drop logging so a vanished notification can be traced to the rule that ate it Co-Authored-By: Claude Fable 5 --- config.example.yaml | 22 +++- docs/sources.md | 70 +++++++++- nerve/config.py | 21 +++ nerve/sources/filters.py | 11 ++ nerve/sources/github.py | 23 ++++ nerve/sources/registry.py | 13 +- nerve/sources/runner.py | 15 +++ tests/test_github_ci_branch.py | 231 +++++++++++++++++++++++++++++++++ tests/test_source_filters.py | 26 ++++ 9 files changed, 426 insertions(+), 6 deletions(-) create mode 100644 tests/test_github_ci_branch.py diff --git a/config.example.yaml b/config.example.yaml index 22154b35..570f7d0d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -128,10 +128,28 @@ sync: github: enabled: true schedule: "*/15 * * * *" - # Guardrails — limit which repos reach the inbox (case-insensitive globs). - # allow_repos = allowlist (empty = all); deny_repos takes precedence. + # Guardrails (case-insensitive globs; deny wins; non-empty allow is + # fail-closed). allow_* = allowlist (empty = all pass). + # repo_name — the notification's repo full_name ("ClickHouse/nerve"). + # actors — every login involved (author, assignees, commenters). + # reason — GitHub's notification reason: mention, author, + # review_requested, assign, team_mention, comment, + # subscribed, ci_activity, state_change, ... + # ci_branch — branch of a CI run (empty for everything else), so this + # one is deny-only: an allow list would fail closed and + # drop every non-CI notification. allow_repos: [] # Example: ["ClickHouse/*", "myorg/myrepo"] deny_repos: [] + allow_actors: [] + deny_actors: [] + allow_reasons: [] + deny_reasons: [] # Example: ["comment", "subscribed"] — mute + # follow-up churn on threads you only + # commented on / watch; keep mentions, + # review requests, and your own PRs. + deny_ci_branches: [] # Example: ["main", "master"] — keep CI + # failures on your own PR branches, drop + # default-branch syncs and scheduled runs. github_repos: # Monitor a set of repos for NEW issues & PRs enabled: false schedule: "*/15 * * * *" diff --git a/docs/sources.md b/docs/sources.md index 1e45593d..88c5c15b 100644 --- a/docs/sources.md +++ b/docs/sources.md @@ -141,8 +141,9 @@ visible rather than failing the fetch. - **Cursor:** ISO 8601 timestamp of the newest notification's `updated_at` - **First run:** Fetches from the last 24 hours - **Subsequent runs:** Uses `since=` with `Z` suffix (not `+00:00` — the `+` in a URL query string is decoded as a space, breaking the filter) -- **Filter:** `participating=true` (assigned, review requested, mentioned) +- **Filter:** `participating=true` (assigned, review requested, mentioned). Note it never returns `ci_activity` — workflow-run notifications are not "participating", so CI never reaches the inbox through this source at all (verified against a live account: 25 `ci_activity` with `all=true`, 0 with `participating=true`) - **Enrichment:** Each notification is enriched with actual content from the subject (PR/issue body, state, assignees, labels) and the latest comment, fetched in parallel (up to 5 concurrent `gh api` calls) +- **Metadata:** `reason`, `repo_name`, `actors` (every login involved) and `ci_branch` (branch of a CheckSuite run, `""` otherwise) — all four are guardrail-filterable, see [Guardrails](#guardrails-inbox-filtering) - **Default schedule:** `*/15 * * * *` (every 15 min) ### GitHub Events @@ -221,6 +222,9 @@ sync: deny_repos: [] # Guardrail denylist — always dropped (takes precedence) allow_actors: [] # Guardrail allowlist of GitHub logins — empty = all. Example: ["alice", "bob"] deny_actors: [] # Guardrail denylist of GitHub logins — always dropped (takes precedence) + allow_reasons: [] # Guardrail allowlist of GitHub reasons — empty = all. Example: ["mention"] + deny_reasons: [] # Guardrail denylist of reasons. Example: ["comment", "subscribed"] + deny_ci_branches: [] # Drop CI runs on these branches. Example: ["main", "master"] github_events: enabled: true @@ -315,6 +319,70 @@ default), all actors pass — behavior is unchanged. The repo and actor rules AN | `github.allow_actors` | list | `[]` | Allowlist of GitHub login globs. Empty = all actors pass | | `github.deny_actors` | list | `[]` | Denylist of GitHub login globs. Takes precedence over `allow_actors` | +### GitHub reason guardrail + +`reason` is GitHub's own answer to "why am I being told this": `author` (you opened the +thread), `mention`, `team_mention`, `assign`, `review_requested`, `comment` (you commented +on it once), `subscribed` (you only watch it), `manual` (you subscribed by hand), +`ci_activity`, `state_change`, `security_alert`. + +```yaml +sync: + github: + deny_reasons: ["comment", "subscribed", "manual", "state_change"] +``` + +That denylist is the compact way to say *"only threads that are mine or that call me by +name"* — it keeps `author`, `mention`, `team_mention`, `assign`, `review_requested` and +`ci_activity`. Prefer `deny_reasons` over `allow_reasons`: the allowlist is fail-closed, so +a reason GitHub adds later would be silently dropped. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `github.allow_reasons` | list | `[]` | Allowlist of reason globs. Empty = all reasons pass | +| `github.deny_reasons` | list | `[]` | Denylist of reason globs. Takes precedence over `allow_reasons` | + +### GitHub CI-branch guardrail + +Keeping `ci_activity` lets in every workflow-run notification, including default-branch +runs that have nothing to do with your work — upstream syncs, scheduled cleanup jobs, +deploys. GitHub has no server-side setting for this: one workflow file serves both the +`push`-to-`main` runs and the `pull_request` runs, and the Actions notification preference +is account-wide. + +CheckSuite notifications carry no `subject.url` and can't be enriched, but the branch is +right there in the title (`"CI workflow run failed for main branch"`). The source parses it +into the `ci_branch` metadata key, which is `""` for every other notification: + +```yaml +sync: + github: + deny_ci_branches: ["main", "master"] +``` + +You keep CI failures on your own PR branches and drop the default-branch noise. This rule +is **deny-only** by design — `ci_branch` is empty for non-CI records, and a non-empty allow +list is fail-closed, so it would drop the entire feed. + +**Currently inert:** the fetch uses `participating=true`, which never returns `ci_activity` +(see the GitHub adapter notes above), so no CheckSuite record reaches the guardrail today. +The rule exists for the day that filter is relaxed — without it, dropping `participating` +would flood the inbox with default-branch runs. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `github.deny_ci_branches` | list | `[]` | Denylist of branch globs for CI runs. Empty = all CI notifications pass | + +### Debugging drops + +Dropped records are never persisted, so the run summary count is backed by per-record log +lines naming the rule that fired: + +``` +Source github: guardrail dropped 3/5 records (e.g. '[owner/repo] CI workflow run failed …') +Source github: dropped 19542… on ci_branch='main' — [owner/repo] CI workflow run failed … +``` + ### Extending to other sources Adding a guardrail to another source is a config field plus one registry line. In diff --git a/nerve/config.py b/nerve/config.py index 7db36d3b..b4b78ac4 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -1208,6 +1208,24 @@ class GitHubSyncConfig: # matching actor is dropped before it reaches the inbox). Empty = all pass. allow_actors: list[str] = field(default_factory=list) deny_actors: list[str] = field(default_factory=list) + # Reason guardrails — limit which GitHub notification "reason" values reach + # the inbox, matched on the "reason" metadata key (GitHub's own reason: + # mention, author, review_requested, assign, team_mention, comment, + # subscribed, ci_activity, state_change, ...). Same semantics — case- + # insensitive globs, deny wins, non-empty allow is fail-closed. Denying + # "comment"/"subscribed" drops follow-up churn on threads you merely + # commented on or watch, while keeping mentions, review requests and + # activity on your own PRs/issues. Empty = all reasons pass. + allow_reasons: list[str] = field(default_factory=list) + deny_reasons: list[str] = field(default_factory=list) + # CI guardrail — drop workflow-run notifications for branches that aren't + # yours, matched on the "ci_branch" metadata key (the branch parsed out of + # a CheckSuite title; "" for every other notification). Denying + # "main"/"master" keeps CI failures on your own PR branches while dropping + # upstream-sync and scheduled runs on the default branch. deny only — an + # allow list is fail-closed and would drop every non-CI notification, since + # their ci_branch is empty. + deny_ci_branches: list[str] = field(default_factory=list) @classmethod @_coerced @@ -1224,6 +1242,9 @@ def from_dict(cls, d: dict) -> GitHubSyncConfig: deny_repos=d.get("deny_repos", []), allow_actors=d.get("allow_actors", []), deny_actors=d.get("deny_actors", []), + allow_reasons=d.get("allow_reasons", []), + deny_reasons=d.get("deny_reasons", []), + deny_ci_branches=d.get("deny_ci_branches", []), ) diff --git a/nerve/sources/filters.py b/nerve/sources/filters.py index d1520c13..82ac622e 100644 --- a/nerve/sources/filters.py +++ b/nerve/sources/filters.py @@ -126,6 +126,17 @@ def passes(self, record: SourceRecord) -> bool: """Return True if *record* passes all rules (should be kept).""" return all(r.passes(record) for r in self.rules) + def rejects(self, record: SourceRecord) -> FieldRule | None: + """The first rule that drops *record*, or None if it passes. + + Dropped records are never persisted, so this is the only way to tell + *why* something vanished (see the runner's drop logging). + """ + for rule in self.rules: + if not rule.passes(record): + return rule + return None + def partition( self, records: list[SourceRecord], ) -> tuple[list[SourceRecord], list[SourceRecord]]: diff --git a/nerve/sources/github.py b/nerve/sources/github.py index b87bfe2d..da63ef65 100644 --- a/nerve/sources/github.py +++ b/nerve/sources/github.py @@ -14,6 +14,7 @@ import asyncio import json import logging +import re from datetime import datetime, timedelta, timezone from typing import Any @@ -29,6 +30,27 @@ # Concurrent API calls for enrichment. _MAX_CONCURRENT_FETCHES = 5 +# CheckSuite notifications carry no ``subject.url``, so they can never be +# enriched — the branch in the title is the only clue about whose run it was: +# "CI workflow run failed for main branch" +# "CI workflow run, Attempt #2 failed for chore/drop-anyio-patch branch" +# A run on the default branch is an upstream sync or a schedule, never a PR. +_CI_BRANCH_RE = re.compile(r"\bfor (?P\S+) branch$") + + +def _ci_branch(subject_type: str, title: str) -> str: + """Branch of a CI run, or ``""`` for anything else. + + Surfaced as the ``ci_branch`` metadata key so the inbox guardrail can deny + runs on branches that aren't yours (see :mod:`nerve.sources.filters`). + Empty for non-CheckSuite subjects and for titles we can't parse — a *deny* + rule never matches the empty string, so those records pass untouched. + """ + if subject_type != "CheckSuite": + return "" + match = _CI_BRANCH_RE.search(title or "") + return match.group("branch") if match else "" + def _collect_actors( subject_user: str, @@ -240,6 +262,7 @@ async def fetch(self, cursor: str | None, limit: int = 100) -> FetchResult: "repo_name": repo_name, "repo_url": repo.get("html_url", ""), "actors": actors, + "ci_branch": _ci_branch(subject_type, subject_title), }, )) diff --git a/nerve/sources/registry.py b/nerve/sources/registry.py index 4277d20b..34446119 100644 --- a/nerve/sources/registry.py +++ b/nerve/sources/registry.py @@ -160,12 +160,16 @@ def build_source_runners( source = GitHubSource() # Guardrails: restrict which repos (matched on the "repo_name" metadata # key) and which GitHub actors (matched on the "actors" metadata key — - # every login involved in a notification) reach the inbox. The two rules + # every login involved in a notification) reach the inbox. The rules # AND together; within each, deny wins and a non-empty allow is - # fail-closed. + # fail-closed. ci_branch is deny-only on purpose: it is empty for + # everything except CheckSuite notifications, so an allow list there + # would fail closed and drop the whole feed. gh_filter = InboxFilter(rules=[ FieldRule(field="repo_name", allow=gh.allow_repos, deny=gh.deny_repos), FieldRule(field="actors", allow=gh.allow_actors, deny=gh.deny_actors), + FieldRule(field="reason", allow=gh.allow_reasons, deny=gh.deny_reasons), + FieldRule(field="ci_branch", deny=gh.deny_ci_branches), ]) runners.append(SourceRunner( source=source, @@ -180,10 +184,13 @@ def build_source_runners( if gh_filter.active: logger.info( "Registered source: github (batch=%d, guardrail: " - "repos allow=%s deny=%s; actors allow=%s deny=%s)", + "repos allow=%s deny=%s; actors allow=%s deny=%s; " + "reasons allow=%s deny=%s; ci_branches deny=%s)", gh.batch_size, gh.allow_repos or "*", gh.deny_repos or [], gh.allow_actors or "*", gh.deny_actors or [], + gh.allow_reasons or "*", gh.deny_reasons or [], + gh.deny_ci_branches or [], ) else: logger.info("Registered source: github (batch=%d)", gh.batch_size) diff --git a/nerve/sources/runner.py b/nerve/sources/runner.py index 5fd6ed6e..b122010e 100644 --- a/nerve/sources/runner.py +++ b/nerve/sources/runner.py @@ -87,6 +87,10 @@ def is_backed_off(self) -> bool: # to a fast LLM for extraction/condensation. _CONDENSE_THRESHOLD = 800 # chars +# Per-record guardrail drop lines emitted per run (a noisy source could +# otherwise drop hundreds at once). +_MAX_DROP_LOG_LINES = 10 + _CONDENSE_PROMPT = ( "Extract the essential information from this source record content.\n" "Rules:\n" @@ -269,6 +273,17 @@ async def _run_locked(self) -> IngestResult: self.source.source_name, dropped_count, len(records), dropped[0].summary, ) + # Name the rule that fired — the summary alone can't tell a + # repo drop from an actor or CI-branch drop, and the record + # itself is gone after this point. Capped to keep logs sane. + for record in dropped[:_MAX_DROP_LOG_LINES]: + rule = self.inbox_filter.rejects(record) + field = rule.field if rule else "?" + logger.info( + "Source %s: dropped %s on %s=%r — %s", + self.source.source_name, record.id, field, + (record.metadata or {}).get(field), record.summary, + ) records = kept # 2. Persist to inbox (post-preprocess, pre-condense — human-readable) diff --git a/tests/test_github_ci_branch.py b/tests/test_github_ci_branch.py new file mode 100644 index 00000000..9f29a20d --- /dev/null +++ b/tests/test_github_ci_branch.py @@ -0,0 +1,231 @@ +"""CI-branch guardrail for the GitHub notifications source. + +Covers the ``ci_branch`` metadata key that ``GitHubSource`` parses out of +CheckSuite titles, the ``deny_ci_branches`` config, and the registry wiring that +turns it into an inbox guardrail — so workflow runs on the default branch +(upstream syncs, schedules, deploys) never reach the inbox while CI failures on +your own PR branches still do. + +GitHub itself cannot make this distinction: one workflow file serves both the +``push``-to-``main`` runs and the ``pull_request`` runs, and the Actions +notification preference is account-wide. +""" + +from __future__ import annotations + +import json + +import pytest + +from nerve.config import NerveConfig +from nerve.sources.github import GitHubSource, _ci_branch +from nerve.sources.models import SourceRecord +from nerve.sources.registry import build_source_runners + + +# --------------------------------------------------------------------------- +# _ci_branch — pure title parsing +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("title,expected", [ + # Titles observed in the wild, including a retry and a slashed branch name. + ("CI workflow run failed for main branch", "main"), + ("Deploy workflow run failed for main branch", "main"), + ("Remove old images workflow run failed for main branch", "main"), + ( + "CI workflow run, Attempt #2 failed for chore/drop-anyio-patch branch", + "chore/drop-anyio-patch", + ), + ("CI workflow run failed for fix/chat-new-route branch", "fix/chat-new-route"), +]) +def test_ci_branch_parses_check_suite_titles(title, expected): + assert _ci_branch("CheckSuite", title) == expected + + +def test_ci_branch_empty_for_non_check_suite(): + # A PR whose title happens to end like a CI title must not be misread. + title = "CI workflow run failed for main branch" + assert _ci_branch("PullRequest", title) == "" + assert _ci_branch("Issue", title) == "" + assert _ci_branch("", title) == "" + + +def test_ci_branch_empty_when_title_is_unparseable(): + assert _ci_branch("CheckSuite", "CI workflow run failed") == "" + assert _ci_branch("CheckSuite", "") == "" + assert _ci_branch("CheckSuite", "for branch") == "" + + +# --------------------------------------------------------------------------- +# Config — deny_ci_branches parsing +# --------------------------------------------------------------------------- + +def test_github_sync_config_reads_deny_ci_branches(): + cfg = NerveConfig.from_dict({ + "sync": {"github": {"deny_ci_branches": ["main", "master"]}}, + }) + assert cfg.sync.github.deny_ci_branches == ["main", "master"] + + +def test_github_sync_config_deny_ci_branches_defaults_empty(): + assert NerveConfig.from_dict({}).sync.github.deny_ci_branches == [] + + +# --------------------------------------------------------------------------- +# Source — fetch() surfaces the "ci_branch" key in record metadata +# --------------------------------------------------------------------------- + +class _FakeProc: + """Minimal stand-in for an asyncio subprocess returning canned stdout.""" + + def __init__(self, stdout: bytes): + self._stdout = stdout + self.returncode = 0 + + async def communicate(self): + return self._stdout, b"" + + +@pytest.mark.asyncio +async def test_fetch_populates_ci_branch_metadata(monkeypatch): + notifications = [ + { + "id": "ci-main", + "reason": "ci_activity", + "updated_at": "2026-01-02T10:00:00Z", + # CheckSuite subjects carry no url — enrichment can never run. + "subject": { + "title": "CI workflow run failed for main branch", + "type": "CheckSuite", + "url": None, + }, + "repository": { + "full_name": "owner/repo", + "html_url": "https://github.com/owner/repo", + }, + }, + { + "id": "pr", + "reason": "author", + "updated_at": "2026-01-02T11:00:00Z", + "subject": { + "title": "Fix the thing", + "type": "PullRequest", + "url": "https://api.github.com/repos/owner/repo/pulls/1", + }, + "repository": { + "full_name": "owner/repo", + "html_url": "https://github.com/owner/repo", + }, + }, + ] + + async def fake_exec(*args, **kwargs): + return _FakeProc(json.dumps(notifications).encode()) + + monkeypatch.setattr( + "nerve.sources.github.asyncio.create_subprocess_exec", fake_exec, + ) + + src = GitHubSource() + + async def fake_enrich(notif, sem): + return {} + + monkeypatch.setattr(src, "_enrich_notification", fake_enrich) + + result = await src.fetch(cursor="2026-01-02T09:00:00Z") + + by_id = {r.id: r for r in result.records} + assert by_id["ci-main"].metadata["ci_branch"] == "main" + # Non-CI records still carry the key, empty — a deny glob never matches it. + assert by_id["pr"].metadata["ci_branch"] == "" + + +# --------------------------------------------------------------------------- +# Registry — deny_ci_branches becomes an active inbox guardrail +# --------------------------------------------------------------------------- + +def _ci_rec(rid: str, branch: str) -> SourceRecord: + return SourceRecord( + id=rid, source="github", record_type="github_notification", + summary="[owner/repo] CI workflow run failed (ci_activity)", + content="c", timestamp="2026-01-01T00:00:00Z", + metadata={"repo_name": "owner/repo", "actors": [], "ci_branch": branch}, + ) + + +@pytest.mark.asyncio +async def test_build_source_runners_wires_ci_branch_guardrail(db): + cfg = NerveConfig.from_dict({ + "sync": {"github": { + "enabled": True, + "deny_ci_branches": ["main", "master"], + }}, + }) + runners = build_source_runners(cfg, db) + gh = next(r for r in runners if r.source.source_name == "github") + + assert gh.inbox_filter is not None + assert gh.inbox_filter.active is True + assert gh.inbox_filter.passes(_ci_rec("main", "main")) is False + assert gh.inbox_filter.passes(_ci_rec("master", "master")) is False + assert gh.inbox_filter.passes(_ci_rec("pr", "fix/chat-new-route")) is True + + +@pytest.mark.asyncio +async def test_ci_branch_guardrail_leaves_non_ci_records_alone(db): + # The whole design rests on this: ci_branch is "" everywhere except + # CheckSuite, and a deny list must never match the empty string. + cfg = NerveConfig.from_dict({ + "sync": {"github": { + "enabled": True, + "deny_ci_branches": ["main", "master"], + }}, + }) + runners = build_source_runners(cfg, db) + gh = next(r for r in runners if r.source.source_name == "github") + + assert gh.inbox_filter.passes(_ci_rec("empty", "")) is True + # A record predating the change has no ci_branch key at all. + legacy = SourceRecord( + id="legacy", source="github", record_type="github_notification", + summary="[owner/repo] Review requested (review_requested)", + content="c", timestamp="2026-01-01T00:00:00Z", + metadata={"repo_name": "owner/repo", "actors": ["alice"]}, + ) + assert gh.inbox_filter.passes(legacy) is True + + +@pytest.mark.asyncio +async def test_deny_reasons_and_ci_branch_and_together(db): + cfg = NerveConfig.from_dict({ + "sync": {"github": { + "enabled": True, + "deny_reasons": ["comment", "subscribed", "manual", "state_change"], + "deny_ci_branches": ["main", "master"], + }}, + }) + runners = build_source_runners(cfg, db) + gh = next(r for r in runners if r.source.source_name == "github") + + def rec(rid: str, reason: str, branch: str = "") -> SourceRecord: + return SourceRecord( + id=rid, source="github", record_type="github_notification", + summary="[owner/repo] x", content="c", + timestamp="2026-01-01T00:00:00Z", + metadata={ + "repo_name": "owner/repo", "actors": [], + "reason": reason, "ci_branch": branch, + }, + ) + + # Mine, or addressed at me → kept. + assert gh.inbox_filter.passes(rec("a", "author")) is True + assert gh.inbox_filter.passes(rec("b", "mention")) is True + assert gh.inbox_filter.passes(rec("c", "review_requested")) is True + assert gh.inbox_filter.passes(rec("d", "ci_activity", "fix/thing")) is True + # Someone else's thread, or a run that isn't my PR → dropped. + assert gh.inbox_filter.passes(rec("e", "comment")) is False + assert gh.inbox_filter.passes(rec("f", "manual")) is False + assert gh.inbox_filter.passes(rec("g", "ci_activity", "main")) is False diff --git a/tests/test_source_filters.py b/tests/test_source_filters.py index af4d0066..29d9d04e 100644 --- a/tests/test_source_filters.py +++ b/tests/test_source_filters.py @@ -245,3 +245,29 @@ def test_actor_allowlist_is_case_insensitive(): assert rule.passes(_rec(actors=["alice"])) is True assert rule.passes(_rec(actors=["ALICE"])) is True assert rule.passes(_rec(actors=["bob"])) is False + + +def test_deny_only_rule_ignores_absent_and_empty_fields(): + # The GitHub CI guardrail relies on this: ci_branch is "" (or absent) for + # every non-CheckSuite notification, and a deny list must not touch those. + rule = FieldRule(field="ci_branch", deny=["main", "master"]) + assert rule.passes(_rec(ci_branch="main")) is False + assert rule.passes(_rec(ci_branch="fix/thing")) is True + assert rule.passes(_rec(ci_branch="")) is True + assert rule.passes(_rec()) is True # key absent entirely + + +def test_rejects_names_the_first_failing_rule(): + flt = InboxFilter(rules=[ + FieldRule(field="repo_name", allow=["ClickHouse/*"]), + FieldRule(field="ci_branch", deny=["main"]), + ]) + kept = _rec("ClickHouse/nerve", ci_branch="fix/thing") + assert flt.rejects(kept) is None + + by_branch = flt.rejects(_rec("ClickHouse/nerve", ci_branch="main")) + assert by_branch is not None and by_branch.field == "ci_branch" + + # Rules are evaluated in order, so the repo rule is reported first. + by_repo = flt.rejects(_rec("other/repo", ci_branch="main")) + assert by_repo is not None and by_repo.field == "repo_name"