Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-notify-router/src/flow.rs

471 lines
16 KiB
Rust

//! 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<String>),
}
/// 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<Action> {
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<ReviewRequest> {
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<ReviewRequest> {
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<Action> {
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<String, String> {
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);
}
}