Radicle CI event filter implementation

Implementing a new event filter

Overview:

Example

The patch to add a AnyDelegate filter looks like below.

commit 94fbd9f72fc4db779422fa97786c99bb9b3b14d4
Author: Lars Wirzenius <liw@liw.fi>
Date:   Mon Nov 10 14:00:53 2025 +0200

    feat: add the AnyDelegate filter

diff --git a/ci-broker.md b/ci-broker.md
index f86748e..ac217d0 100644
--- a/ci-broker.md
+++ b/ci-broker.md
@@ -1234,6 +1234,43 @@ sed -i "s/NODEID/$rid/g" "$yaml"
 ~~~
 
 
+## Filter predicate `AnyDelegate`
+
+_Want:_ We can allow an event that originates in a node for any delegate.
+
+_Why:_ We want to constrain CI to privileged developers for a repository.
+
+~~~scenario
+given a Radicle node, with CI configured with broker.yaml and adapter dummy.sh
+given a Git repository xyzzy in the Radicle node
+given a Git repository other in the Radicle node
+
+given file config.yaml from filter-anydelegate.yaml
+given file update-nodeid.sh
+when I run bash update-nodeid.sh xyzzy config.yaml
+
+when I run cibtool --db ci-broker.db trigger --repo xyzzy --commit HEAD
+when I run cibtool --db ci-broker.db trigger --repo other --commit HEAD --node z6MkgEMYod7Hxfy9qCvDv5hYHkZ4ciWmLFgfvm3Wn1b2w2FV
+
+when I run ./env.sh cib --config config.yaml queued
+
+when I run cibtool --db ci-broker.db run list --json
+then stdout contains ""repo_name": "xyzzy""
+then stdout doesn't contain ""repo_name": "other""
+~~~
+
+~~~{#filter-anydelegate.yaml .file .json}
+db: ci-broker.db
+adapters:
+  default:
+    command: ./adapter.sh
+triggers:
+  - adapter: default
+    filters:
+      - !AnyDelegate
+~~~
+
+
 ## Filter predicate `Tag`
 
 _Want:_ We can allow an event that is about a specific tag.
diff --git a/src/filter.rs b/src/filter.rs
index 188c8bd..86ea648 100644
--- a/src/filter.rs
+++ b/src/filter.rs
@@ -1,5 +1,6 @@
 use std::path::{Path, PathBuf};
 
+use radicle_crypto::PublicKey;
 use regex::Regex;
 use serde::{Deserialize, Serialize};
 
@@ -8,7 +9,7 @@ use radicle::{
     git::{BranchName, Oid, raw::ObjectType},
     node::NodeId,
     prelude::{Profile, RepoId},
-    storage::git::Repository,
+    storage::{ReadRepository, git::Repository},
 };
 
 use crate::{
@@ -92,6 +93,11 @@ pub enum EventFilter {
     /// Change originated from specific node.
     Node(NodeId),
 
+    /// Change originated from any delegate node. Note that will change to
+    /// "from delegate" once Radicle separates the "user" and "node"
+    /// concepts.
+    AnyDelegate,
+
     /// Commit in change contains a file or directory with this name.
     HasFile(PathBuf),
 
@@ -147,6 +153,25 @@ impl EventFilter {
                     format!("wanted={wanted} actual={actual:?}"),
                 )
             }
+            #[allow(clippy::unwrap_used)]
+            Self::AnyDelegate => {
+                let repo_id = event.repository().unwrap();
+                let radicle = crate::ergo::Radicle::new().unwrap();
+                let repo = radicle.repository(repo_id).unwrap();
+                let origin = event.from_node().unwrap();
+                let delegates: Vec<PublicKey> = repo
+                    .delegates()
+                    .iter()
+                    .flatten()
+                    .map(|d| *d.as_key())
+                    .collect();
+                let allowed = delegates.contains(origin);
+                Decision::string(
+                    "AnyDelegate",
+                    allowed,
+                    format!("wanted={origin} delegates={delegates:?}",),
+                )
+            }
             Self::Repository(wanted) => {
                 let actual = event.repository();
                 let allowed = Some(wanted) == actual;