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:
- **`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-host-sock/`** (hivectl ↔ c0re), **`hive-priv-sock/`** (c0re ↔
priv), **`hive-core-agent-sock/`** (harness ↔ c0re, `/run/hive/mcp.sock`)

27
Cargo.lock generated
View file

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

View file

@ -24,6 +24,7 @@ members = [
"hive-sh4re",
"hive-sock-client",
"hive-subagent-mcp",
"hive-template",
"hive-types",
"hivectl",
"swarm-agent-verify",
@ -115,6 +116,7 @@ hive-host-sock = { path = "hive-host-sock" }
hive-priv-sock = { path = "hive-priv-sock" }
hive-runtime = { path = "hive-runtime" }
hive-sock-client = { path = "hive-sock-client" }
hive-template = { path = "hive-template" }
hive-types = { path = "hive-types" }
swarm-agent-verify = { path = "swarm-agent-verify" }
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 **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
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
pull request. It ends a branch, like a todo, and the row of kinds above the
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
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
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
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 }}`
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
[notification router card on `/swarm`](../../2-handbook/swarm/ui.md#notification-flows) shows the document and
switches built-ins.
@ -112,7 +112,7 @@ accepts:
- `source_events`: `[{name, description}]`, the source events.
- `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
`PR` of `kind`.
- `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
PR, so a flow can send to it:
| node | setting |
| ------ | ------------------------------------------------------------- |
| source | `workflow_run_failure` |
| todo | `to` = field `author`, key `forge:{repo}#{number}:ci`, upsert |
| node | setting |
| ------ | --------------------------------------------------------------------- |
| source | `workflow_run_failure` |
| 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
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 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
document never leaves it with no flows. The controller refuses to overwrite a
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
.put(
notify_flows::KEY,
br#"{"version":2,"flows":[]}"#.to_vec().into(),
br#"{"version":3,"flows":[]}"#.to_vec().into(),
)
.await
.expect("seed");
let got = get_flows(State(state.clone()))
.await
.expect_err("version 2");
.expect_err("version 3");
assert_eq!(got.status, Some(StatusCode::CONFLICT), "{got:?}");
let refused = put_flows(State(state.clone()), Ok(body(0, &[], vec![])))
.await
.expect_err("version 2 is kept");
.expect_err("version 3 is kept");
assert_eq!(refused.status, Some(StatusCode::CONFLICT), "{refused:?}");
let kept = store
.get(notify_flows::KEY)
.await
.expect("read")
.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.
let garbage = store
@ -454,11 +454,11 @@ mod tests {
let mut typo = custom("typo");
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])))
.await
.expect_err("{numbr} is no field");
.expect_err("numbr is no field");
assert_eq!(refused.status, Some(StatusCode::BAD_REQUEST), "{refused:?}");
// Control: the same flow spelled right is stored.
let _ = put_flows(State(state), Ok(body(0, &[], vec![custom("typo")])))

View file

@ -377,11 +377,11 @@ mod tests {
store
.put(
notify_flows::KEY,
br#"{"version":2,"flows":[]}"#.to_vec().into(),
br#"{"version":3,"flows":[]}"#.to_vec().into(),
)
.await
.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));
}

View file

@ -15,6 +15,8 @@ async-nats = { workspace = true, features = ["kv"] }
# StreamExt / TryStreamExt on the pulled batch and the subject listing.
futures-util.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
# `swarm-secret-client`'s `meter_provider` builds.
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
/// joined with `, `. A name that is no field is an error naming it.
/// `template`, a Jinja template, rendered over the event's fields; a list field
/// 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> {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
let Some(close) = after.find('}') else {
out.push_str(&rest[open..]);
return Ok(out);
};
let name = &after[..close];
match fields.get(name) {
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)
let context: BTreeMap<&str, String> = fields
.iter()
.map(|(name, value)| {
let text = match value {
Value::Text(v) => v.clone(),
Value::List(items) => items.join(", "),
};
(*name, text)
})
.collect();
hive_template::render(template, &context)
}
#[cfg(test)]
@ -387,9 +379,9 @@ mod tests {
f.nodes[2].kind = NodeKind::Todo(TodoSink {
to: Recipients::Agent("triage".to_owned()),
op: Op::Upsert,
key: "forge:{repo}#{number}:triage".to_owned(),
key: "forge:{{ repo }}#{{ number }}:triage".to_owned(),
prefix: false,
text: "triage {repo}#{number}".to_owned(),
text: "triage {{ repo }}#{{ number }}".to_owned(),
});
let got = evaluate(
&f,
@ -502,13 +494,10 @@ mod tests {
fn a_placeholder_expands_to_its_field_and_an_unknown_one_is_an_error() {
let fields = event("assigned", &["a", "b"], &[], &[]);
assert_eq!(
expand("{repo}#{number} {assignees_added} {", &fields),
expand("{{ repo }}#{{ number }} {{ assignees_added }} {", &fields),
Ok("hyperhive/hyperhive#7 a, b {".to_owned())
);
assert_eq!(
expand("x {nope}", &fields),
Err("{nope} is no event field".to_owned())
);
assert!(expand("x {{ nope }}", &fields).is_err());
}
/// A sink naming a placeholder the event lacks sends nothing, rather
@ -519,7 +508,7 @@ mod tests {
f.nodes[2].kind = NodeKind::Todo(TodoSink {
to: Recipients::Agent("triage".to_owned()),
op: Op::Upsert,
key: "forge:{repo}#{numbr}:triage".to_owned(),
key: "forge:{{ repo }}#{{ numbr }}:triage".to_owned(),
prefix: false,
text: "t".to_owned(),
});
@ -527,7 +516,7 @@ mod tests {
assert!(evaluate(&f, "issue_assign", &fields).is_empty());
// Control: spelled right, the same flow emits.
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);
}

View file

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

View file

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

View file

@ -27,8 +27,14 @@ 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 document layout this build reads and writes. Version 1 wrote a todo's
/// key and text with `{field}` placeholders; version 2 writes them as Jinja
/// templates (`{{ field }}`). [`Document::decode`] converts a version 1
/// document, and the next save writes version 2.
pub const VERSION: u32 = 2;
/// The layout [`Document::decode`] still reads and converts.
const LEGACY_VERSION: u32 = 1;
/// The most nodes one flow may have.
pub const MAX_NODES: usize = 64;
@ -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
/// 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)] = &[
("event", "The event type, one of the source events"),
("action", "What happened, such as opened, closed, assigned"),
@ -237,12 +243,12 @@ pub struct TodoSink {
pub to: Recipients,
#[cfg_attr(feature = "schema", schema(value_type = String))]
pub op: Op,
/// The todo key, with `{field}` placeholders.
/// The todo key, a template over the event's [`FIELDS`].
pub key: String,
/// For a clear: `key` is a prefix.
#[serde(default)]
pub prefix: bool,
/// The todo text, with `{field}` placeholders. Empty for a clear.
/// The todo text, a template over the event's [`FIELDS`]. Empty for a clear.
#[serde(default)]
pub text: String,
}
@ -261,7 +267,7 @@ pub enum Recipients {
/// 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.
/// A version this build does not read, such as a newer build's.
UnknownVersion(String),
/// Not JSON, or not the shape of a [`Document`].
Malformed(String),
@ -271,7 +277,10 @@ 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}")
write!(
f,
"version {v}, and this build reads versions {LEGACY_VERSION} and {VERSION}"
)
}
Self::Malformed(why) => f.write_str(why),
}
@ -284,14 +293,18 @@ impl Document {
/// # Errors
///
/// 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> {
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}")))?;
if let Some(found) = value.get("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)
.map_err(|e| DecodeError::Malformed(format!("not a flows document: {e}")))
@ -573,11 +586,11 @@ pub fn schema() -> Schema {
fields: vec![
to,
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(
"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,
),
],
@ -596,19 +609,44 @@ pub fn schema() -> Schema {
}
}
/// 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();
/// A version 1 document with its todo templates rewritten as version 2 ones and
/// its version set to [`VERSION`].
fn migrate_legacy(document: &mut serde_json::Value) {
document["version"] = VERSION.into();
let nodes = document["flows"]
.as_array_mut()
.into_iter()
.flatten()
.flat_map(|flow| flow["nodes"].as_array_mut().into_iter().flatten());
for node in nodes.filter(|n| n["kind"] == "todo") {
for field in ["key", "text"] {
if let Some(template) = node[field].as_str() {
node[field] = jinja_of_legacy(template).into();
}
}
}
}
/// A version 1 template as a Jinja one: each `{name}` becomes `{{ name }}`. A `{`
/// with no `}` after it was plain text, and is kept so by writing it as a string.
fn jinja_of_legacy(template: &str) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
let Some(close) = after.find('}') else { break };
names.push(&after[..close]);
let Some(close) = after.find('}') else {
out.push_str("{{ \"{\" }}");
rest = after;
continue;
};
out.push_str("{{ ");
out.push_str(&after[..close]);
out.push_str(" }}");
rest = &after[close + 1..];
}
names
out.push_str(rest);
out
}
impl Flow {
@ -769,8 +807,10 @@ impl TodoSink {
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"));
let read = hive_template::variables(template)
.map_err(|why| format!("{what} is not a valid template: {why}"))?;
if let Some(bad) = read.iter().find(|n| !is_field(n)) {
return Err(format!("{what} reads {bad}, which is no event field"));
}
}
match self.op {
@ -800,7 +840,7 @@ impl TodoSink {
/// replaces the old one.
#[must_use]
pub fn builtins() -> Vec<Flow> {
let assignment_key = "forge:{repo}#{number}:assignment";
let assignment_key = "forge:{{ repo }}#{{ number }}:assignment";
let mut flows = vec![
chain(
"assigned",
@ -811,7 +851,7 @@ pub fn builtins() -> Vec<Flow> {
op: Op::Upsert,
key: assignment_key.to_owned(),
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(
@ -823,7 +863,7 @@ pub fn builtins() -> Vec<Flow> {
op: Op::Upsert,
key: assignment_key.to_owned(),
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(),
},
),
@ -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 same key rewritten for a cancelled one, and the clear a push makes.
fn ci_flows() -> Vec<Flow> {
let ci_key = "forge:{repo}#{number}:ci";
let ci_key = "forge:{{ repo }}#{{ number }}:ci";
let to_author = |op, text: &str| TodoSink {
to: Recipients::Field("author".to_owned()),
op,
@ -852,7 +892,7 @@ fn ci_flows() -> Vec<Flow> {
"failure",
to_author(
Op::Upsert,
"CI failed on {kind} {repo}#{number}: {title} ({url})",
"CI failed on {{ kind }} {{ repo }}#{{ number }}: {{ title }} ({{ url }})",
),
),
chain(
@ -861,7 +901,7 @@ fn ci_flows() -> Vec<Flow> {
"cancelled",
to_author(
Op::Upsert,
"CI was cancelled on {kind} {repo}#{number}: {title} ({url})",
"CI was cancelled on {{ kind }} {{ repo }}#{{ number }}: {{ title }} ({{ url }})",
),
),
chain(
@ -882,7 +922,7 @@ fn closed() -> Flow {
TodoSink {
to: Recipients::Field("todo_holders".to_owned()),
op: Op::Clear,
key: "forge:{repo}#{number}:".to_owned(),
key: "forge:{{ repo }}#{{ number }}:".to_owned(),
prefix: true,
text: String::new(),
},
@ -892,9 +932,10 @@ fn closed() -> Flow {
kind: NodeKind::Todo(TodoSink {
to: Recipients::Field("todo_holders".to_owned()),
op: Op::Upsert,
key: "forge:{repo}#{number}:closed".to_owned(),
key: "forge:{{ repo }}#{{ number }}:closed".to_owned(),
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 {
@ -1022,9 +1063,9 @@ mod tests {
f.nodes[2].kind = NodeKind::Todo(TodoSink {
to: Recipients::Agent("triage".to_owned()),
op: Op::Upsert,
key: "forge:{repo}#{number}:triage".to_owned(),
key: "forge:{{ repo }}#{{ number }}:triage".to_owned(),
prefix: false,
text: "triage {repo}#{number}".to_owned(),
text: "triage {{ repo }}#{{ number }}".to_owned(),
});
f
}
@ -1371,19 +1412,47 @@ mod tests {
f.validate()
};
assert_eq!(
with("forge:{repo}#{number}:triage", "{title} {url}"),
with(
"forge:{{ repo }}#{{ number }}:triage",
"{{ title }} {{ url }}"
),
Ok(())
);
assert!(
with("forge:{repo}#{numbr}:triage", "t")
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!(with("k", "see {{ ulr }}").unwrap_err().contains("ulr"));
assert!(with("k{{ }}", "t").is_err(), "an empty name");
assert!(with("k{{ repo", "t").is_err(), "an unclosed tag");
// A lone brace is text.
assert_eq!(with("k", "set {x"), Ok(()));
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]
@ -1434,7 +1503,7 @@ mod tests {
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;
old.version = 3;
assert!(old.check().is_err(), "version");
}
@ -1492,8 +1561,8 @@ mod tests {
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()))
Document::decode(br#"{"version":3}"#),
Err(DecodeError::UnknownVersion("3".to_owned()))
);
assert!(Document::decode(b"nope").is_err());
assert!(Document::decode(br#"{"flows":[{"id":"x"}]}"#).is_err());
@ -1510,7 +1579,7 @@ mod tests {
"kind": "todo",
"to": { "field": "todo_holders" },
"op": "clear",
"key": "forge:{repo}#{number}:",
"key": "forge:{{ repo }}#{{ number }}:",
"prefix": true,
"text": "",
})