1417 lines
49 KiB
Rust
1417 lines
49 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 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<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, with `{field}` placeholders.
|
|
pub key: String,
|
|
/// For a clear: `key` is a prefix.
|
|
#[serde(default)]
|
|
pub prefix: bool,
|
|
/// The todo text, with `{field}` placeholders. Empty for a clear.
|
|
#[serde(default)]
|
|
pub text: String,
|
|
}
|
|
|
|
/// Who a todo op goes to.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Recipients {
|
|
/// The agents the named event field holds, one of [`FIELDS`].
|
|
Field(String),
|
|
/// This agent, whatever the event.
|
|
Agent(String),
|
|
}
|
|
|
|
/// Why [`Document::decode`] could not read a document.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum DecodeError {
|
|
/// A version other than [`VERSION`], such as a newer build's.
|
|
UnknownVersion(String),
|
|
/// Not JSON, or not the shape of a [`Document`].
|
|
Malformed(String),
|
|
}
|
|
|
|
impl std::fmt::Display for DecodeError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::UnknownVersion(v) => {
|
|
write!(f, "version {v}, and this build reads version {VERSION}")
|
|
}
|
|
Self::Malformed(why) => f.write_str(why),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Document {
|
|
/// `bytes`, the stored document, or why it cannot be read.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Not the expected shape, or a [`Document::version`] this build does not
|
|
/// know. The reader keeps what it had.
|
|
pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> {
|
|
let value: serde_json::Value = serde_json::from_slice(bytes)
|
|
.map_err(|e| DecodeError::Malformed(format!("not JSON: {e}")))?;
|
|
if let Some(found) = value.get("version")
|
|
&& found != VERSION
|
|
{
|
|
return Err(DecodeError::UnknownVersion(found.to_string()));
|
|
}
|
|
serde_json::from_value(value)
|
|
.map_err(|e| DecodeError::Malformed(format!("not a flows document: {e}")))
|
|
}
|
|
|
|
/// What is wrong with the document, if anything: the check a save passes.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// The first problem found, naming the flow it is in.
|
|
pub fn check(&self) -> Result<(), String> {
|
|
if self.version != VERSION {
|
|
return Err(format!("version {} is not {VERSION}", self.version));
|
|
}
|
|
if self.flows.len() > MAX_FLOWS {
|
|
return Err(format!("{} flows, more than {MAX_FLOWS}", self.flows.len()));
|
|
}
|
|
let builtin = builtins();
|
|
for id in &self.disabled_builtins {
|
|
if !builtin.iter().any(|b| b.id == *id) {
|
|
return Err(format!(
|
|
"disabled_builtins names {id}, which is no built-in flow"
|
|
));
|
|
}
|
|
}
|
|
let mut ids: BTreeSet<&str> = builtin.iter().map(|b| b.id.as_str()).collect();
|
|
for flow in &self.flows {
|
|
if !ids.insert(flow.id.as_str()) {
|
|
return Err(format!(
|
|
"flow id {} is used twice, or is a built-in flow's",
|
|
flow.id
|
|
));
|
|
}
|
|
flow.validate()
|
|
.map_err(|why| format!("flow {}: {why}", flow.id))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The flows to run: the built-ins that are not switched off, then the
|
|
/// enabled custom flows that pass [`Flow::validate`] with an id of their
|
|
/// own. With the reason for each custom flow left out.
|
|
#[must_use]
|
|
pub fn active(&self) -> (Vec<Flow>, Vec<String>) {
|
|
let mut flows: Vec<Flow> = builtins()
|
|
.into_iter()
|
|
.filter(|b| !self.disabled_builtins.contains(&b.id))
|
|
.collect();
|
|
let mut skipped = Vec::new();
|
|
for flow in self.flows.iter().filter(|f| f.enabled) {
|
|
if flows.iter().any(|f| f.id == flow.id) {
|
|
skipped.push(format!("flow {}: its id is already in use", flow.id));
|
|
} else if let Err(why) = flow.validate() {
|
|
skipped.push(format!("flow {}: {why}", flow.id));
|
|
} else {
|
|
flows.push(flow.clone());
|
|
}
|
|
}
|
|
(flows, skipped)
|
|
}
|
|
}
|
|
|
|
/// Whether `id` can name a flow or a node: it ends up in a message id and in
|
|
/// a todo's source.
|
|
fn is_id(id: &str) -> bool {
|
|
!id.is_empty()
|
|
&& id.len() <= 64
|
|
&& id
|
|
.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
|
}
|
|
|
|
/// 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<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, 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::<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()?,
|
|
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<Flow> {
|
|
let assignment_key = "forge:{repo}#{number}:assignment";
|
|
vec![
|
|
chain(
|
|
"assigned",
|
|
&["issue_assign", "pull_request_assign"],
|
|
"assigned",
|
|
TodoSink {
|
|
to: Recipients::Field("assignees_added".to_owned()),
|
|
op: Op::Upsert,
|
|
key: assignment_key.to_owned(),
|
|
prefix: false,
|
|
text: "you are assigned to {kind} {repo}#{number}: {title} ({url})".to_owned(),
|
|
},
|
|
),
|
|
chain(
|
|
"unassigned",
|
|
&["issue_assign", "pull_request_assign"],
|
|
"unassigned",
|
|
TodoSink {
|
|
to: Recipients::Field("assignees_removed".to_owned()),
|
|
op: Op::Upsert,
|
|
key: assignment_key.to_owned(),
|
|
prefix: false,
|
|
text: "you are not assigned to {kind} {repo}#{number} anymore: {title} ({url})"
|
|
.to_owned(),
|
|
},
|
|
),
|
|
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<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(()));
|
|
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<Flow>| Document {
|
|
version: VERSION,
|
|
disabled_builtins: disabled.iter().map(|s| (*s).to_owned()).collect(),
|
|
flows,
|
|
};
|
|
assert_eq!(doc(&["assigned"], vec![custom("triage")]).check(), Ok(()));
|
|
assert!(doc(&["nope"], vec![]).check().unwrap_err().contains("nope"));
|
|
assert!(
|
|
doc(&[], vec![custom("assigned")]).check().is_err(),
|
|
"a built-in's id"
|
|
);
|
|
assert!(
|
|
doc(&[], vec![custom("a"), custom("a")]).check().is_err(),
|
|
"a repeated id"
|
|
);
|
|
let mut bad = custom("bad");
|
|
bad.edges.pop();
|
|
assert!(
|
|
doc(&[], vec![bad])
|
|
.check()
|
|
.unwrap_err()
|
|
.starts_with("flow bad:")
|
|
);
|
|
let many = (0..=MAX_FLOWS).map(|i| custom(&format!("f{i}"))).collect();
|
|
assert!(doc(&[], many).check().is_err(), "flow count");
|
|
let mut old = doc(&[], vec![]);
|
|
old.version = 2;
|
|
assert!(old.check().is_err(), "version");
|
|
}
|
|
|
|
#[test]
|
|
fn active_is_the_builtins_minus_the_disabled_plus_the_enabled_valid_custom() {
|
|
let ids = |d: &Document| d.active().0.into_iter().map(|f| f.id).collect::<Vec<_>>();
|
|
assert_eq!(
|
|
ids(&Document::default()),
|
|
["assigned", "unassigned", "closed", "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": "",
|
|
})
|
|
);
|
|
}
|
|
}
|