notify router: read custom flows and disabled built-ins from a stored document
This commit is contained in:
parent
03fc548b0a
commit
bb08fac33c
11 changed files with 1418 additions and 342 deletions
|
|
@ -18,6 +18,7 @@ pub mod builder;
|
|||
pub mod claude_login;
|
||||
pub mod forge_events;
|
||||
pub mod notices;
|
||||
pub mod notify_flows;
|
||||
pub mod notify_router;
|
||||
pub mod review_classifier;
|
||||
pub mod router_todo;
|
||||
|
|
|
|||
774
swarm-wire/src/notify_flows.rs
Normal file
774
swarm-wire/src/notify_flows.rs
Normal file
|
|
@ -0,0 +1,774 @@
|
|||
//! 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": "",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue