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

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": "",
})