//! 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. A `request_reviewer` node is a leaf that asks the forge for //! a review of the pull request the event concerns. 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", ), ( "pull_request_sync", "A push to the branch of an open pull request", ), ( "pull_request_review_approved", "A review submitted on a pull request that approves it", ), ( "pull_request_review_rejected", "A review submitted on a pull request that asks for changes", ), ( "pull_request_review_comment", "A review submitted on a pull request that only comments", ), ]; /// 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 forge account of the review classifier, whose review the built-in /// `head-classifier` flow requests. pub const CLASSIFIER_ACCOUNT: &str = "review-classifier"; /// 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, /// The flows the operator defined. #[serde(default)] pub flows: Vec, } 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:`. 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, pub edges: Vec, } 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 }, /// Passes when every condition holds. Match { when: Vec }, /// Emits a todo op to each recipient. Todo(TodoSink), /// Asks the forge for `reviewer`'s review of the pull request the event /// concerns, unless their review of its head already stands. Does nothing /// for an issue. RequestReviewer { /// A forge login. reviewer: String, }, } /// `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 { 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, Vec) { let mut flows: Vec = 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'_') } /// Whether `login` can name a forge account: Forgejo allows letters, digits, /// `-`, `_` and `.`, and no more than 40 of them. fn is_login(login: &str) -> bool { !login.is_empty() && login.len() <= 40 && login .bytes() .all(|b| b.is_ascii_alphanumeric() || matches!(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, } /// `values_from` marker: the names of the agents, as `GET /api/agents` lists /// them. pub const VALUES_FROM_AGENTS: &str = "agents"; /// `values_from` marker: the repositories with an open issue, as /// `GET /api/repos` lists them. A repository with none is missing, so a field /// that takes this marker is never closed. pub const VALUES_FROM_REPOS: &str = "repos"; /// The `kind` field's values: what the router puts there, which is not the /// words of the field's description. const KINDS: &[(&str, &str)] = &[("issue", "An issue"), ("PR", "A pull request")]; /// Some of what the forge sends as `action`. Others exist, so it is a list of /// suggestions only. const ACTIONS: &[(&str, &str)] = &[ ("opened", "Created"), ("closed", "Closed, or a pull request merged"), ("reopened", "Opened again after being closed"), ("edited", "Title or text changed"), ("assigned", "An assignee added"), ("unassigned", "An assignee removed"), ("synchronized", "New commits pushed to a pull request"), ]; /// One event field a condition or recipient can name, and what it can hold. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))] pub struct EventField { pub name: String, pub description: String, /// Values the field is known to hold; empty when none are listed. pub values: Vec, /// Where an editor asks for the values only known live: one of /// [`VALUES_FROM_AGENTS`], [`VALUES_FROM_REPOS`]. Absent when there is /// none. #[serde(skip_serializing_if = "Option::is_none")] pub values_from: Option, /// Whether `values` or the source is every value the field can hold, /// which an editor can show as a dropdown. When false they are /// suggestions and the editor still takes any other text. pub closed: bool, } /// 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, /// Where an editor asks for the values only known live, as /// [`EventField::values_from`]. #[serde(skip_serializing_if = "Option::is_none")] pub values_from: Option, /// Whether the values are every one the field accepts, as /// [`EventField::closed`]. pub closed: bool, } /// 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, } /// 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, pub fields: Vec, pub node_kinds: Vec, pub limits: Limits, } fn described(list: &[(&str, &str)]) -> Vec { 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(), values_from: None, closed: false, } } /// [`FIELDS`] with what each is known to hold. fn event_fields() -> Vec { FIELDS .iter() .map(|(name, description)| { let (values, values_from, closed) = match *name { "event" => (described(SOURCE_EVENTS), None, true), "kind" => (described(KINDS), None, true), "action" => (described(ACTIONS), None, false), "repo" => (Vec::new(), Some(VALUES_FROM_REPOS), false), "assignees" | "assignees_added" | "assignees_removed" | "todo_holders" => { (Vec::new(), Some(VALUES_FROM_AGENTS), false) } _ => (Vec::new(), None, false), }; EventField { name: (*name).to_owned(), description: (*description).to_owned(), values, values_from: values_from.map(str::to_owned), closed, } }) .collect() } /// 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); op.closed = true; let mut to = kind_field( "to", "The recipients: {field: } or {agent: }", true, ); to.values_from = Some(VALUES_FROM_AGENTS.to_owned()); to.closed = true; let mut reviewer = kind_field("reviewer", "The forge login to request a review from", true); // A login need not belong to an agent: the classifier's does not. reviewer.values_from = Some(VALUES_FROM_AGENTS.to_owned()); Schema { source_events: described(SOURCE_EVENTS), fields: event_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![ to, 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, ), ], }, KindSchema { kind: "request_reviewer".to_owned(), description: "A leaf: asks the forge for a review of the pull request the event concerns, unless the reviewer's review of its head already stands; does nothing for an issue".to_owned(), fields: vec![reviewer], }, ], 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(_) | NodeKind::RequestReviewer { .. } ) && !self.children(&node.id).is_empty() { return Err(format!("{} has children, and is a leaf", 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::>() .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()?, Self::RequestReviewer { reviewer } => { if !is_login(reviewer) { return Err(format!("reviewer {reviewer:?} is not a forge login")); } } } 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, /// then tells each agent that held one with a `closed` todo. The clear is the /// first child of the match and runs first: its prefix also covers the /// `closed` key. /// - `head-classifier`: a PR opened, reopened or pushed to requests the review /// classifier's review. #[must_use] pub fn builtins() -> Vec { 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(), }, ), closed(), head_classifier(), ] } /// The `closed` flow: the clear, then the todo that says why. fn closed() -> Flow { let mut flow = 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(), }, ); flow.nodes.push(Node { id: "inform".to_owned(), kind: NodeKind::Todo(TodoSink { to: Recipients::Field("todo_holders".to_owned()), op: Op::Upsert, key: "forge:{repo}#{number}:closed".to_owned(), prefix: false, text: "{kind} {repo}#{number} was closed: {title} ({url})".to_owned(), }), }); flow.edges.push(Edge { from: "match".to_owned(), to: "inform".to_owned(), }); flow } /// source(`pull_request`, `pull_request_sync`) → match(`action` = `opened`, /// `reopened` or `synchronized`) → request the review classifier's review. fn head_classifier() -> Flow { let mut nodes = vec![Node { id: "source".to_owned(), kind: NodeKind::Source { events: vec!["pull_request".to_owned(), "pull_request_sync".to_owned()], }, }]; let mut edges = Vec::new(); for action in ["opened", "reopened", "synchronized"] { let (matched, request) = (format!("match-{action}"), format!("request-{action}")); nodes.push(Node { id: matched.clone(), kind: NodeKind::Match { when: vec![Condition { field: "action".to_owned(), equals: action.to_owned(), }], }, }); nodes.push(Node { id: request.clone(), kind: NodeKind::RequestReviewer { reviewer: CLASSIFIER_ACCOUNT.to_owned(), }, }); edges.push(Edge { from: "source".to_owned(), to: matched.clone(), }); edges.push(Edge { from: matched, to: request, }); } Flow { id: "head-classifier".to_owned(), name: "head-classifier".to_owned(), enabled: true, nodes, edges, } } /// 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", "request_reviewer"]); let op_names = |op: &str| -> Option { 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 .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"); // What an editor offers for a field is what the router puts there. let field = |name: &str| s.fields.iter().find(|f| f.name == name).expect(name); let names = |f: &EventField| -> Vec { f.values.iter().map(|v| v.name.clone()).collect() }; assert_eq!(names(field("kind")), ["issue", "PR"]); assert!(field("kind").closed); let events: Vec = s.source_events.iter().map(|e| e.name.clone()).collect(); assert_eq!(names(field("event")), events); assert!(field("event").closed); assert!(!field("action").closed, "the forge may send more actions"); assert!(names(field("action")).contains(&"closed".to_owned())); assert_eq!( field("repo").values_from.as_deref(), Some(VALUES_FROM_REPOS) ); assert_eq!( field("assignees").values_from.as_deref(), Some(VALUES_FROM_AGENTS) ); assert!(field("title").values.is_empty() && field("title").values_from.is_none()); // A marker is one an editor knows to resolve. for f in &s.fields { if let Some(from) = &f.values_from { assert!([VALUES_FROM_AGENTS, VALUES_FROM_REPOS].contains(&from.as_str())); } } let kind_field_of = |kind: &str, name: &str| { s.node_kinds .iter() .find(|k| k.kind == kind) .and_then(|k| k.fields.iter().find(|f| f.name == name)) .expect(name) }; assert!(kind_field_of("todo", "op").closed); assert_eq!( kind_field_of("todo", "to").values_from.as_deref(), Some(VALUES_FROM_AGENTS) ); let reviewer = kind_field_of("request_reviewer", "reviewer"); assert_eq!(reviewer.values_from.as_deref(), Some(VALUES_FROM_AGENTS)); assert!(!reviewer.closed, "any forge login is allowed"); 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_syn".to_owned()], }; assert!(event.validate().unwrap_err().contains("pull_request_syn")); // Control: the spelling that exists passes. event.nodes[0].kind = NodeKind::Source { events: vec!["pull_request_sync".to_owned()], }; assert_eq!(event.validate(), Ok(())); 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(())); } /// A reviewer request is a leaf naming a forge login; the wire spells the /// kind as the schema lists it. #[test] fn a_request_reviewer_node_is_a_leaf_naming_a_login() { let with = |reviewer: &str| { let mut f = custom("c"); f.nodes[2].kind = NodeKind::RequestReviewer { reviewer: reviewer.to_owned(), }; f.validate() }; assert_eq!(with("review-classifier"), Ok(())); assert_eq!(with("a.b_c-1"), Ok(())); for bad in ["", "two words", "a/b", "x@y", &"a".repeat(41)] { assert!(with(bad).is_err(), "{bad:?}"); } let mut parent = custom("c"); parent.nodes[2].kind = NodeKind::RequestReviewer { reviewer: "review-classifier".to_owned(), }; parent.nodes.push(Node { id: "m2".to_owned(), kind: NodeKind::Match { when: Vec::new() }, }); parent.edges.push(edge("sink", "m2")); assert!( parent.validate().unwrap_err().contains("leaf"), "a reviewer request with a child" ); let json = serde_json::to_value(Node { id: "ask".to_owned(), kind: NodeKind::RequestReviewer { reviewer: "review-classifier".to_owned(), }, }) .unwrap(); assert_eq!( json, serde_json::json!({ "id": "ask", "kind": "request_reviewer", "reviewer": "review-classifier", }) ); let kind = json["kind"].as_str().unwrap(); assert!( schema().node_kinds.iter().any(|k| k.kind == kind), "the schema lists the kind the wire uses" ); let node: Node = serde_json::from_value(json).unwrap(); assert!(matches!(node.kind, NodeKind::RequestReviewer { .. })); } /// The built-in that requests the classifier on an open, a reopen and a push. #[test] fn head_classifier_requests_the_classifier_on_open_reopen_and_push() { let f = builtin("head-classifier"); assert_eq!(f.validate(), Ok(())); assert_eq!( f.nodes[0].kind, NodeKind::Source { events: vec!["pull_request".to_owned(), "pull_request_sync".to_owned()] } ); assert_eq!( f.children("source"), ["match-opened", "match-reopened", "match-synchronized"] ); for action in ["opened", "reopened", "synchronized"] { assert_eq!( f.node(&format!("match-{action}")).map(|n| &n.kind), Some(&NodeKind::Match { when: vec![Condition { field: "action".to_owned(), equals: action.to_owned() }] }) ); assert_eq!( f.node(&format!("request-{action}")).map(|n| &n.kind), Some(&NodeKind::RequestReviewer { reviewer: CLASSIFIER_ACCOUNT.to_owned() }) ); assert_eq!( f.children(&format!("match-{action}")), [format!("request-{action}")] ); } } #[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| 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::>(); assert_eq!( ids(&Document::default()), ["assigned", "unassigned", "closed", "head-classifier"], "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", "head-classifier", "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": "", }) ); } }