//! The notification router's flows: the stored document, the flow type both //! the built-in and the custom flows are, and the checks a flow passes. //! //! A flow is a graph of nodes joined by edges, rooted at one `source` node. //! A `match` node passes when every one of its conditions holds, and then //! each of its children runs. A `todo` node is a leaf and emits one todo op //! per recipient. Flows are trees: one source, one parent for every other //! node, every node reachable from the source ([`Flow::validate`]). //! //! The document under [`KEY`] in [`BUCKET`] holds the ids of the built-in //! flows that are switched off and the custom flows. The built-ins //! ([`builtins`]) are defined here, in code, so a build that adds one reaches //! every swarm without a migration. swarm-controller is the only writer; the //! router reads it. use std::collections::BTreeSet; use serde::{Deserialize, Serialize}; use crate::router_todo::Op; /// The bucket holding the document. Only the controller writes it. pub const BUCKET: &str = "notify-flows"; /// The one key in [`BUCKET`]. pub const KEY: &str = "all"; /// The document layout this build reads and writes. pub const VERSION: u32 = 1; /// The most nodes one flow may have. pub const MAX_NODES: usize = 64; /// The deepest a node may sit below the source. pub const MAX_DEPTH: usize = 16; /// The most custom flows the document may hold. pub const MAX_FLOWS: usize = 100; /// The longest a flow's name, or a todo's key or text, may be. pub const MAX_TEXT: usize = 500; /// The forge event types a source node can start from: the ones /// swarm-controller's hooks deliver to the events stream. pub const SOURCE_EVENTS: &[&str] = &["issues", "issue_assign", "pull_request", "pull_request_assign"]; /// The fields of an event a condition or a recipient can name. pub const FIELDS: &[&str] = &[ "event", "action", "kind", "repo", "number", "title", "url", "sender", "assignees", "assignees_added", "assignees_removed", "todo_holders", ]; /// The value under [`KEY`]. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] pub struct Document { /// [`VERSION`] when written. A reader that does not know the number /// leaves the document alone. #[serde(default = "version")] pub version: u32, /// The ids of the built-in flows that are switched off. #[serde(default)] pub disabled_builtins: Vec, /// The flows the operator defined. #[serde(default)] pub flows: Vec, } fn version() -> u32 { VERSION } impl Default for Document { /// No built-in switched off and no custom flow, at [`VERSION`]. fn default() -> Self { Self { version: VERSION, disabled_builtins: Vec::new(), flows: Vec::new(), } } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] pub struct Flow { /// Unique among the built-in and custom flows. Names the flow in the /// todo's source, `router:`. pub id: String, pub name: String, /// A flow switched off is kept and runs on no event. Built-ins ignore it /// and are switched off through [`Document::disabled_builtins`]. #[serde(default = "on")] pub enabled: bool, pub nodes: Vec, pub edges: Vec, } fn on() -> bool { true } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] pub struct Node { /// Unique within its flow. pub id: String, #[serde(flatten)] pub kind: NodeKind, } /// A directed edge, parent to child. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] pub struct Edge { pub from: String, pub to: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] #[serde(tag = "kind", rename_all = "snake_case")] pub enum NodeKind { /// The root: the forge event types the flow starts from, from /// [`SOURCE_EVENTS`]. Source { events: Vec }, /// Passes when every condition holds. Match { when: Vec }, /// Emits a todo op to each recipient. Todo(TodoSink), } /// `field` equals `value`. A list field holds when one of its items does. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] pub struct Condition { /// One of [`FIELDS`]. pub field: String, pub equals: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] pub struct TodoSink { pub to: Recipients, #[cfg_attr(feature = "schema", schema(value_type = String))] pub op: Op, /// The todo key, with `{field}` placeholders. pub key: String, /// For a clear: `key` is a prefix. #[serde(default)] pub prefix: bool, /// The todo text, with `{field}` placeholders. Empty for a clear. #[serde(default)] pub text: String, } /// Who a todo op goes to. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] #[serde(rename_all = "snake_case")] pub enum Recipients { /// The agents the named event field holds, one of [`FIELDS`]. Field(String), /// This agent, whatever the event. Agent(String), } /// Why [`Document::decode`] could not read a document. #[derive(Clone, Debug, PartialEq, Eq)] pub enum DecodeError { /// A version other than [`VERSION`], such as a newer build's. UnknownVersion(String), /// Not JSON, or not the shape of a [`Document`]. Malformed(String), } impl std::fmt::Display for DecodeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::UnknownVersion(v) => { write!(f, "version {v}, and this build reads version {VERSION}") } Self::Malformed(why) => f.write_str(why), } } } impl Document { /// `bytes`, the stored document, or why it cannot be read. /// /// # Errors /// /// Not the expected shape, or a [`Document::version`] this build does not /// know. The reader keeps what it had. pub fn decode(bytes: &[u8]) -> Result { let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| DecodeError::Malformed(format!("not JSON: {e}")))?; if let Some(found) = value.get("version") && found != VERSION { return Err(DecodeError::UnknownVersion(found.to_string())); } serde_json::from_value(value) .map_err(|e| DecodeError::Malformed(format!("not a flows document: {e}"))) } /// What is wrong with the document, if anything: the check a save passes. /// /// # Errors /// /// The first problem found, naming the flow it is in. pub fn check(&self) -> Result<(), String> { if self.version != VERSION { return Err(format!("version {} is not {VERSION}", self.version)); } if self.flows.len() > MAX_FLOWS { return Err(format!( "{} flows, more than {MAX_FLOWS}", self.flows.len() )); } let builtin = builtins(); for id in &self.disabled_builtins { if !builtin.iter().any(|b| b.id == *id) { return Err(format!("disabled_builtins names {id}, which is no built-in flow")); } } let mut ids: BTreeSet<&str> = builtin.iter().map(|b| b.id.as_str()).collect(); for flow in &self.flows { if !ids.insert(flow.id.as_str()) { return Err(format!( "flow id {} is used twice, or is a built-in flow's", flow.id )); } flow.validate().map_err(|why| format!("flow {}: {why}", flow.id))?; } Ok(()) } /// The flows to run: the built-ins that are not switched off, then the /// enabled custom flows that pass [`Flow::validate`] with an id of their /// own. With the reason for each custom flow left out. #[must_use] pub fn active(&self) -> (Vec, Vec) { let mut flows: Vec = builtins() .into_iter() .filter(|b| !self.disabled_builtins.contains(&b.id)) .collect(); let mut skipped = Vec::new(); for flow in self.flows.iter().filter(|f| f.enabled) { if flows.iter().any(|f| f.id == flow.id) { skipped.push(format!("flow {}: its id is already in use", flow.id)); } else if let Err(why) = flow.validate() { skipped.push(format!("flow {}: {why}", flow.id)); } else { flows.push(flow.clone()); } } (flows, skipped) } } /// Whether `id` can name a flow or a node: it ends up in a message id and in /// a todo's source. fn is_id(id: &str) -> bool { !id.is_empty() && id.len() <= 64 && id .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') } fn is_field(name: &str) -> bool { FIELDS.contains(&name) } impl Flow { /// Check the flow is a tree within the caps, and that every node reads /// only what exists. /// /// # Errors /// /// Says why not. pub fn validate(&self) -> Result<(), String> { if !is_id(&self.id) { return Err(format!( "id {:?} is not 1 to 64 letters, digits, - or _", self.id )); } if self.name.trim().is_empty() || self.name.len() > MAX_TEXT { return Err(format!("name is empty or longer than {MAX_TEXT} bytes")); } if self.nodes.len() > MAX_NODES { return Err(format!("{} nodes, more than {MAX_NODES}", self.nodes.len())); } let mut ids = BTreeSet::new(); for node in &self.nodes { if !is_id(&node.id) { return Err(format!( "node id {:?} is not 1 to 64 letters, digits, - or _", node.id )); } if !ids.insert(node.id.as_str()) { return Err(format!("node id {} is used twice", node.id)); } node.kind .validate() .map_err(|why| format!("node {}: {why}", node.id))?; } for edge in &self.edges { for end in [&edge.from, &edge.to] { if !ids.contains(end.as_str()) { return Err(format!("an edge names {end}, which is no node")); } } } let roots: Vec<&Node> = self .nodes .iter() .filter(|n| matches!(n.kind, NodeKind::Source { .. })) .collect(); let [root] = roots.as_slice() else { return Err(format!("{} source nodes, not one", roots.len())); }; for node in &self.nodes { if matches!(node.kind, NodeKind::Todo(_)) && !self.children(&node.id).is_empty() { return Err(format!("todo {} has children", node.id)); } } // From the one root, every node reached exactly once: a node reached // twice has a second parent or sits on a cycle, and one not reached is // cut off. let mut reached = 0; let mut stack = vec![(root.id.as_str(), 0)]; while let Some((id, depth)) = stack.pop() { if depth > MAX_DEPTH { return Err(format!("node {id} is deeper than {MAX_DEPTH}")); } reached += 1; if reached > self.nodes.len() { return Err( "a node is reached twice: it has two parents or sits on a cycle".to_owned(), ); } stack.extend(self.children(id).into_iter().map(|c| (c, depth + 1))); } if reached != self.nodes.len() { return Err(format!( "{} of {} nodes are not reachable from the source", self.nodes.len() - reached, self.nodes.len() )); } Ok(()) } /// The ids of the children of node `id`. #[must_use] pub fn children(&self, id: &str) -> Vec<&str> { self.edges .iter() .filter(|e| e.from == id) .map(|e| e.to.as_str()) .collect() } /// The node with `id`. #[must_use] pub fn node(&self, id: &str) -> Option<&Node> { self.nodes.iter().find(|n| n.id == id) } } impl NodeKind { fn validate(&self) -> Result<(), String> { match self { Self::Source { events } => { if events.is_empty() { return Err("a source with no events".to_owned()); } if let Some(bad) = events.iter().find(|e| !SOURCE_EVENTS.contains(&e.as_str())) { return Err(format!( "event {bad:?} is not one of {}", SOURCE_EVENTS.join(", ") )); } } Self::Match { when } => { if let Some(bad) = when.iter().find(|c| !is_field(&c.field)) { return Err(format!("condition on {:?}, which is no event field", bad.field)); } } Self::Todo(sink) => sink.validate()?, } Ok(()) } } impl TodoSink { fn validate(&self) -> Result<(), String> { match &self.to { Recipients::Field(f) if !is_field(f) => { return Err(format!("recipients from {f:?}, which is no event field")); } Recipients::Agent(a) if !is_id(a) => { return Err(format!("recipient {a:?} is not an agent name")); } _ => {} } if self.key.is_empty() || self.key.len() > MAX_TEXT { return Err(format!("key is empty or longer than {MAX_TEXT} bytes")); } if self.text.len() > MAX_TEXT { return Err(format!("text is longer than {MAX_TEXT} bytes")); } match self.op { Op::Upsert if self.text.trim().is_empty() => Err("an upsert with no text".to_owned()), Op::Upsert if self.prefix => Err("prefix is for a clear".to_owned()), Op::Clear if !self.text.is_empty() => Err("a clear takes no text".to_owned()), _ => Ok(()), } } } /// The flows every swarm ships with. /// /// - `assigned`: a newly assigned agent gets a todo for the issue or PR. /// - `unassigned`: an agent taken off it gets that todo rewritten to say so. /// Both write the same key, so the second replaces the first. /// - `closed`: closing it clears every router todo the router queued for it. #[must_use] pub fn builtins() -> Vec { let assignment_key = "forge:{repo}#{number}:assignment"; vec![ chain( "assigned", &["issue_assign", "pull_request_assign"], "assigned", TodoSink { to: Recipients::Field("assignees_added".to_owned()), op: Op::Upsert, key: assignment_key.to_owned(), prefix: false, text: "you are assigned to {kind} {repo}#{number}: {title} ({url})".to_owned(), }, ), chain( "unassigned", &["issue_assign", "pull_request_assign"], "unassigned", TodoSink { to: Recipients::Field("assignees_removed".to_owned()), op: Op::Upsert, key: assignment_key.to_owned(), prefix: false, text: "you are not assigned to {kind} {repo}#{number} anymore: {title} ({url})" .to_owned(), }, ), chain( "closed", &["issues", "pull_request"], "closed", TodoSink { to: Recipients::Field("todo_holders".to_owned()), op: Op::Clear, key: "forge:{repo}#{number}:".to_owned(), prefix: true, text: String::new(), }, ), ] } /// source(`events`) → match(`action` = `action`) → `sink`. fn chain(id: &str, events: &[&str], action: &str, sink: TodoSink) -> Flow { let node = |id: &str, kind| Node { id: id.to_owned(), kind, }; let edge = |from: &str, to: &str| Edge { from: from.to_owned(), to: to.to_owned(), }; Flow { id: id.to_owned(), name: id.to_owned(), enabled: true, nodes: vec![ node( "source", NodeKind::Source { events: events.iter().map(|e| (*e).to_owned()).collect(), }, ), node( "match", NodeKind::Match { when: vec![Condition { field: "action".to_owned(), equals: action.to_owned(), }], }, ), node("sink", NodeKind::Todo(sink)), ], edges: vec![edge("source", "match"), edge("match", "sink")], } } #[cfg(test)] mod tests { use super::*; fn edge(from: &str, to: &str) -> Edge { Edge { from: from.to_owned(), to: to.to_owned(), } } fn builtin(id: &str) -> Flow { builtins().into_iter().find(|f| f.id == id).expect("a built-in") } /// A custom flow: opened issue or PR → a todo for `triage`. fn custom(id: &str) -> Flow { let mut f = builtin("assigned"); f.id = id.to_owned(); f.name = "new to triage".to_owned(); f.nodes[2].kind = NodeKind::Todo(TodoSink { to: Recipients::Agent("triage".to_owned()), op: Op::Upsert, key: "forge:{repo}#{number}:triage".to_owned(), prefix: false, text: "triage {repo}#{number}".to_owned(), }); f } #[test] fn every_builtin_is_a_valid_tree() { for f in builtins() { assert_eq!(f.validate(), Ok(()), "{}", f.id); } } #[test] fn a_flow_that_is_not_a_tree_is_refused() { let mut cycle = builtin("assigned"); cycle.edges[0] = edge("sink", "match"); cycle.edges.push(edge("match", "sink")); assert!(cycle.validate().is_err(), "a cycle"); let mut orphan = builtin("assigned"); orphan.edges.pop(); assert!(orphan.validate().is_err(), "an unreachable sink"); let mut two_parents = builtin("assigned"); two_parents.edges.push(edge("source", "sink")); assert!(two_parents.validate().is_err(), "a sink with two parents"); let mut sink_child = builtin("assigned"); sink_child.nodes.push(Node { id: "m2".to_owned(), kind: NodeKind::Match { when: Vec::new() }, }); sink_child.edges.push(edge("sink", "m2")); assert!(sink_child.validate().is_err(), "a sink with a child"); let mut dangling = builtin("assigned"); dangling.edges.push(edge("match", "nowhere")); assert!(dangling.validate().is_err(), "an edge to no node"); let mut two_sources = builtin("assigned"); two_sources.nodes[1].kind = NodeKind::Source { events: vec!["issues".to_owned()], }; assert!(two_sources.validate().is_err(), "two sources"); let mut twice = builtin("assigned"); twice.nodes[1].id = "sink".to_owned(); assert!(twice.validate().is_err(), "a node id used twice"); } #[test] fn the_caps_hold_at_the_limit_and_refuse_one_past_it() { // A chain source → m0 → m1 → … of `n` matches under the source. let chain_of = |n: usize| { let mut f = builtin("assigned"); f.nodes.truncate(1); f.edges.clear(); let mut parent = "source".to_owned(); for i in 0..n { let id = format!("m{i}"); f.nodes.push(Node { id: id.clone(), kind: NodeKind::Match { when: Vec::new() }, }); f.edges.push(edge(&parent, &id)); parent = id; } f }; assert_eq!(chain_of(MAX_DEPTH).validate(), Ok(())); assert!(chain_of(MAX_DEPTH + 1).validate().is_err(), "depth"); let mut wide = chain_of(0); for i in 0..MAX_NODES - 1 { wide.nodes.push(Node { id: format!("m{i}"), kind: NodeKind::Match { when: Vec::new() }, }); wide.edges.push(edge("source", &format!("m{i}"))); } assert_eq!(wide.validate(), Ok(())); wide.nodes.push(Node { id: "extra".to_owned(), kind: NodeKind::Match { when: Vec::new() }, }); wide.edges.push(edge("source", "extra")); assert!(wide.validate().is_err(), "node count"); } #[test] fn a_node_that_reads_what_does_not_exist_is_refused() { let mut event = builtin("assigned"); event.nodes[0].kind = NodeKind::Source { events: vec!["pull_request_sync".to_owned()], }; assert!(event.validate().unwrap_err().contains("pull_request_sync")); let mut field = builtin("assigned"); field.nodes[1].kind = NodeKind::Match { when: vec![Condition { field: "actoin".to_owned(), equals: "closed".to_owned(), }], }; assert!(field.validate().unwrap_err().contains("actoin")); let mut to = custom("c"); to.nodes[2].kind = NodeKind::Todo(TodoSink { to: Recipients::Field("nobody".to_owned()), op: Op::Upsert, key: "k".to_owned(), prefix: false, text: "t".to_owned(), }); assert!(to.validate().unwrap_err().contains("nobody")); // Control: a literal agent and the sources above spelled right pass. assert_eq!(custom("c").validate(), Ok(())); } #[test] fn a_sink_must_say_what_its_op_needs() { let sink = |op, prefix, text: &str| { let mut f = custom("c"); f.nodes[2].kind = NodeKind::Todo(TodoSink { to: Recipients::Agent("triage".to_owned()), op, key: "k".to_owned(), prefix, text: text.to_owned(), }); f.validate() }; assert_eq!(sink(Op::Upsert, false, "t"), Ok(())); assert_eq!(sink(Op::Clear, true, ""), Ok(())); assert!(sink(Op::Upsert, false, " ").is_err(), "no text"); assert!(sink(Op::Upsert, true, "t").is_err(), "prefix on an upsert"); assert!(sink(Op::Clear, false, "t").is_err(), "text on a clear"); } #[test] fn a_document_names_only_real_builtins_and_unique_flow_ids() { let doc = |disabled: &[&str], flows: Vec| Document { version: VERSION, disabled_builtins: disabled.iter().map(|s| (*s).to_owned()).collect(), flows, }; assert_eq!(doc(&["assigned"], vec![custom("triage")]).check(), Ok(())); assert!(doc(&["nope"], vec![]).check().unwrap_err().contains("nope")); assert!( doc(&[], vec![custom("assigned")]).check().is_err(), "a built-in's id" ); assert!( doc(&[], vec![custom("a"), custom("a")]).check().is_err(), "a repeated id" ); let mut bad = custom("bad"); bad.edges.pop(); assert!(doc(&[], vec![bad]).check().unwrap_err().starts_with("flow bad:")); let many = (0..=MAX_FLOWS).map(|i| custom(&format!("f{i}"))).collect(); assert!(doc(&[], many).check().is_err(), "flow count"); let mut old = doc(&[], vec![]); old.version = 2; assert!(old.check().is_err(), "version"); } #[test] fn active_is_the_builtins_minus_the_disabled_plus_the_enabled_valid_custom() { let ids = |d: &Document| d.active().0.into_iter().map(|f| f.id).collect::>(); assert_eq!( ids(&Document::default()), ["assigned", "unassigned", "closed"], "an empty document is the built-ins" ); let mut off = custom("off"); off.enabled = false; let mut broken = custom("broken"); broken.edges.clear(); let doc = Document { version: VERSION, disabled_builtins: vec!["unassigned".to_owned(), "gone".to_owned()], flows: vec![custom("triage"), off, broken, custom("assigned")], }; assert_eq!(ids(&doc), ["assigned", "closed", "triage"]); let (_, skipped) = doc.active(); assert_eq!(skipped.len(), 2, "{skipped:?}"); assert!(skipped.iter().any(|s| s.starts_with("flow broken:"))); assert!(skipped.iter().any(|s| s.contains("assigned"))); } #[test] fn a_document_reads_back_and_a_foreign_one_is_refused() { let doc = Document { version: VERSION, disabled_builtins: vec!["closed".to_owned()], flows: vec![custom("triage")], }; let bytes = serde_json::to_vec(&doc).unwrap(); assert_eq!(Document::decode(&bytes), Ok(doc)); assert_eq!(Document::decode(b"{}"), Ok(Document::default())); assert_eq!( Document::decode(br#"{"version":2}"#), Err(DecodeError::UnknownVersion("2".to_owned())) ); assert!(Document::decode(b"nope").is_err()); assert!(Document::decode(br#"{"flows":[{"id":"x"}]}"#).is_err()); } /// The wire shape the API and the stored document share. #[test] fn a_node_is_flat_with_its_kind_beside_its_id() { let json = serde_json::to_value(&builtin("closed").nodes[2]).unwrap(); assert_eq!( json, serde_json::json!({ "id": "sink", "kind": "todo", "to": { "field": "todo_holders" }, "op": "clear", "key": "forge:{repo}#{number}:", "prefix": true, "text": "", }) ); } }