Watch
0
0
Fork
You've already forked hyperhive
0

notify flows: GET returns a schema of the events, fields, node kinds and limits

This commit is contained in:
atlas 2026-10-24 12:25:19 +02:00 • committed by mara
commit d5802d16a0
5 changed files with 281 additions and 26 deletions

View file

@ -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");