Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-wire/src/notify_flows.rs

1064 lines
36 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, with what each means:
/// the ones swarm-controller's hooks deliver to the events stream.
pub const SOURCE_EVENTS: &[(&str, &str)] = &[
(
"issues",
"An issue opened, closed, edited or otherwise changed",
),
("issue_assign", "An issue assigned or unassigned"),
(
"pull_request",
"A pull request opened, closed, edited or otherwise changed",
),
(
"pull_request_assign",
"A pull request assigned or unassigned",
),
];
/// The fields of an event a condition or a recipient can name, with what each
/// holds. A todo's key and text read them as `{field}` placeholders.
pub const FIELDS: &[(&str, &str)] = &[
("event", "The event type, one of the source events"),
("action", "What happened, such as opened, closed, assigned"),
("kind", "issue or pull request"),
("repo", "The repository, owner/name"),
("number", "The issue or pull request number"),
("title", "The issue or pull request title"),
("url", "The web link to the issue or pull request"),
("sender", "The account that caused the event"),
("assignees", "The agents assigned after the event (a list)"),
("assignees_added", "The agents the event assigned (a list)"),
(
"assignees_removed",
"The agents the event unassigned (a list)",
),
(
"todo_holders",
"The agents holding a router todo for the issue or pull request (a list)",
),
];
/// The todo ops a `todo` node's `op` takes, one per [`Op`] variant.
const OPS: &[(&str, &str)] = &[
(
"upsert",
"Add the todo, or update the one under the same key",
),
(
"clear",
"Remove the todo under the key, or with prefix every one starting with it",
),
];
/// 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.iter().any(|(n, _)| *n == name)
}
fn is_source_event(name: &str) -> bool {
SOURCE_EVENTS.iter().any(|(n, _)| *n == name)
}
/// A name with a one-line description.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct Described {
pub name: String,
pub description: String,
}
/// One field of a node kind.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct KindField {
pub name: String,
pub description: String,
/// Whether a node of the kind must set it.
pub required: bool,
/// The accepted values when the field takes one of a fixed few; empty
/// otherwise.
pub values: Vec<Described>,
}
/// One kind of node and the fields it takes besides `id` and `kind`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct KindSchema {
pub kind: String,
pub description: String,
pub fields: Vec<KindField>,
}
/// The limits [`Document::check`] enforces.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct Limits {
pub max_nodes: usize,
pub max_depth: usize,
pub max_flows: usize,
}
/// What a flow editor needs to offer only what [`Document::check`] accepts,
/// built from the constants the check reads.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub struct Schema {
pub source_events: Vec<Described>,
pub fields: Vec<Described>,
pub node_kinds: Vec<KindSchema>,
pub limits: Limits,
}
fn described(list: &[(&str, &str)]) -> Vec<Described> {
list.iter()
.map(|(name, description)| Described {
name: (*name).to_owned(),
description: (*description).to_owned(),
})
.collect()
}
fn kind_field(name: &str, description: &str, required: bool) -> KindField {
KindField {
name: name.to_owned(),
description: description.to_owned(),
required,
values: Vec::new(),
}
}
/// The schema of this build's flows.
#[must_use]
pub fn schema() -> Schema {
let mut op = kind_field("op", "What the todo does to the recipient's list", true);
op.values = described(OPS);
Schema {
source_events: described(SOURCE_EVENTS),
fields: described(FIELDS),
node_kinds: vec![
KindSchema {
kind: "source".to_owned(),
description: "The root: the forge events the flow starts from".to_owned(),
fields: vec![kind_field(
"events",
"One or more of the source events",
true,
)],
},
KindSchema {
kind: "match".to_owned(),
description: "Passes when every condition holds; each child then runs".to_owned(),
fields: vec![kind_field(
"when",
"Conditions, each {field, equals}; a list field holds when one item equals",
true,
)],
},
KindSchema {
kind: "todo".to_owned(),
description: "A leaf: emits one todo op per recipient".to_owned(),
fields: vec![
kind_field(
"to",
"The recipients: {field: <event field>} or {agent: <agent name>}",
true,
),
op,
kind_field("key", "The todo key, with {field} placeholders", true),
kind_field("prefix", "For a clear: key is a prefix", false),
kind_field(
"text",
"The todo text, with {field} placeholders; required for an upsert, empty for a clear",
false,
),
],
},
],
limits: Limits {
max_nodes: MAX_NODES,
max_depth: MAX_DEPTH,
max_flows: MAX_FLOWS,
},
}
}
/// The `{name}` placeholders in `template`, in order. A `{` with no `}` after
/// it is plain text.
#[must_use]
pub fn placeholders(template: &str) -> Vec<&str> {
let mut names = Vec::new();
let mut rest = template;
while let Some(open) = rest.find('{') {
let after = &rest[open + 1..];
let Some(close) = after.find('}') else { break };
names.push(&after[..close]);
rest = &after[close + 1..];
}
names
}
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| !is_source_event(e)) {
return Err(format!(
"event {bad:?} is not one of {}",
SOURCE_EVENTS
.iter()
.map(|(n, _)| *n)
.collect::<Vec<_>>()
.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"));
}
for (what, template) in [("key", &self.key), ("text", &self.text)] {
if let Some(bad) = placeholders(template).into_iter().find(|n| !is_field(n)) {
return Err(format!("{what} has {{{bad}}}, which is no event field"));
}
}
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);
}
}
/// What the schema offers is what the check accepts, and nothing else is.
#[test]
fn the_schema_lists_what_a_flow_may_name() {
let s = schema();
let with_event = |name: &str| {
let mut f = builtin("assigned");
f.nodes[0].kind = NodeKind::Source {
events: vec![name.to_owned()],
};
f.validate()
};
assert!(!s.source_events.is_empty());
for e in &s.source_events {
assert_eq!(with_event(&e.name), Ok(()), "{}", e.name);
assert!(!e.description.is_empty(), "{}", e.name);
}
assert!(with_event("no_such_event").is_err(), "control");
let with_field = |name: &str| {
let mut f = builtin("assigned");
f.nodes[1].kind = NodeKind::Match {
when: vec![Condition {
field: name.to_owned(),
equals: "x".to_owned(),
}],
};
f.validate()
};
assert!(!s.fields.is_empty());
for field in &s.fields {
assert_eq!(with_field(&field.name), Ok(()), "{}", field.name);
assert!(!field.description.is_empty(), "{}", field.name);
}
assert!(with_field("no_such_field").is_err(), "control");
let kinds: Vec<&str> = s.node_kinds.iter().map(|k| k.kind.as_str()).collect();
assert_eq!(kinds, ["source", "match", "todo"]);
let op_names = |op: &str| -> Option<Op> {
serde_json::from_value(serde_json::Value::String(op.to_owned())).ok()
};
let todo = s
.node_kinds
.iter()
.find(|k| k.kind == "todo")
.expect("todo");
let op = todo.fields.iter().find(|f| f.name == "op").expect("op");
let listed: Vec<Op> = op
.values
.iter()
.map(|v| op_names(&v.name).expect("an op"))
.collect();
// Every `Op` variant is listed: this match stops compiling when one is added.
for o in [Op::Upsert, Op::Clear] {
match o {
Op::Upsert | Op::Clear => assert!(listed.contains(&o), "{o:?}"),
}
}
assert_eq!(listed.len(), 2);
assert!(op_names("nope").is_none(), "control");
assert_eq!(
(s.limits.max_nodes, s.limits.max_depth, s.limits.max_flows),
(MAX_NODES, MAX_DEPTH, MAX_FLOWS)
);
}
#[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_placeholder_must_name_an_event_field() {
let with = |key: &str, text: &str| {
let mut f = custom("c");
f.nodes[2].kind = NodeKind::Todo(TodoSink {
to: Recipients::Agent("triage".to_owned()),
op: Op::Upsert,
key: key.to_owned(),
prefix: false,
text: text.to_owned(),
});
f.validate()
};
assert_eq!(
with("forge:{repo}#{number}:triage", "{title} {url}"),
Ok(())
);
assert!(
with("forge:{repo}#{numbr}:triage", "t")
.unwrap_err()
.contains("numbr")
);
assert!(with("k", "see {ulr}").unwrap_err().contains("ulr"));
assert!(with("k{}", "t").is_err(), "an empty name");
// An unclosed brace is text.
assert_eq!(with("k", "set {x"), Ok(()));
assert_eq!(placeholders("a{b}c{d} {e"), ["b", "d"]);
}
#[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": "",
})
);
}
}