774 lines
26 KiB
Rust
774 lines
26 KiB
Rust
//! 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<String>,
|
|
/// The flows the operator defined.
|
|
#[serde(default)]
|
|
pub flows: Vec<Flow>,
|
|
}
|
|
|
|
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:<id>`.
|
|
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<Node>,
|
|
pub edges: Vec<Edge>,
|
|
}
|
|
|
|
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<String> },
|
|
/// Passes when every condition holds.
|
|
Match { when: Vec<Condition> },
|
|
/// 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<Self, DecodeError> {
|
|
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<Flow>, Vec<String>) {
|
|
let mut flows: Vec<Flow> = 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<Flow> {
|
|
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<Flow>| 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::<Vec<_>>();
|
|
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": "",
|
|
})
|
|
);
|
|
}
|
|
}
|