471 lines
16 KiB
Rust
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);
|
|
}
|
|
}
|