notify flows: GET returns a schema of the events, fields, node kinds and limits
This commit is contained in:
parent
627964f72d
commit
d5802d16a0
5 changed files with 281 additions and 26 deletions
|
|
@ -40,29 +40,57 @@ pub const MAX_FLOWS: usize = 100;
|
|||
/// The longest a flow's name, or a todo's key or text, may be.
|
||||
pub const MAX_TEXT: usize = 500;
|
||||
|
||||
/// The forge event types a source node can start from: the ones
|
||||
/// swarm-controller's hooks deliver to the events stream.
|
||||
pub const SOURCE_EVENTS: &[&str] = &[
|
||||
"issues",
|
||||
"issue_assign",
|
||||
"pull_request",
|
||||
"pull_request_assign",
|
||||
/// The forge event types a source node can start from, with what each means:
|
||||
/// the ones swarm-controller's hooks deliver to the events stream.
|
||||
pub const SOURCE_EVENTS: &[(&str, &str)] = &[
|
||||
(
|
||||
"issues",
|
||||
"An issue opened, closed, edited or otherwise changed",
|
||||
),
|
||||
("issue_assign", "An issue assigned or unassigned"),
|
||||
(
|
||||
"pull_request",
|
||||
"A pull request opened, closed, edited or otherwise changed",
|
||||
),
|
||||
(
|
||||
"pull_request_assign",
|
||||
"A pull request assigned or unassigned",
|
||||
),
|
||||
];
|
||||
|
||||
/// The fields of an event a condition or a recipient can name.
|
||||
pub const FIELDS: &[&str] = &[
|
||||
"event",
|
||||
"action",
|
||||
"kind",
|
||||
"repo",
|
||||
"number",
|
||||
"title",
|
||||
"url",
|
||||
"sender",
|
||||
"assignees",
|
||||
"assignees_added",
|
||||
"assignees_removed",
|
||||
"todo_holders",
|
||||
/// The fields of an event a condition or a recipient can name, with what each
|
||||
/// holds. A todo's key and text read them as `{field}` placeholders.
|
||||
pub const FIELDS: &[(&str, &str)] = &[
|
||||
("event", "The event type, one of the source events"),
|
||||
("action", "What happened, such as opened, closed, assigned"),
|
||||
("kind", "issue or pull request"),
|
||||
("repo", "The repository, owner/name"),
|
||||
("number", "The issue or pull request number"),
|
||||
("title", "The issue or pull request title"),
|
||||
("url", "The web link to the issue or pull request"),
|
||||
("sender", "The account that caused the event"),
|
||||
("assignees", "The agents assigned after the event (a list)"),
|
||||
("assignees_added", "The agents the event assigned (a list)"),
|
||||
(
|
||||
"assignees_removed",
|
||||
"The agents the event unassigned (a list)",
|
||||
),
|
||||
(
|
||||
"todo_holders",
|
||||
"The agents holding a router todo for the issue or pull request (a list)",
|
||||
),
|
||||
];
|
||||
|
||||
/// The todo ops a `todo` node's `op` takes, one per [`Op`] variant.
|
||||
const OPS: &[(&str, &str)] = &[
|
||||
(
|
||||
"upsert",
|
||||
"Add the todo, or update the one under the same key",
|
||||
),
|
||||
(
|
||||
"clear",
|
||||
"Remove the todo under the key, or with prefix every one starting with it",
|
||||
),
|
||||
];
|
||||
|
||||
/// The value under [`KEY`].
|
||||
|
|
@ -288,7 +316,134 @@ fn is_id(id: &str) -> bool {
|
|||
}
|
||||
|
||||
fn is_field(name: &str) -> bool {
|
||||
FIELDS.contains(&name)
|
||||
FIELDS.iter().any(|(n, _)| *n == name)
|
||||
}
|
||||
|
||||
fn is_source_event(name: &str) -> bool {
|
||||
SOURCE_EVENTS.iter().any(|(n, _)| *n == name)
|
||||
}
|
||||
|
||||
/// A name with a one-line description.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||
pub struct Described {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// One field of a node kind.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||
pub struct KindField {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
/// Whether a node of the kind must set it.
|
||||
pub required: bool,
|
||||
/// The accepted values when the field takes one of a fixed few; empty
|
||||
/// otherwise.
|
||||
pub values: Vec<Described>,
|
||||
}
|
||||
|
||||
/// One kind of node and the fields it takes besides `id` and `kind`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||
pub struct KindSchema {
|
||||
pub kind: String,
|
||||
pub description: String,
|
||||
pub fields: Vec<KindField>,
|
||||
}
|
||||
|
||||
/// The limits [`Document::check`] enforces.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||
pub struct Limits {
|
||||
pub max_nodes: usize,
|
||||
pub max_depth: usize,
|
||||
pub max_flows: usize,
|
||||
}
|
||||
|
||||
/// What a flow editor needs to offer only what [`Document::check`] accepts,
|
||||
/// built from the constants the check reads.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||
pub struct Schema {
|
||||
pub source_events: Vec<Described>,
|
||||
pub fields: Vec<Described>,
|
||||
pub node_kinds: Vec<KindSchema>,
|
||||
pub limits: Limits,
|
||||
}
|
||||
|
||||
fn described(list: &[(&str, &str)]) -> Vec<Described> {
|
||||
list.iter()
|
||||
.map(|(name, description)| Described {
|
||||
name: (*name).to_owned(),
|
||||
description: (*description).to_owned(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn kind_field(name: &str, description: &str, required: bool) -> KindField {
|
||||
KindField {
|
||||
name: name.to_owned(),
|
||||
description: description.to_owned(),
|
||||
required,
|
||||
values: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The schema of this build's flows.
|
||||
#[must_use]
|
||||
pub fn schema() -> Schema {
|
||||
let mut op = kind_field("op", "What the todo does to the recipient's list", true);
|
||||
op.values = described(OPS);
|
||||
Schema {
|
||||
source_events: described(SOURCE_EVENTS),
|
||||
fields: described(FIELDS),
|
||||
node_kinds: vec![
|
||||
KindSchema {
|
||||
kind: "source".to_owned(),
|
||||
description: "The root: the forge events the flow starts from".to_owned(),
|
||||
fields: vec![kind_field(
|
||||
"events",
|
||||
"One or more of the source events",
|
||||
true,
|
||||
)],
|
||||
},
|
||||
KindSchema {
|
||||
kind: "match".to_owned(),
|
||||
description: "Passes when every condition holds; each child then runs".to_owned(),
|
||||
fields: vec![kind_field(
|
||||
"when",
|
||||
"Conditions, each {field, equals}; a list field holds when one item equals",
|
||||
true,
|
||||
)],
|
||||
},
|
||||
KindSchema {
|
||||
kind: "todo".to_owned(),
|
||||
description: "A leaf: emits one todo op per recipient".to_owned(),
|
||||
fields: vec![
|
||||
kind_field(
|
||||
"to",
|
||||
"The recipients: {field: <event field>} or {agent: <agent name>}",
|
||||
true,
|
||||
),
|
||||
op,
|
||||
kind_field("key", "The todo key, with {field} placeholders", true),
|
||||
kind_field("prefix", "For a clear: key is a prefix", false),
|
||||
kind_field(
|
||||
"text",
|
||||
"The todo text, with {field} placeholders; required for an upsert, empty for a clear",
|
||||
false,
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
limits: Limits {
|
||||
max_nodes: MAX_NODES,
|
||||
max_depth: MAX_DEPTH,
|
||||
max_flows: MAX_FLOWS,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The `{name}` placeholders in `template`, in order. A `{` with no `}` after
|
||||
|
|
@ -412,10 +567,14 @@ impl NodeKind {
|
|||
if events.is_empty() {
|
||||
return Err("a source with no events".to_owned());
|
||||
}
|
||||
if let Some(bad) = events.iter().find(|e| !SOURCE_EVENTS.contains(&e.as_str())) {
|
||||
if let Some(bad) = events.iter().find(|e| !is_source_event(e)) {
|
||||
return Err(format!(
|
||||
"event {bad:?} is not one of {}",
|
||||
SOURCE_EVENTS.join(", ")
|
||||
SOURCE_EVENTS
|
||||
.iter()
|
||||
.map(|(n, _)| *n)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -590,6 +749,72 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// What the schema offers is what the check accepts, and nothing else is.
|
||||
#[test]
|
||||
fn the_schema_lists_what_a_flow_may_name() {
|
||||
let s = schema();
|
||||
let with_event = |name: &str| {
|
||||
let mut f = builtin("assigned");
|
||||
f.nodes[0].kind = NodeKind::Source {
|
||||
events: vec![name.to_owned()],
|
||||
};
|
||||
f.validate()
|
||||
};
|
||||
assert!(!s.source_events.is_empty());
|
||||
for e in &s.source_events {
|
||||
assert_eq!(with_event(&e.name), Ok(()), "{}", e.name);
|
||||
assert!(!e.description.is_empty(), "{}", e.name);
|
||||
}
|
||||
assert!(with_event("no_such_event").is_err(), "control");
|
||||
|
||||
let with_field = |name: &str| {
|
||||
let mut f = builtin("assigned");
|
||||
f.nodes[1].kind = NodeKind::Match {
|
||||
when: vec![Condition {
|
||||
field: name.to_owned(),
|
||||
equals: "x".to_owned(),
|
||||
}],
|
||||
};
|
||||
f.validate()
|
||||
};
|
||||
assert!(!s.fields.is_empty());
|
||||
for field in &s.fields {
|
||||
assert_eq!(with_field(&field.name), Ok(()), "{}", field.name);
|
||||
assert!(!field.description.is_empty(), "{}", field.name);
|
||||
}
|
||||
assert!(with_field("no_such_field").is_err(), "control");
|
||||
|
||||
let kinds: Vec<&str> = s.node_kinds.iter().map(|k| k.kind.as_str()).collect();
|
||||
assert_eq!(kinds, ["source", "match", "todo"]);
|
||||
let op_names = |op: &str| -> Option<Op> {
|
||||
serde_json::from_value(serde_json::Value::String(op.to_owned())).ok()
|
||||
};
|
||||
let todo = s
|
||||
.node_kinds
|
||||
.iter()
|
||||
.find(|k| k.kind == "todo")
|
||||
.expect("todo");
|
||||
let op = todo.fields.iter().find(|f| f.name == "op").expect("op");
|
||||
let listed: Vec<Op> = op
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| op_names(&v.name).expect("an op"))
|
||||
.collect();
|
||||
// Every `Op` variant is listed: this match stops compiling when one is added.
|
||||
for o in [Op::Upsert, Op::Clear] {
|
||||
match o {
|
||||
Op::Upsert | Op::Clear => assert!(listed.contains(&o), "{o:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(listed.len(), 2);
|
||||
assert!(op_names("nope").is_none(), "control");
|
||||
|
||||
assert_eq!(
|
||||
(s.limits.max_nodes, s.limits.max_depth, s.limits.max_flows),
|
||||
(MAX_NODES, MAX_DEPTH, MAX_FLOWS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flow_that_is_not_a_tree_is_refused() {
|
||||
let mut cycle = builtin("assigned");
|
||||
|
|
|
|||
Loading…
Reference in a new issue