Watch
0
0
Fork
You've already forked hyperhive
0

templates: one Jinja language (hive-template) for the notify router's flows, flows document version 2

A todo node's key and text were hand-expanded `{field}` strings. They are now Jinja templates rendered through the new hive-template crate (minijinja, an unknown variable is an error), and the controller's save check reads the variables the template uses. The flows document is version 2; a version 1 document reads as version 2 with each `{field}` converted to `{{ field }}`, and the next save writes it.
This commit is contained in:
damocles 2026-11-11 02:13:54 +01:00 • committed by mara
commit 8ef3138863
15 changed files with 306 additions and 91 deletions

View file

@ -101,6 +101,7 @@ Per-hive (host + in-container):
Wire types + socket plumbing: Wire types + socket plumbing:
- **`hive-types/`** — leaf newtypes, chiefly the validated `Ident`. - **`hive-types/`** — leaf newtypes, chiefly the validated `Ident`.
- **`hive-template/`** — the one template language (minijinja, strict undefined): `render`, `variables`.
- **`hive-sh4re/`** — shared payload types (`Message`, …). - **`hive-sh4re/`** — shared payload types (`Message`, …).
- **`hive-host-sock/`** (hivectl ↔ c0re), **`hive-priv-sock/`** (c0re ↔ - **`hive-host-sock/`** (hivectl ↔ c0re), **`hive-priv-sock/`** (c0re ↔
priv), **`hive-core-agent-sock/`** (harness ↔ c0re, `/run/hive/mcp.sock`) priv), **`hive-core-agent-sock/`** (harness ↔ c0re, `/run/hive/mcp.sock`)

27
Cargo.lock generated
View file

@ -2106,6 +2106,15 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "hive-template"
version = "0.1.0"
dependencies = [
"minijinja",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "hive-types" name = "hive-types"
version = "0.1.0" version = "0.1.0"
@ -3176,6 +3185,12 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memo-map"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5449c8c750f1a07ea702bbd212bd999fceece9b3d1508b17023b3e174583124b"
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.18" version = "0.3.18"
@ -3198,6 +3213,16 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "minijinja"
version = "2.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86886cf6dbf4e614b19c9a1eec9775f021869d7eadde0fc73921a81b90c9b4c9"
dependencies = [
"memo-map",
"serde",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.9.1" version = "0.9.1"
@ -5254,6 +5279,7 @@ dependencies = [
"async-nats", "async-nats",
"futures-util", "futures-util",
"hive-log", "hive-log",
"hive-template",
"opentelemetry", "opentelemetry",
"opentelemetry_sdk", "opentelemetry_sdk",
"serde", "serde",
@ -5338,6 +5364,7 @@ dependencies = [
name = "swarm-wire" name = "swarm-wire"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"hive-template",
"hive-types", "hive-types",
"serde", "serde",
"serde_json", "serde_json",

View file

@ -24,6 +24,7 @@ members = [
"hive-sh4re", "hive-sh4re",
"hive-sock-client", "hive-sock-client",
"hive-subagent-mcp", "hive-subagent-mcp",
"hive-template",
"hive-types", "hive-types",
"hivectl", "hivectl",
"swarm-agent-verify", "swarm-agent-verify",
@ -115,6 +116,7 @@ hive-host-sock = { path = "hive-host-sock" }
hive-priv-sock = { path = "hive-priv-sock" } hive-priv-sock = { path = "hive-priv-sock" }
hive-runtime = { path = "hive-runtime" } hive-runtime = { path = "hive-runtime" }
hive-sock-client = { path = "hive-sock-client" } hive-sock-client = { path = "hive-sock-client" }
hive-template = { path = "hive-template" }
hive-types = { path = "hive-types" } hive-types = { path = "hive-types" }
swarm-agent-verify = { path = "swarm-agent-verify" } swarm-agent-verify = { path = "swarm-agent-verify" }
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" } swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }

View file

@ -403,7 +403,7 @@ the ends. Pick a node to edit it in the panel beside the canvas:
- a **match** lists conditions, each an event field and the value it must equal; - a **match** lists conditions, each an event field and the value it must equal;
- a **todo** says who gets it (the agents an event field names, or one agent, picked from the agents the swarm has), - a **todo** says who gets it (the agents an event field names, or one agent, picked from the agents the swarm has),
whether it sets or clears a todo, the todo's key, and its text. The key and the whether it sets or clears a todo, the todo's key, and its text. The key and the
text can use `{field}` placeholders; text are Jinja templates over the event's fields, such as `{{ repo }}#{{ number }}`;
- a **request_reviewer** names the forge login the router asks to review the - a **request_reviewer** names the forge login the router asks to review the
pull request. It ends a branch, like a todo, and the row of kinds above the pull request. It ends a branch, like a todo, and the row of kinds above the
canvas only offers it when the controller does. canvas only offers it when the controller does.

View file

@ -100,8 +100,8 @@ switched off, and the custom flows. Only the controller writes it, through
compare-and-set. The controller answers 400 unless every flow passes the tree compare-and-set. The controller answers 400 unless every flow passes the tree
check above and reads only what exists: a source event from `issues`, check above and reads only what exists: a source event from `issues`,
`issue_assign`, `pull_request`, `pull_request_assign`, `pull_request_sync` or one of the `issue_assign`, `pull_request`, `pull_request_assign`, `pull_request_sync` or one of the
three `pull_request_review_*` events, `workflow_run_failure` or `workflow_run_cancelled`, and condition and recipient fields from the event's field list. The same goes for the `{field}` three `pull_request_review_*` events, `workflow_run_failure` or `workflow_run_cancelled`, and condition and recipient fields from the event's field list. The same goes for the `{{ field }}`
placeholders in a todo's key and text. A `PUT` must carry the `revision` it read, `0` for no variables in a todo's key and text, which are Jinja templates (see [`hive-template`](../../../hive-template/README.md)). A `PUT` must carry the `revision` it read, `0` for no
document, and gets 409 when the stored revision is another. Swarm UI's document, and gets 409 when the stored revision is another. Swarm UI's
[notification router card on `/swarm`](../../2-handbook/swarm/ui.md#notification-flows) shows the document and [notification router card on `/swarm`](../../2-handbook/swarm/ui.md#notification-flows) shows the document and
switches built-ins. switches built-ins.
@ -112,7 +112,7 @@ accepts:
- `source_events`: `[{name, description}]`, the source events. - `source_events`: `[{name, description}]`, the source events.
- `fields`: `[{name, description, values, values_from?, closed}]`, the condition - `fields`: `[{name, description, values, values_from?, closed}]`, the condition
and recipient fields, and the `{field}` placeholders. `values` lists what the and recipient fields, and the `{{ field }}` template variables. `values` lists what the
field holds, as far as the controller knows, as `{name, description}` pairs, such as the `issue` and field holds, as far as the controller knows, as `{name, description}` pairs, such as the `issue` and
`PR` of `kind`. `PR` of `kind`.
- `node_kinds`: `[{kind, description, fields}]` for `source`, `match`, `todo` - `node_kinds`: `[{kind, description, fields}]` for `source`, `match`, `todo`
@ -197,10 +197,10 @@ anything else, such as a push to a branch with no PR, names no PR and the
router ignores it. The `author` field is the account that opened the issue or router ignores it. The `author` field is the account that opened the issue or
PR, so a flow can send to it: PR, so a flow can send to it:
| node | setting | | node | setting |
| ------ | ------------------------------------------------------------- | | ------ | --------------------------------------------------------------------- |
| source | `workflow_run_failure` | | source | `workflow_run_failure` |
| todo | `to` = field `author`, key `forge:{repo}#{number}:ci`, upsert | | todo | `to` = field `author`, key `forge:{{ repo }}#{{ number }}:ci`, upsert |
A clear of one key, such as `ci-cleared`'s, leaves the agent in the issue's A clear of one key, such as `ci-cleared`'s, leaves the agent in the issue's
holders, because it may still hold the assignment todo that `closed` must find. holders, because it may still hold the assignment todo that `closed` must find.
@ -225,7 +225,10 @@ leaves it out, and runs the rest.
| a document it can't read | the flows it ran before | | a document it can't read | the flows it ran before |
| a read that fails | the flows it ran before | | a read that fails | the flows it ran before |
"Can't read" means not JSON, not a flows document, or a `version` other than 1. "Can't read" means not JSON, not a flows document, or a `version` other than 1 or 2.
A version 1 document, whose todo keys and texts used `{field}` placeholders, reads
as version 2 with each one converted to `{{ field }}`; the next save writes version 2,
and a router that predates version 2 keeps its flows until you upgrade it.
Before the router has read any document, "before" means the built-ins, so a bad Before the router has read any document, "before" means the built-ins, so a bad
document never leaves it with no flows. The controller refuses to overwrite a document never leaves it with no flows. The controller refuses to overwrite a
document of a version it doesn't know. document of a version it doesn't know.

18
hive-template/Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "hive-template"
edition.workspace = true
version.workspace = true
readme = "README.md"
[lints]
workspace = true
[dependencies]
minijinja = { version = "2", default-features = false, features = [
"builtins",
"serde",
] }
serde.workspace = true
[dev-dependencies]
serde_json.workspace = true

14
hive-template/README.md Normal file
View file

@ -0,0 +1,14 @@
# hive-template
The one template language the hyperhive workspace renders with: Jinja, through
[`minijinja`](https://crates.io/crates/minijinja). A value is `{{ name }}`, a
block is `{% if cond %}...{% endif %}`, and a lone `{` or `}` stays plain text.
- **`render(template, &context)`**: the text, or a message when the template
does not parse or names a variable the context lacks. An unknown variable is
an error, never an empty string.
- **`variables(template)`**: the variables the template reads, so a caller can
refuse an unknown field when the template is saved instead of when it renders.
Used by the notification router's flow templates (`swarm-notify-router`,
`swarm-wire`).

89
hive-template/src/lib.rs Normal file
View file

@ -0,0 +1,89 @@
//! The one template language the workspace renders with: Jinja, through
//! `minijinja`, with an unknown variable an error rather than an empty string.
//!
//! A template is `{{ name }}` for a value and `{% if cond %}...{% endif %}` for
//! a block; a lone `{` or `}` is plain text. Callers pass their context as any
//! `Serialize` value and get back the text or a message naming what is wrong.
use std::collections::BTreeSet;
use minijinja::{Environment, UndefinedBehavior};
use serde::Serialize;
/// An environment that refuses a name the context does not hold, and keeps the
/// template's trailing newline.
fn environment() -> Environment<'static> {
let mut env = Environment::new();
env.set_undefined_behavior(UndefinedBehavior::Strict);
env.set_keep_trailing_newline(true);
env
}
/// `template` rendered against `context`.
///
/// # Errors
///
/// The template does not parse, or it names a variable `context` does not hold.
pub fn render(template: &str, context: &impl Serialize) -> Result<String, String> {
environment()
.render_str(template, context)
.map_err(|e| e.to_string())
}
/// The variables `template` reads that it does not define itself, such as `repo`
/// for `{{ repo }}`. Lets a caller refuse a template that names an unknown field
/// before any event renders it.
///
/// # Errors
///
/// The template does not parse.
pub fn variables(template: &str) -> Result<BTreeSet<String>, String> {
let env = environment();
let parsed = env.template_from_str(template).map_err(|e| e.to_string())?;
Ok(parsed.undeclared_variables(false).into_iter().collect())
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn a_value_and_a_block_render() {
let ctx = json!({ "repo": "o/r", "role": "agent" });
let out = render(
"{{ repo }}{% if role == \"agent\" %} (agent){% endif %}\n",
&ctx,
);
assert_eq!(out.as_deref(), Ok("o/r (agent)\n"));
}
#[test]
fn single_braces_are_plain_text() {
assert_eq!(
render("{not a name} and { open", &json!({})).as_deref(),
Ok("{not a name} and { open")
);
}
#[test]
fn an_unknown_variable_is_an_error() {
assert!(render("{{ numbr }}", &json!({ "number": "7" })).is_err());
}
#[test]
fn variables_lists_what_the_template_reads_not_what_it_defines() {
let found = variables("{{ repo }}#{{ number }}{% set x = 1 %}{{ x }}").expect("parses");
assert_eq!(
found.into_iter().collect::<Vec<_>>(),
["number", "repo"].map(String::from)
);
}
#[test]
fn a_template_that_does_not_parse_is_refused() {
assert!(variables("{{ repo ").is_err());
assert!(render("{% if %}", &json!({})).is_err());
}
}

View file

@ -375,24 +375,24 @@ mod tests {
store store
.put( .put(
notify_flows::KEY, notify_flows::KEY,
br#"{"version":2,"flows":[]}"#.to_vec().into(), br#"{"version":3,"flows":[]}"#.to_vec().into(),
) )
.await .await
.expect("seed"); .expect("seed");
let got = get_flows(State(state.clone())) let got = get_flows(State(state.clone()))
.await .await
.expect_err("version 2"); .expect_err("version 3");
assert_eq!(got.status, Some(StatusCode::CONFLICT), "{got:?}"); assert_eq!(got.status, Some(StatusCode::CONFLICT), "{got:?}");
let refused = put_flows(State(state.clone()), Ok(body(0, &[], vec![]))) let refused = put_flows(State(state.clone()), Ok(body(0, &[], vec![])))
.await .await
.expect_err("version 2 is kept"); .expect_err("version 3 is kept");
assert_eq!(refused.status, Some(StatusCode::CONFLICT), "{refused:?}"); assert_eq!(refused.status, Some(StatusCode::CONFLICT), "{refused:?}");
let kept = store let kept = store
.get(notify_flows::KEY) .get(notify_flows::KEY)
.await .await
.expect("read") .expect("read")
.expect("stored"); .expect("stored");
assert_eq!(&kept[..], br#"{"version":2,"flows":[]}"#); assert_eq!(&kept[..], br#"{"version":3,"flows":[]}"#);
// Control: garbage that names no version is replaced by a save. // Control: garbage that names no version is replaced by a save.
let garbage = store let garbage = store
@ -454,11 +454,11 @@ mod tests {
let mut typo = custom("typo"); let mut typo = custom("typo");
if let NodeKind::Todo(sink) = &mut typo.nodes[1].kind { if let NodeKind::Todo(sink) = &mut typo.nodes[1].kind {
sink.key = "forge:{repo}#{numbr}:triage".to_owned(); sink.key = "forge:{{ repo }}#{{ numbr }}:triage".to_owned();
} }
let refused = put_flows(State(state.clone()), Ok(body(0, &[], vec![typo]))) let refused = put_flows(State(state.clone()), Ok(body(0, &[], vec![typo])))
.await .await
.expect_err("{numbr} is no field"); .expect_err("numbr is no field");
assert_eq!(refused.status, Some(StatusCode::BAD_REQUEST), "{refused:?}"); assert_eq!(refused.status, Some(StatusCode::BAD_REQUEST), "{refused:?}");
// Control: the same flow spelled right is stored. // Control: the same flow spelled right is stored.
let _ = put_flows(State(state), Ok(body(0, &[], vec![custom("typo")]))) let _ = put_flows(State(state), Ok(body(0, &[], vec![custom("typo")])))

View file

@ -377,11 +377,11 @@ mod tests {
store store
.put( .put(
notify_flows::KEY, notify_flows::KEY,
br#"{"version":2,"flows":[]}"#.to_vec().into(), br#"{"version":3,"flows":[]}"#.to_vec().into(),
) )
.await .await
.expect("seed"); .expect("seed");
let unreadable = get_dry_run(State(state)).await.expect_err("version 2"); let unreadable = get_dry_run(State(state)).await.expect_err("version 3");
assert_eq!(unreadable.status, Some(StatusCode::CONFLICT)); assert_eq!(unreadable.status, Some(StatusCode::CONFLICT));
} }

View file

@ -15,6 +15,8 @@ async-nats = { workspace = true, features = ["kv"] }
# StreamExt / TryStreamExt on the pulled batch and the subject listing. # StreamExt / TryStreamExt on the pulled batch and the subject listing.
futures-util.workspace = true futures-util.workspace = true
hive-log.workspace = true hive-log.workspace = true
# The flow templates a todo node renders.
hive-template.workspace = true
# The metrics the router exports (`metrics`), pushed through the OTLP exporter # The metrics the router exports (`metrics`), pushed through the OTLP exporter
# `swarm-secret-client`'s `meter_provider` builds. # `swarm-secret-client`'s `meter_provider` builds.
opentelemetry.workspace = true opentelemetry.workspace = true

View file

@ -175,28 +175,20 @@ fn holds(condition: &Condition, fields: &Fields) -> bool {
} }
} }
/// `template` with each `{field}` replaced by that field's value, a list /// `template`, a Jinja template, rendered over the event's fields; a list field
/// joined with `, `. A name that is no field is an error naming it. /// reads as its items joined with `, `. A name that is no field is an error.
pub fn expand(template: &str, fields: &Fields) -> Result<String, String> { pub fn expand(template: &str, fields: &Fields) -> Result<String, String> {
let mut out = String::with_capacity(template.len()); let context: BTreeMap<&str, String> = fields
let mut rest = template; .iter()
while let Some(open) = rest.find('{') { .map(|(name, value)| {
out.push_str(&rest[..open]); let text = match value {
let after = &rest[open + 1..]; Value::Text(v) => v.clone(),
let Some(close) = after.find('}') else { Value::List(items) => items.join(", "),
out.push_str(&rest[open..]); };
return Ok(out); (*name, text)
}; })
let name = &after[..close]; .collect();
match fields.get(name) { hive_template::render(template, &context)
Some(Value::Text(v)) => out.push_str(v),
Some(Value::List(items)) => out.push_str(&items.join(", ")),
None => return Err(format!("{{{name}}} is no event field")),
}
rest = &after[close + 1..];
}
out.push_str(rest);
Ok(out)
} }
#[cfg(test)] #[cfg(test)]
@ -387,9 +379,9 @@ mod tests {
f.nodes[2].kind = NodeKind::Todo(TodoSink { f.nodes[2].kind = NodeKind::Todo(TodoSink {
to: Recipients::Agent("triage".to_owned()), to: Recipients::Agent("triage".to_owned()),
op: Op::Upsert, op: Op::Upsert,
key: "forge:{repo}#{number}:triage".to_owned(), key: "forge:{{ repo }}#{{ number }}:triage".to_owned(),
prefix: false, prefix: false,
text: "triage {repo}#{number}".to_owned(), text: "triage {{ repo }}#{{ number }}".to_owned(),
}); });
let got = evaluate( let got = evaluate(
&f, &f,
@ -502,13 +494,10 @@ mod tests {
fn a_placeholder_expands_to_its_field_and_an_unknown_one_is_an_error() { fn a_placeholder_expands_to_its_field_and_an_unknown_one_is_an_error() {
let fields = event("assigned", &["a", "b"], &[], &[]); let fields = event("assigned", &["a", "b"], &[], &[]);
assert_eq!( assert_eq!(
expand("{repo}#{number} {assignees_added} {", &fields), expand("{{ repo }}#{{ number }} {{ assignees_added }} {", &fields),
Ok("hyperhive/hyperhive#7 a, b {".to_owned()) Ok("hyperhive/hyperhive#7 a, b {".to_owned())
); );
assert_eq!( assert!(expand("x {{ nope }}", &fields).is_err());
expand("x {nope}", &fields),
Err("{nope} is no event field".to_owned())
);
} }
/// A sink naming a placeholder the event lacks sends nothing, rather /// A sink naming a placeholder the event lacks sends nothing, rather
@ -519,7 +508,7 @@ mod tests {
f.nodes[2].kind = NodeKind::Todo(TodoSink { f.nodes[2].kind = NodeKind::Todo(TodoSink {
to: Recipients::Agent("triage".to_owned()), to: Recipients::Agent("triage".to_owned()),
op: Op::Upsert, op: Op::Upsert,
key: "forge:{repo}#{numbr}:triage".to_owned(), key: "forge:{{ repo }}#{{ numbr }}:triage".to_owned(),
prefix: false, prefix: false,
text: "t".to_owned(), text: "t".to_owned(),
}); });
@ -527,7 +516,7 @@ mod tests {
assert!(evaluate(&f, "issue_assign", &fields).is_empty()); assert!(evaluate(&f, "issue_assign", &fields).is_empty());
// Control: spelled right, the same flow emits. // Control: spelled right, the same flow emits.
if let NodeKind::Todo(s) = &mut f.nodes[2].kind { if let NodeKind::Todo(s) = &mut f.nodes[2].kind {
s.key = "forge:{repo}#{number}:triage".to_owned(); s.key = "forge:{{ repo }}#{{ number }}:triage".to_owned();
} }
assert_eq!(evaluate(&f, "issue_assign", &fields).len(), 1); assert_eq!(evaluate(&f, "issue_assign", &fields).len(), 1);
} }

View file

@ -236,7 +236,7 @@ mod tests {
); );
for bad in [ for bad in [
junk(2, b"not json"), junk(2, b"not json"),
junk(3, br#"{"version":2,"flows":[]}"#), junk(3, br#"{"version":3,"flows":[]}"#),
junk(4, br#"{"flows":[{"id":"x"}]}"#), junk(4, br#"{"flows":[{"id":"x"}]}"#),
Read::Failed("timeout".to_owned()), Read::Failed("timeout".to_owned()),
] { ] {

View file

@ -9,6 +9,7 @@ edition.workspace = true
schema = ["dep:utoipa"] schema = ["dep:utoipa"]
[dependencies] [dependencies]
hive-template.workspace = true
hive-types.workspace = true hive-types.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true

View file

@ -27,8 +27,14 @@ pub const BUCKET: &str = "notify-flows";
/// The one key in [`BUCKET`]. /// The one key in [`BUCKET`].
pub const KEY: &str = "all"; pub const KEY: &str = "all";
/// The document layout this build reads and writes. /// The document layout this build reads and writes. Version 1 wrote a todo's
pub const VERSION: u32 = 1; /// 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. /// The most nodes one flow may have.
pub const MAX_NODES: usize = 64; pub const MAX_NODES: usize = 64;
@ -85,7 +91,7 @@ pub const SOURCE_EVENTS: &[(&str, &str)] = &[
]; ];
/// The fields of an event a condition or a recipient can name, with what each /// 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. /// holds. A todo's key and text read them as `{{ field }}` in a template.
pub const FIELDS: &[(&str, &str)] = &[ pub const FIELDS: &[(&str, &str)] = &[
("event", "The event type, one of the source events"), ("event", "The event type, one of the source events"),
("action", "What happened, such as opened, closed, assigned"), ("action", "What happened, such as opened, closed, assigned"),
@ -237,12 +243,12 @@ pub struct TodoSink {
pub to: Recipients, pub to: Recipients,
#[cfg_attr(feature = "schema", schema(value_type = String))] #[cfg_attr(feature = "schema", schema(value_type = String))]
pub op: Op, pub op: Op,
/// The todo key, with `{field}` placeholders. /// The todo key, a template over the event's [`FIELDS`].
pub key: String, pub key: String,
/// For a clear: `key` is a prefix. /// For a clear: `key` is a prefix.
#[serde(default)] #[serde(default)]
pub prefix: bool, pub prefix: bool,
/// The todo text, with `{field}` placeholders. Empty for a clear. /// The todo text, a template over the event's [`FIELDS`]. Empty for a clear.
#[serde(default)] #[serde(default)]
pub text: String, pub text: String,
} }
@ -261,7 +267,7 @@ pub enum Recipients {
/// Why [`Document::decode`] could not read a document. /// Why [`Document::decode`] could not read a document.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum DecodeError { pub enum DecodeError {
/// A version other than [`VERSION`], such as a newer build's. /// A version this build does not read, such as a newer build's.
UnknownVersion(String), UnknownVersion(String),
/// Not JSON, or not the shape of a [`Document`]. /// Not JSON, or not the shape of a [`Document`].
Malformed(String), Malformed(String),
@ -271,7 +277,10 @@ impl std::fmt::Display for DecodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::UnknownVersion(v) => { Self::UnknownVersion(v) => {
write!(f, "version {v}, and this build reads version {VERSION}") write!(
f,
"version {v}, and this build reads versions {LEGACY_VERSION} and {VERSION}"
)
} }
Self::Malformed(why) => f.write_str(why), Self::Malformed(why) => f.write_str(why),
} }
@ -284,14 +293,18 @@ impl Document {
/// # Errors /// # Errors
/// ///
/// Not the expected shape, or a [`Document::version`] this build does not /// Not the expected shape, or a [`Document::version`] this build does not
/// know. The reader keeps what it had. /// know. The reader keeps what it had. A version 1 document is
/// converted to [`VERSION`].
pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> { pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> {
let value: serde_json::Value = serde_json::from_slice(bytes) let mut value: serde_json::Value = serde_json::from_slice(bytes)
.map_err(|e| DecodeError::Malformed(format!("not JSON: {e}")))?; .map_err(|e| DecodeError::Malformed(format!("not JSON: {e}")))?;
if let Some(found) = value.get("version") if let Some(found) = value.get("version")
&& found != VERSION && found != VERSION
{ {
return Err(DecodeError::UnknownVersion(found.to_string())); if found != LEGACY_VERSION {
return Err(DecodeError::UnknownVersion(found.to_string()));
}
migrate_legacy(&mut value);
} }
serde_json::from_value(value) serde_json::from_value(value)
.map_err(|e| DecodeError::Malformed(format!("not a flows document: {e}"))) .map_err(|e| DecodeError::Malformed(format!("not a flows document: {e}")))
@ -573,11 +586,11 @@ pub fn schema() -> Schema {
fields: vec![ fields: vec![
to, to,
op, op,
kind_field("key", "The todo key, with {field} placeholders", true), kind_field("key", "The todo key, a template: {{ field }}", true),
kind_field("prefix", "For a clear: key is a prefix", false), kind_field("prefix", "For a clear: key is a prefix", false),
kind_field( kind_field(
"text", "text",
"The todo text, with {field} placeholders; required for an upsert, empty for a clear", "The todo text, a template: {{ field }}; required for an upsert, empty for a clear",
false, false,
), ),
], ],
@ -596,19 +609,44 @@ pub fn schema() -> Schema {
} }
} }
/// The `{name}` placeholders in `template`, in order. A `{` with no `}` after /// A version 1 document with its todo templates rewritten as version 2 ones and
/// it is plain text. /// its version set to [`VERSION`].
#[must_use] fn migrate_legacy(document: &mut serde_json::Value) {
pub fn placeholders(template: &str) -> Vec<&str> { document["version"] = VERSION.into();
let mut names = Vec::new(); 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; let mut rest = template;
while let Some(open) = rest.find('{') { while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..]; let after = &rest[open + 1..];
let Some(close) = after.find('}') else { break }; let Some(close) = after.find('}') else {
names.push(&after[..close]); out.push_str("{{ \"{\" }}");
rest = after;
continue;
};
out.push_str("{{ ");
out.push_str(&after[..close]);
out.push_str(" }}");
rest = &after[close + 1..]; rest = &after[close + 1..];
} }
names out.push_str(rest);
out
} }
impl Flow { impl Flow {
@ -769,8 +807,10 @@ impl TodoSink {
return Err(format!("text is longer than {MAX_TEXT} bytes")); return Err(format!("text is longer than {MAX_TEXT} bytes"));
} }
for (what, template) in [("key", &self.key), ("text", &self.text)] { for (what, template) in [("key", &self.key), ("text", &self.text)] {
if let Some(bad) = placeholders(template).into_iter().find(|n| !is_field(n)) { let read = hive_template::variables(template)
return Err(format!("{what} has {{{bad}}}, which is no event field")); .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 { match self.op {
@ -800,7 +840,7 @@ impl TodoSink {
/// replaces the old one. /// replaces the old one.
#[must_use] #[must_use]
pub fn builtins() -> Vec<Flow> { pub fn builtins() -> Vec<Flow> {
let assignment_key = "forge:{repo}#{number}:assignment"; let assignment_key = "forge:{{ repo }}#{{ number }}:assignment";
let mut flows = vec![ let mut flows = vec![
chain( chain(
"assigned", "assigned",
@ -811,7 +851,7 @@ pub fn builtins() -> Vec<Flow> {
op: Op::Upsert, op: Op::Upsert,
key: assignment_key.to_owned(), key: assignment_key.to_owned(),
prefix: false, prefix: false,
text: "you are assigned to {kind} {repo}#{number}: {title} ({url})".to_owned(), text: "you are assigned to {{ kind }} {{ repo }}#{{ number }}: {{ title }} ({{ url }})".to_owned(),
}, },
), ),
chain( chain(
@ -823,7 +863,7 @@ pub fn builtins() -> Vec<Flow> {
op: Op::Upsert, op: Op::Upsert,
key: assignment_key.to_owned(), key: assignment_key.to_owned(),
prefix: false, prefix: false,
text: "you are not assigned to {kind} {repo}#{number} anymore: {title} ({url})" text: "you are not assigned to {{ kind }} {{ repo }}#{{ number }} anymore: {{ title }} ({{ url }})"
.to_owned(), .to_owned(),
}, },
), ),
@ -837,7 +877,7 @@ pub fn builtins() -> Vec<Flow> {
/// The flows that tell a PR's author about its CI: the todo for a failed run, /// 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. /// the same key rewritten for a cancelled one, and the clear a push makes.
fn ci_flows() -> Vec<Flow> { fn ci_flows() -> Vec<Flow> {
let ci_key = "forge:{repo}#{number}:ci"; let ci_key = "forge:{{ repo }}#{{ number }}:ci";
let to_author = |op, text: &str| TodoSink { let to_author = |op, text: &str| TodoSink {
to: Recipients::Field("author".to_owned()), to: Recipients::Field("author".to_owned()),
op, op,
@ -852,7 +892,7 @@ fn ci_flows() -> Vec<Flow> {
"failure", "failure",
to_author( to_author(
Op::Upsert, Op::Upsert,
"CI failed on {kind} {repo}#{number}: {title} ({url})", "CI failed on {{ kind }} {{ repo }}#{{ number }}: {{ title }} ({{ url }})",
), ),
), ),
chain( chain(
@ -861,7 +901,7 @@ fn ci_flows() -> Vec<Flow> {
"cancelled", "cancelled",
to_author( to_author(
Op::Upsert, Op::Upsert,
"CI was cancelled on {kind} {repo}#{number}: {title} ({url})", "CI was cancelled on {{ kind }} {{ repo }}#{{ number }}: {{ title }} ({{ url }})",
), ),
), ),
chain( chain(
@ -882,7 +922,7 @@ fn closed() -> Flow {
TodoSink { TodoSink {
to: Recipients::Field("todo_holders".to_owned()), to: Recipients::Field("todo_holders".to_owned()),
op: Op::Clear, op: Op::Clear,
key: "forge:{repo}#{number}:".to_owned(), key: "forge:{{ repo }}#{{ number }}:".to_owned(),
prefix: true, prefix: true,
text: String::new(), text: String::new(),
}, },
@ -892,9 +932,10 @@ fn closed() -> Flow {
kind: NodeKind::Todo(TodoSink { kind: NodeKind::Todo(TodoSink {
to: Recipients::Field("todo_holders".to_owned()), to: Recipients::Field("todo_holders".to_owned()),
op: Op::Upsert, op: Op::Upsert,
key: "forge:{repo}#{number}:closed".to_owned(), key: "forge:{{ repo }}#{{ number }}:closed".to_owned(),
prefix: false, prefix: false,
text: "{kind} {repo}#{number} was closed: {title} ({url})".to_owned(), text: "{{ kind }} {{ repo }}#{{ number }} was closed: {{ title }} ({{ url }})"
.to_owned(),
}), }),
}); });
flow.edges.push(Edge { flow.edges.push(Edge {
@ -1022,9 +1063,9 @@ mod tests {
f.nodes[2].kind = NodeKind::Todo(TodoSink { f.nodes[2].kind = NodeKind::Todo(TodoSink {
to: Recipients::Agent("triage".to_owned()), to: Recipients::Agent("triage".to_owned()),
op: Op::Upsert, op: Op::Upsert,
key: "forge:{repo}#{number}:triage".to_owned(), key: "forge:{{ repo }}#{{ number }}:triage".to_owned(),
prefix: false, prefix: false,
text: "triage {repo}#{number}".to_owned(), text: "triage {{ repo }}#{{ number }}".to_owned(),
}); });
f f
} }
@ -1371,19 +1412,47 @@ mod tests {
f.validate() f.validate()
}; };
assert_eq!( assert_eq!(
with("forge:{repo}#{number}:triage", "{title} {url}"), with(
"forge:{{ repo }}#{{ number }}:triage",
"{{ title }} {{ url }}"
),
Ok(()) Ok(())
); );
assert!( assert!(
with("forge:{repo}#{numbr}:triage", "t") with("forge:{{ repo }}#{{ numbr }}:triage", "t")
.unwrap_err() .unwrap_err()
.contains("numbr") .contains("numbr")
); );
assert!(with("k", "see {ulr}").unwrap_err().contains("ulr")); assert!(with("k", "see {{ ulr }}").unwrap_err().contains("ulr"));
assert!(with("k{}", "t").is_err(), "an empty name"); assert!(with("k{{ }}", "t").is_err(), "an empty name");
// An unclosed brace is text. assert!(with("k{{ repo", "t").is_err(), "an unclosed tag");
// A lone brace is text.
assert_eq!(with("k", "set {x"), Ok(())); assert_eq!(with("k", "set {x"), Ok(()));
assert_eq!(placeholders("a{b}c{d} {e"), ["b", "d"]); }
#[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] #[test]
@ -1434,7 +1503,7 @@ mod tests {
let many = (0..=MAX_FLOWS).map(|i| custom(&format!("f{i}"))).collect(); let many = (0..=MAX_FLOWS).map(|i| custom(&format!("f{i}"))).collect();
assert!(doc(&[], many).check().is_err(), "flow count"); assert!(doc(&[], many).check().is_err(), "flow count");
let mut old = doc(&[], vec![]); let mut old = doc(&[], vec![]);
old.version = 2; old.version = 3;
assert!(old.check().is_err(), "version"); assert!(old.check().is_err(), "version");
} }
@ -1492,8 +1561,8 @@ mod tests {
assert_eq!(Document::decode(&bytes), Ok(doc)); assert_eq!(Document::decode(&bytes), Ok(doc));
assert_eq!(Document::decode(b"{}"), Ok(Document::default())); assert_eq!(Document::decode(b"{}"), Ok(Document::default()));
assert_eq!( assert_eq!(
Document::decode(br#"{"version":2}"#), Document::decode(br#"{"version":3}"#),
Err(DecodeError::UnknownVersion("2".to_owned())) Err(DecodeError::UnknownVersion("3".to_owned()))
); );
assert!(Document::decode(b"nope").is_err()); assert!(Document::decode(b"nope").is_err());
assert!(Document::decode(br#"{"flows":[{"id":"x"}]}"#).is_err()); assert!(Document::decode(br#"{"flows":[{"id":"x"}]}"#).is_err());
@ -1510,7 +1579,7 @@ mod tests {
"kind": "todo", "kind": "todo",
"to": { "field": "todo_holders" }, "to": { "field": "todo_holders" },
"op": "clear", "op": "clear",
"key": "forge:{repo}#{number}:", "key": "forge:{{ repo }}#{{ number }}:",
"prefix": true, "prefix": true,
"text": "", "text": "",
}) })