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

1893 lines
67 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. 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 key an archetype's flow `flow` of `archetype` goes by: in the
/// document's `disabled_builtins` it switches the flow off for every member,
/// and it names the flow in the router's log, a todo's source and the hit
/// counts. `flow` is the `id` the archetype declares.
#[must_use]
pub fn archetype_flow_key(archetype: &str, flow: &str) -> String {
format!("archetype:{archetype}/{flow}")
}
/// [`archetype_flow_key`] followed by `@agent`: in `disabled_builtins` it
/// switches the flow off for that one member.
#[must_use]
pub fn archetype_member_key(archetype: &str, flow: &str, agent: &str) -> String {
format!("{}@{agent}", archetype_flow_key(archetype, flow))
}
/// Whether `key` has the shape of [`archetype_flow_key`] or
/// [`archetype_member_key`]. Whether such a flow, or agent, exists is not
/// asked: a key for one that is gone is ignored, so editing an archetype
/// cannot make the stored document fail its check.
#[must_use]
pub fn is_archetype_key(key: &str) -> bool {
let Some(rest) = key.strip_prefix("archetype:") else {
return false;
};
let Some((archetype, rest)) = rest.split_once('/') else {
return false;
};
let (flow, agent) = match rest.split_once('@') {
Some((flow, agent)) => (flow, Some(agent)),
None => (rest, None),
};
is_id(archetype) && is_id(flow) && agent.is_none_or(is_id)
}
/// The document layout this build reads and writes. Version 1 wrote a todo's
/// key and text with `{field}` placeholders; version 2 writes them as Jinja
/// templates (`{{ field }}`). [`Document::decode`] converts a version 1
/// document, and the next save writes version 2.
pub const VERSION: u32 = 2;
/// The layout [`Document::decode`] still reads and converts.
const LEGACY_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",
),
(
"workflow_run_failure",
"A CI run on a pull request that finished with a failed job",
),
(
"workflow_run_cancelled",
"A CI run on a pull request that was cancelled",
),
(
"pull_request_mergeability",
"What swarm-controller found about an open pull request after a push to its target \
branch: action conflicting, or mergeable",
),
];
/// 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 }}` in a template.
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"),
(
"author",
"The account that opened the issue or pull request",
),
(
"draft",
"true when the pull request is a draft (its title starts WIP: or [WIP]), else false",
),
(
"undrafted",
"true when the event is a title edit that took the pull request out of draft, else false",
),
("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)",
),
(
"mergeability_changed",
"true when a mergeability event moves the pull request into or out of conflict, else false",
),
];
/// 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, and the keys of the
/// archetype flows that are ([`archetype_flow_key`], [`archetype_member_key`]).
#[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),
/// 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, a template over the event's [`FIELDS`].
pub key: String,
/// For a clear: `key` is a prefix.
#[serde(default)]
pub prefix: bool,
/// The todo text, a template over the event's [`FIELDS`]. 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),
/// Every agent created from the archetype the flow came from. Only an
/// archetype's own flows take it: the operator's document has no
/// archetype, so [`Flow::validate`] refuses it there, and
/// [`Flow::validate_archetype`] refuses every other recipient.
Members,
}
/// Why [`Document::decode`] could not read a document.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DecodeError {
/// A version this build does not read, 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 versions {LEGACY_VERSION} and {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. A version 1 document is
/// converted to [`VERSION`].
pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> {
let mut 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
{
if found != LEGACY_VERSION {
return Err(DecodeError::UnknownVersion(found.to_string()));
}
migrate_legacy(&mut value);
}
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) && !is_archetype_key(id) {
return Err(format!(
"disabled_builtins names {id}, which is no built-in flow and no archetype flow key"
));
}
}
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'_')
}
/// 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"),
(
"conflicting",
"A pull request does not merge into its target branch",
),
("mergeable", "A pull request merges into its target branch"),
];
/// 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<Described>,
/// 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<String>,
/// 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<Described>,
/// 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<String>,
/// 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<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<EventField>,
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(),
values_from: None,
closed: false,
}
}
/// [`FIELDS`] with what each is known to hold.
fn event_fields() -> Vec<EventField> {
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: <event field>} or {agent: <agent name>}",
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, a template: {{ field }}", true),
kind_field("prefix", "For a clear: key is a prefix", false),
kind_field(
"text",
"The todo text, a template: {{ field }}; 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,
},
}
}
/// A version 1 document with its todo templates rewritten as version 2 ones and
/// its version set to [`VERSION`].
fn migrate_legacy(document: &mut serde_json::Value) {
document["version"] = VERSION.into();
let nodes = document["flows"]
.as_array_mut()
.into_iter()
.flatten()
.flat_map(|flow| flow["nodes"].as_array_mut().into_iter().flatten());
for node in nodes.filter(|n| n["kind"] == "todo") {
for field in ["key", "text"] {
if let Some(template) = node[field].as_str() {
node[field] = jinja_of_legacy(template).into();
}
}
}
}
/// A version 1 template as a Jinja one: each `{name}` becomes `{{ name }}`. A `{`
/// with no `}` after it was plain text, and is kept so by writing it as a string.
fn jinja_of_legacy(template: &str) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
let Some(close) = after.find('}') else {
out.push_str("{{ \"{\" }}");
rest = after;
continue;
};
out.push_str("{{ ");
out.push_str(&after[..close]);
out.push_str(" }}");
rest = &after[close + 1..];
}
out.push_str(rest);
out
}
impl Flow {
/// Check the flow is a tree within the caps, and that every node reads
/// only what exists. This is the check for the operator's document, which
/// has no archetype, so a [`Recipients::Members`] fails it.
///
/// # Errors
///
/// Says why not.
pub fn validate(&self) -> Result<(), String> {
self.validate_in(Scope::Document)
}
/// The check for a flow an archetype carries: [`Flow::validate`], except
/// that every todo goes to [`Recipients::Members`] and no node asks for a
/// review. A flow that names an agent, or the agents an event field holds,
/// or asks for a review, belongs in the operator's document.
///
/// # Errors
///
/// Says why not.
pub fn validate_archetype(&self) -> Result<(), String> {
self.validate_in(Scope::Archetype)
}
fn validate_in(&self, scope: Scope) -> 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(scope)
.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)
}
}
/// Where a flow lives, which decides who its todos may go to.
#[derive(Clone, Copy)]
enum Scope {
/// The operator's document, or a built-in.
Document,
/// An archetype's `meta.flows`.
Archetype,
}
impl NodeKind {
fn validate(&self, scope: Scope) -> 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(scope)?,
Self::RequestReviewer { .. } if matches!(scope, Scope::Archetype) => {
return Err("an archetype's flow cannot request a review: that is the \
operator's flows document's job"
.to_owned());
}
Self::RequestReviewer { reviewer } => {
if !is_login(reviewer) {
return Err(format!("reviewer {reviewer:?} is not a forge login"));
}
}
}
Ok(())
}
}
impl TodoSink {
fn validate(&self, scope: Scope) -> Result<(), String> {
match (&self.to, scope) {
(Recipients::Members, Scope::Archetype) => {}
(Recipients::Members, Scope::Document) => {
return Err("recipients members is for an archetype's flows: the \
document has no archetype"
.to_owned());
}
(_, Scope::Archetype) => {
return Err(
"an archetype's flow sends only to its members: a flow to a \
named agent or to an event field's agents is the operator's flows \
document's job"
.to_owned(),
);
}
(Recipients::Field(f), Scope::Document) if !is_field(f) => {
return Err(format!("recipients from {f:?}, which is no event field"));
}
(Recipients::Agent(a), Scope::Document) 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)] {
let read = hive_template::variables(template)
.map_err(|why| format!("{what} is not a valid template: {why}"))?;
if let Some(bad) = read.iter().find(|n| !is_field(n)) {
return Err(format!("{what} reads {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.
/// - `ci-failed` and `ci-cancelled`: a CI run on a PR that failed or was
/// cancelled gives the PR's author a todo. Both write the same key, so the
/// later run's outcome replaces the earlier one.
/// - `ci-cleared`: a push to the PR's branch clears that todo, as the new run
/// replaces the old one.
/// - `conflict` and `conflict-cleared`: a PR that stops merging into its
/// target branch gives its author a todo, and the todo clears when it merges
/// again. Both read `mergeability_changed`, so a repeated finding that the
/// PR still conflicts writes nothing.
#[must_use]
pub fn builtins() -> Vec<Flow> {
let assignment_key = "forge:{{ repo }}#{{ number }}:assignment";
let mut flows = 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(),
];
flows.extend(ci_flows());
flows.extend(conflict_flows());
flows
}
/// source(`pull_request_mergeability`) → match(`action` and
/// `mergeability_changed` = `true`) → the author's `:conflict` todo.
fn conflict_flows() -> Vec<Flow> {
let flow = |id: &str, action: &str, op, text: &str| {
let mut flow = chain(
id,
&["pull_request_mergeability"],
action,
TodoSink {
to: Recipients::Field("author".to_owned()),
op,
key: "forge:{{ repo }}#{{ number }}:conflict".to_owned(),
prefix: false,
text: text.to_owned(),
},
);
for node in &mut flow.nodes {
if let NodeKind::Match { when } = &mut node.kind {
when.push(Condition {
field: "mergeability_changed".to_owned(),
equals: "true".to_owned(),
});
}
}
flow
};
vec![
flow(
"conflict",
"conflicting",
Op::Upsert,
"{{ kind }} {{ repo }}#{{ number }} no longer merges into its target branch: {{ title }} ({{ url }})",
),
flow("conflict-cleared", "mergeable", Op::Clear, ""),
]
}
/// The flows that tell a PR's author about its CI: the todo for a failed run,
/// the same key rewritten for a cancelled one, and the clear a push makes.
fn ci_flows() -> Vec<Flow> {
let ci_key = "forge:{{ repo }}#{{ number }}:ci";
let to_author = |op, text: &str| TodoSink {
to: Recipients::Field("author".to_owned()),
op,
key: ci_key.to_owned(),
prefix: false,
text: text.to_owned(),
};
vec![
chain(
"ci-failed",
&["workflow_run_failure"],
"failure",
to_author(
Op::Upsert,
"CI failed on {{ kind }} {{ repo }}#{{ number }}: {{ title }} ({{ url }})",
),
),
chain(
"ci-cancelled",
&["workflow_run_cancelled"],
"cancelled",
to_author(
Op::Upsert,
"CI was cancelled on {{ kind }} {{ repo }}#{{ number }}: {{ title }} ({{ url }})",
),
),
chain(
"ci-cleared",
&["pull_request_sync"],
"synchronized",
to_author(Op::Clear, ""),
),
]
}
/// 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`, and `draft` = `false`) → request the review
/// classifier's review; and match(`undrafted` = `true`) → the same request,
/// for a PR whose last push came while it was a draft.
fn head_classifier() -> Flow {
let branches = [
("opened", "action", "opened"),
("reopened", "action", "reopened"),
("synchronized", "action", "synchronized"),
("undrafted", "undrafted", "true"),
];
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 (name, field, equals) in branches {
let (matched, request) = (format!("match-{name}"), format!("request-{name}"));
let condition = |field: &str, equals: &str| Condition {
field: field.to_owned(),
equals: equals.to_owned(),
};
let mut when = vec![condition(field, equals)];
if field == "action" {
when.push(condition("draft", "false"));
}
nodes.push(Node {
id: matched.clone(),
kind: NodeKind::Match { when },
});
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);
}
}
/// Both conflict flows act only when the mergeability event changed the
/// PR's state, and write the author's `:conflict` key.
#[test]
fn the_conflict_flows_read_the_change_and_write_the_conflict_key() {
for (id, action, op) in [
("conflict", "conflicting", Op::Upsert),
("conflict-cleared", "mergeable", Op::Clear),
] {
let f = builtin(id);
assert_eq!(
f.node("source").map(|n| &n.kind),
Some(&NodeKind::Source {
events: vec!["pull_request_mergeability".to_owned()]
}),
"{id}"
);
let want = |field: &str, equals: &str| Condition {
field: field.to_owned(),
equals: equals.to_owned(),
};
assert_eq!(
f.node("match").map(|n| &n.kind),
Some(&NodeKind::Match {
when: vec![want("action", action), want("mergeability_changed", "true")]
}),
"{id}"
);
let Some(NodeKind::Todo(sink)) = f.node("sink").map(|n| &n.kind) else {
panic!("{id}: no todo");
};
assert_eq!(sink.op, op, "{id}");
assert_eq!(sink.key, "forge:{{ repo }}#{{ number }}:conflict", "{id}");
assert_eq!(sink.to, Recipients::Field("author".to_owned()), "{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<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");
// 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<String> { f.values.iter().map(|v| v.name.clone()).collect() };
assert_eq!(names(field("kind")), ["issue", "PR"]);
assert!(field("kind").closed);
let events: Vec<String> = 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(()));
let cond = |field: &str, equals: &str| Condition {
field: field.to_owned(),
equals: equals.to_owned(),
};
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",
"match-undrafted"
]
);
for action in ["opened", "reopened", "synchronized", "undrafted"] {
let when = if action == "undrafted" {
vec![cond("undrafted", "true")]
} else {
vec![cond("action", action), cond("draft", "false")]
};
assert_eq!(
f.node(&format!("match-{action}")).map(|n| &n.kind),
Some(&NodeKind::Match { when })
);
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");
assert!(with("k{{ repo", "t").is_err(), "an unclosed tag");
// A lone brace is text.
assert_eq!(with("k", "set {x"), Ok(()));
}
#[test]
fn a_version_1_document_reads_with_its_templates_converted() {
let v1 = serde_json::json!({
"version": 1,
"disabled_builtins": [],
"flows": [{
"id": "c", "name": "c", "enabled": true,
"nodes": [
{"id": "source", "kind": "source", "events": ["issues"]},
{"id": "t", "kind": "todo", "to": {"agent": "triage"}, "op": "upsert",
"key": "forge:{repo}#{number}:x", "text": "{title} {url} set {x"},
],
"edges": [{"from": "source", "to": "t"}],
}],
});
let doc = Document::decode(v1.to_string().as_bytes()).expect("reads");
assert_eq!(doc.version, VERSION);
let NodeKind::Todo(sink) = &doc.flows[0].nodes[1].kind else {
panic!("a todo node");
};
assert_eq!(sink.key, "forge:{{ repo }}#{{ number }}:x");
assert_eq!(sink.text, "{{ title }} {{ url }} set {{ \"{\" }}x");
assert_eq!(doc.check(), 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 = 3;
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",
"head-classifier",
"ci-failed",
"ci-cancelled",
"ci-cleared",
"conflict",
"conflict-cleared"
],
"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",
"ci-failed",
"ci-cancelled",
"ci-cleared",
"conflict",
"conflict-cleared",
"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":3}"#),
Err(DecodeError::UnknownVersion("3".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": "",
})
);
}
/// A copy of the `custom` flow whose todo goes to `to`.
fn sending_to(id: &str, to: Recipients) -> Flow {
let mut f = custom(id);
let NodeKind::Todo(sink) = &mut f.nodes[2].kind else {
panic!("a todo")
};
sink.to = to;
f
}
/// `members` is an archetype flow's recipient: the operator's document and
/// the document check refuse it, an archetype flow takes nothing else.
#[test]
fn members_is_for_an_archetypes_flows_only() {
let members = sending_to("m", Recipients::Members);
// Presence: the same flow passes where `members` belongs.
assert_eq!(members.validate_archetype(), Ok(()));
let why = members.validate().expect_err("no archetype in a document");
assert!(why.contains("no archetype"), "{why}");
let doc = Document {
flows: vec![members],
..Document::default()
};
assert!(doc.check().is_err());
assert!(doc.active().0.iter().all(|f| f.id != "m"), "left out");
for other in [
Recipients::Agent("triage".to_owned()),
Recipients::Field("author".to_owned()),
] {
let f = sending_to("o", other);
assert_eq!(f.validate(), Ok(()), "control: a document takes it");
let why = f.validate_archetype().expect_err("not for an archetype");
assert!(why.contains("only to its members"), "{why}");
}
let mut review = custom("r");
review.nodes[2].kind = NodeKind::RequestReviewer {
reviewer: "argus".to_owned(),
};
assert_eq!(review.validate(), Ok(()), "control: a document takes it");
assert!(review.validate_archetype().is_err());
for built in builtins() {
assert!(built.validate_archetype().is_err(), "{}", built.id);
}
}
/// An archetype's flow reads its key and text as the same Jinja templates a
/// document's flow does: an unknown variable or broken syntax is refused.
#[test]
fn an_archetypes_templates_are_checked_like_the_documents() {
let with = |key: &str, text: &str| {
let mut f = sending_to("t", Recipients::Members);
let NodeKind::Todo(sink) = &mut f.nodes[2].kind else {
panic!("a todo")
};
sink.key = key.to_owned();
sink.text = text.to_owned();
f
};
let ok = with("forge:{{ repo }}#{{ number }}:x", "{{ title }} {{ url }}");
assert_eq!(ok.validate_archetype(), Ok(()), "control");
for (key, text) in [
("forge:{{ repo }}#{{ numbr }}:x", "t"),
("k", "{{ nope }}"),
("k", "{{ title "),
] {
let f = with(key, text);
assert!(f.validate_archetype().is_err(), "{key} {text}");
}
}
/// The keys that switch an archetype's flow off join `disabled_builtins`;
/// one that names nothing there is still accepted, one of no valid shape is not.
#[test]
fn archetype_flow_keys_are_accepted_in_the_disabled_list() {
let flow = archetype_flow_key("triage", "new-issues");
let member = archetype_member_key("triage", "new-issues", "alice");
assert_eq!(flow, "archetype:triage/new-issues");
assert_eq!(member, "archetype:triage/new-issues@alice");
let with = |keys: &[&str]| Document {
disabled_builtins: keys.iter().map(|k| (*k).to_owned()).collect(),
..Document::default()
};
assert_eq!(with(&[&flow, &member, "closed"]).check(), Ok(()));
assert_eq!(with(&["archetype:gone/flow"]).check(), Ok(()), "stale");
for bad in [
"archetype:triage",
"archetype:/x",
"archetype:a/",
"archetype:a/b@",
"archetype:a b/c",
"archetype:a/b/c",
"triage/new-issues",
"no-such-builtin",
] {
assert!(with(&[bad]).check().is_err(), "{bad}");
assert!(!is_archetype_key(bad), "{bad}");
}
// A stale key leaves the built-ins as they were.
assert_eq!(with(&[&flow]).active().0.len(), builtins().len());
}
/// What saving a customized archetype flow sends: a custom flow to one
/// agent and that agent's switch-off key, in one document.
#[test]
fn a_custom_flow_and_the_agents_switch_off_key_pass_the_check_together() {
let doc = Document {
disabled_builtins: vec![archetype_member_key("triage", "new-issues", "alice")],
flows: vec![sending_to(
"new-issues-alice",
Recipients::Agent("alice".to_owned()),
)],
..Document::default()
};
assert_eq!(doc.check(), Ok(()));
// The archetype's own `members` form is not what the copy carries.
let bad = Document {
flows: vec![sending_to("new-issues-alice", Recipients::Members)],
..doc
};
assert!(bad.check().is_err());
}
}