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:
parent
11393bc538
commit
8ef3138863
1 changed files with 306 additions and 91 deletions
|
|
@ -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": "",
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue