//! Running a flow over one event. //! //! The flow types, their checks and the built-in flows are //! `swarm_wire::notify_flows`; the built-in and the custom flows are the same //! type and this walk runs both. A `match` node passes when every one of its //! conditions holds, and then each of its children runs, so one event can //! reach several branches, in edge order, so a clear listed before an upsert //! is queued before it. A `todo` node emits one op per recipient and a //! `request_reviewer` node one request for a review. The router runs only //! flows that pass [`Flow::validate`]. use std::collections::BTreeMap; use swarm_wire::notify_flows::{Condition, Flow, Node, NodeKind, Recipients, TodoSink}; use swarm_wire::router_todo::{Op, RouterTodoOp}; /// One event, as the fields a flow reads. pub type Fields = BTreeMap<&'static str, Value>; /// A field's value. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Value { Text(String), List(Vec), } /// One op the walk emitted, for one recipient. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Action { pub flow: String, pub node: String, pub agent: String, pub op: RouterTodoOp, } /// One request for `reviewer`'s review of pull request `number` of `repo`. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ReviewRequest { pub flow: String, pub node: String, pub repo: String, pub number: u64, pub reviewer: String, } /// Walk `flow` for one event of type `event_type`, calling `visit` on each node /// the event reaches: the `source`, each `match` it passes, and the `todo` and /// `request_reviewer` nodes below. Assumes [`Flow::validate`] passed. fn walk<'f>(flow: &'f Flow, event_type: &str, fields: &Fields, mut visit: impl FnMut(&'f Node)) { let Some(root) = flow .nodes .iter() .find(|n| matches!(&n.kind, NodeKind::Source { events } if events.iter().any(|e| e == event_type))) else { return; }; let mut stack = vec![root.id.as_str()]; while let Some(id) = stack.pop() { let Some(node) = flow.node(id) else { continue }; match &node.kind { NodeKind::Source { .. } => { visit(node); stack.extend(flow.children(id).into_iter().rev()); } NodeKind::Match { when } => { if when.iter().all(|c| holds(c, fields)) { visit(node); stack.extend(flow.children(id).into_iter().rev()); } } NodeKind::Todo(_) | NodeKind::RequestReviewer { .. } => visit(node), } } } /// The ids of the nodes of `flow` that one event of type `event_type` reaches, /// a `match` node counting only when it passes. Emits nothing: this is the /// dry run's view of the walk that [`evaluate`] and [`requests`] make. pub fn reached<'f>(flow: &'f Flow, event_type: &str, fields: &Fields) -> Vec<&'f str> { let mut ids = Vec::new(); walk(flow, event_type, fields, |node| ids.push(node.id.as_str())); ids } /// Walk `flow` for one event of type `event_type` and return the todo ops it /// emits. Assumes [`Flow::validate`] passed. pub fn evaluate(flow: &Flow, event_type: &str, fields: &Fields) -> Vec { let mut actions = Vec::new(); walk(flow, event_type, fields, |node| { if let NodeKind::Todo(sink) = &node.kind { actions.extend(emit(flow, node, sink, fields)); } }); actions } /// Walk `flow` for one event of type `event_type` and return the review /// requests it makes. An event that is not about a pull request makes none. /// Assumes [`Flow::validate`] passed. pub fn requests(flow: &Flow, event_type: &str, fields: &Fields) -> Vec { let mut requests = Vec::new(); walk(flow, event_type, fields, |node| { if let NodeKind::RequestReviewer { reviewer } = &node.kind && let Some(request) = request(flow, node, reviewer, fields) { requests.push(request); } }); requests } fn request(flow: &Flow, node: &Node, reviewer: &str, fields: &Fields) -> Option { let text = |name: &str| match fields.get(name) { Some(Value::Text(v)) => Some(v.as_str()), _ => None, }; if text("kind") != Some("PR") { return None; } Some(ReviewRequest { flow: flow.id.clone(), node: node.id.clone(), repo: text("repo")?.to_owned(), number: text("number")?.parse().ok()?, reviewer: reviewer.to_owned(), }) } fn emit(flow: &Flow, node: &Node, sink: &TodoSink, fields: &Fields) -> Vec { let recipients = match &sink.to { Recipients::Agent(agent) => vec![agent.clone()], Recipients::Field(name) => match fields.get(name.as_str()) { Some(Value::List(items)) => items.clone(), Some(Value::Text(one)) => vec![one.clone()], None => Vec::new(), }, }; let rendered = expand(&sink.key, fields).and_then(|key| { let summary = match sink.op { Op::Upsert => expand(&sink.text, fields)?, Op::Clear => String::new(), }; Ok((key, summary)) }); let (key, summary) = match rendered { Ok(done) => done, Err(why) => { tracing::warn!(flow = %flow.id, node = %node.id, %why, "notify router: a todo node names a placeholder the event lacks; nothing sent"); return Vec::new(); } }; let op = RouterTodoOp { op: sink.op, key, prefix: sink.op == Op::Clear && sink.prefix, summary, source: format!("router:{}", flow.id), }; recipients .into_iter() .map(|agent| Action { flow: flow.id.clone(), node: node.id.clone(), agent, op: op.clone(), }) .collect() } fn holds(condition: &Condition, fields: &Fields) -> bool { match fields.get(condition.field.as_str()) { Some(Value::Text(v)) => *v == condition.equals, Some(Value::List(items)) => items.contains(&condition.equals), None => false, } } /// `template` with each `{field}` replaced by that field's value, a list /// joined with `, `. A name that is no field is an error naming it. pub fn expand(template: &str, fields: &Fields) -> Result { let mut out = String::with_capacity(template.len()); let mut rest = template; while let Some(open) = rest.find('{') { out.push_str(&rest[..open]); let after = &rest[open + 1..]; let Some(close) = after.find('}') else { out.push_str(&rest[open..]); return Ok(out); }; let name = &after[..close]; match fields.get(name) { Some(Value::Text(v)) => out.push_str(v), Some(Value::List(items)) => out.push_str(&items.join(", ")), None => return Err(format!("{{{name}}} is no event field")), } rest = &after[close + 1..]; } out.push_str(rest); Ok(out) } #[cfg(test)] mod tests { use swarm_wire::notify_flows::{Edge, builtins}; use super::*; fn text(v: &str) -> Value { Value::Text(v.to_owned()) } fn list(v: &[&str]) -> Value { Value::List(v.iter().map(|s| (*s).to_owned()).collect()) } fn event(action: &str, added: &[&str], removed: &[&str], holders: &[&str]) -> Fields { Fields::from([ ("action", text(action)), ("kind", text("issue")), ("repo", text("hyperhive/hyperhive")), ("number", text("7")), ("title", text("router")), ("url", text("https://forge/7")), ("assignees_added", list(added)), ("assignees_removed", list(removed)), ("todo_holders", list(holders)), ]) } fn flow(id: &str) -> Flow { builtins() .into_iter() .find(|f| f.id == id) .expect("a built-in") } #[test] fn assigning_queues_a_todo_for_each_newly_assigned_agent_only() { let assigned = flow("assigned"); let got = evaluate( &assigned, "issue_assign", &event("assigned", &["atlas", "iris"], &[], &[]), ); let agents: Vec<&str> = got.iter().map(|a| a.agent.as_str()).collect(); assert_eq!(agents, ["atlas", "iris"]); assert_eq!(got[0].op.op, Op::Upsert); assert_eq!(got[0].op.key, "forge:hyperhive/hyperhive#7:assignment"); assert_eq!( got[0].op.summary, "you are assigned to issue hyperhive/hyperhive#7: router (https://forge/7)" ); assert_eq!(got[0].op.source, "router:assigned"); // The match gates: an unassign event reaches no assigned sink. assert!( evaluate( &assigned, "issue_assign", &event("unassigned", &["atlas"], &[], &[]) ) .is_empty() ); // The source gates: another event type starts nothing. assert!( evaluate( &assigned, "issues", &event("assigned", &["atlas"], &[], &[]) ) .is_empty() ); } #[test] fn unassigning_rewrites_the_same_key_for_the_removed_agent() { let got = evaluate( &flow("unassigned"), "pull_request_assign", &event("unassigned", &[], &["argus"], &[]), ); assert_eq!(got.len(), 1); assert_eq!(got[0].agent, "argus"); assert_eq!(got[0].op.key, "forge:hyperhive/hyperhive#7:assignment"); assert!( got[0] .op .summary .starts_with("you are not assigned to issue") ); } #[test] fn closing_clears_by_prefix_for_every_holder() { let closed = flow("closed"); let got = evaluate( &closed, "issues", &event("closed", &[], &[], &["atlas", "argus"]), ); let got: Vec<_> = got.into_iter().filter(|a| a.node == "sink").collect(); assert_eq!(got.len(), 2); for a in &got { assert_eq!(a.op.op, Op::Clear); assert!(a.op.prefix); assert_eq!(a.op.key, "forge:hyperhive/hyperhive#7:"); assert_eq!(a.op.summary, ""); } assert!(evaluate(&closed, "issues", &event("reopened", &[], &[], &["atlas"])).is_empty()); } /// The clear runs before the `closed` todo, so its prefix does not take the /// todo with it, and both read the one holders set. #[test] fn closing_clears_then_tells_each_holder() { let closed = flow("closed"); let got = evaluate( &closed, "pull_request", &event("closed", &[], &[], &["atlas", "argus"]), ); let seen: Vec<(&str, &str, Op)> = got .iter() .map(|a| (a.node.as_str(), a.agent.as_str(), a.op.op)) .collect(); assert_eq!( seen, [ ("sink", "atlas", Op::Clear), ("sink", "argus", Op::Clear), ("inform", "atlas", Op::Upsert), ("inform", "argus", Op::Upsert), ] ); let tell = &got[2].op; assert_eq!(tell.key, "forge:hyperhive/hyperhive#7:closed"); assert!(!tell.prefix); assert_eq!( tell.summary, "issue hyperhive/hyperhive#7 was closed: router (https://forge/7)" ); assert_eq!(tell.source, "router:closed"); // The clear's prefix covers the todo's key: the order is what saves it. assert!(tell.key.starts_with(&got[0].op.key)); // No holders, no todos. assert!(evaluate(&closed, "issues", &event("closed", &[], &[], &[])).is_empty()); } /// A match node with two children runs both: the branching the tree /// allows. #[test] fn a_match_with_two_children_runs_both() { let mut f = flow("assigned"); let NodeKind::Todo(sink) = f.nodes[2].kind.clone() else { panic!("the third node is the sink") }; f.nodes.push(Node { id: "second".to_owned(), kind: NodeKind::Todo(TodoSink { to: Recipients::Field("assignees_removed".to_owned()), ..sink }), }); f.edges.push(Edge { from: "match".to_owned(), to: "second".to_owned(), }); assert_eq!(f.validate(), Ok(())); let got = evaluate( &f, "issue_assign", &event("assigned", &["atlas"], &["argus"], &[]), ); let mut nodes: Vec<(&str, &str)> = got .iter() .map(|a| (a.node.as_str(), a.agent.as_str())) .collect(); nodes.sort_unstable(); assert_eq!(nodes, [("second", "argus"), ("sink", "atlas")]); } /// A custom flow's sink can name one agent, whatever the event holds. #[test] fn a_sink_to_a_named_agent_reaches_it_once() { let mut f = flow("assigned"); 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(), }); let got = evaluate( &f, "issue_assign", &event("assigned", &["atlas", "iris"], &[], &[]), ); assert_eq!(got.len(), 1); assert_eq!(got[0].agent, "triage"); assert_eq!(got[0].op.summary, "triage hyperhive/hyperhive#7"); assert_eq!(got[0].op.source, "router:assigned"); } /// A request sits under the same match gating as a todo, and names the /// pull request the event concerns. #[test] fn a_reviewer_request_runs_under_its_match_and_only_for_a_pull_request() { let mut f = flow("assigned"); f.nodes[0].kind = NodeKind::Source { events: vec!["pull_request_sync".to_owned()], }; f.nodes[2].kind = NodeKind::RequestReviewer { reviewer: "argus".to_owned(), }; assert_eq!(f.validate(), Ok(())); let pull = |action: &str, kind: &'static str| { let mut fields = event(action, &[], &[], &[]); fields.insert("kind", text(kind)); fields }; let got = requests(&f, "pull_request_sync", &pull("assigned", "PR")); assert_eq!( got, [ReviewRequest { flow: "assigned".to_owned(), node: "sink".to_owned(), repo: "hyperhive/hyperhive".to_owned(), number: 7, reviewer: "argus".to_owned(), }] ); assert!(requests(&f, "pull_request_sync", &pull("opened", "PR")).is_empty()); assert!(requests(&f, "pull_request_sync", &pull("assigned", "issue")).is_empty()); assert!(requests(&f, "pull_request", &pull("assigned", "PR")).is_empty()); // A request is no todo. assert!(evaluate(&f, "pull_request_sync", &pull("assigned", "PR")).is_empty()); } #[test] fn a_placeholder_expands_to_its_field_and_an_unknown_one_is_an_error() { let fields = event("assigned", &["a", "b"], &[], &[]); assert_eq!( expand("{repo}#{number} {assignees_added} {", &fields), Ok("hyperhive/hyperhive#7 a, b {".to_owned()) ); assert_eq!( expand("x {nope}", &fields), Err("{nope} is no event field".to_owned()) ); } /// A sink naming a placeholder the event lacks sends nothing, rather /// than a todo under a literal `{name}` key. #[test] fn a_sink_with_an_unknown_placeholder_emits_nothing() { let mut f = flow("assigned"); f.nodes[2].kind = NodeKind::Todo(TodoSink { to: Recipients::Agent("triage".to_owned()), op: Op::Upsert, key: "forge:{repo}#{numbr}:triage".to_owned(), prefix: false, text: "t".to_owned(), }); let fields = event("assigned", &["atlas"], &[], &[]); assert!(evaluate(&f, "issue_assign", &fields).is_empty()); // Control: spelled right, the same flow emits. if let NodeKind::Todo(s) = &mut f.nodes[2].kind { s.key = "forge:{repo}#{number}:triage".to_owned(); } assert_eq!(evaluate(&f, "issue_assign", &fields).len(), 1); } }