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
15 changed files with 306 additions and 91 deletions
|
|
@ -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
27
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
18
hive-template/Cargo.toml
Normal 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
14
hive-template/README.md
Normal 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
89
hive-template/src/lib.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")])))
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
] {
|
] {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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": "",
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue