ArchetypeMeta gets flows, checked when the controller stores the record: every todo must go to the new members recipient, which the router expands to the agents of the archetype. The operator's flows document refuses members. The router reads the archetype records and each roster agent's archetype key, and its NATS grant gains read on both buckets.
1805 lines
63 KiB
Rust
1805 lines
63 KiB
Rust
//! The notification router's flows: the stored document, the flow type both
|
|
//! the built-in and the custom flows are, and the checks a flow passes.
|
|
//!
|
|
//! A flow is a graph of nodes joined by edges, rooted at one `source` node.
|
|
//! A `match` node passes when every one of its conditions holds, and then
|
|
//! each of its children runs. A `todo` node is a leaf and emits one todo op
|
|
//! per recipient. A `request_reviewer` node is a leaf that asks the forge for
|
|
//! a review of the pull request the event concerns. Flows are trees: one
|
|
//! source, one parent for every other node, every node reachable from the
|
|
//! source ([`Flow::validate`]).
|
|
//!
|
|
//! The document under [`KEY`] in [`BUCKET`] holds the ids of the built-in
|
|
//! flows that are switched off and the custom flows. The built-ins
|
|
//! ([`builtins`]) are defined here, in code, so a build that adds one reaches
|
|
//! every swarm without a migration. swarm-controller is the only writer; the
|
|
//! router reads it.
|
|
|
|
use std::collections::BTreeSet;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::router_todo::Op;
|
|
|
|
/// The bucket holding the document. Only the controller writes it.
|
|
pub const BUCKET: &str = "notify-flows";
|
|
|
|
/// The one key in [`BUCKET`].
|
|
pub const KEY: &str = "all";
|
|
|
|
/// 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;
|
|
|
|
/// The deepest a node may sit below the source.
|
|
pub const MAX_DEPTH: usize = 16;
|
|
|
|
/// The most custom flows the document may hold.
|
|
pub const MAX_FLOWS: usize = 100;
|
|
|
|
/// The longest a flow's name, or a todo's key or text, may be.
|
|
pub const MAX_TEXT: usize = 500;
|
|
|
|
/// The forge event types a source node can start from, with what each means:
|
|
/// the ones swarm-controller's hooks deliver to the events stream.
|
|
pub const SOURCE_EVENTS: &[(&str, &str)] = &[
|
|
(
|
|
"issues",
|
|
"An issue opened, closed, edited or otherwise changed",
|
|
),
|
|
("issue_assign", "An issue assigned or unassigned"),
|
|
(
|
|
"pull_request",
|
|
"A pull request opened, closed, edited or otherwise changed",
|
|
),
|
|
(
|
|
"pull_request_assign",
|
|
"A pull request assigned or unassigned",
|
|
),
|
|
(
|
|
"pull_request_sync",
|
|
"A push to the branch of an open pull request",
|
|
),
|
|
(
|
|
"pull_request_review_approved",
|
|
"A review submitted on a pull request that approves it",
|
|
),
|
|
(
|
|
"pull_request_review_rejected",
|
|
"A review submitted on a pull request that asks for changes",
|
|
),
|
|
(
|
|
"pull_request_review_comment",
|
|
"A review submitted on a pull request that only comments",
|
|
),
|
|
(
|
|
"workflow_run_failure",
|
|
"A CI run on a pull request that finished with a failed job",
|
|
),
|
|
(
|
|
"workflow_run_cancelled",
|
|
"A CI run on a pull request that was cancelled",
|
|
),
|
|
(
|
|
"pull_request_mergeability",
|
|
"What swarm-controller found about an open pull request after a push to its target \
|
|
branch: action conflicting, or mergeable",
|
|
),
|
|
];
|
|
|
|
/// 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 }}` 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"),
|
|
("kind", "issue or pull request"),
|
|
("repo", "The repository, owner/name"),
|
|
("number", "The issue or pull request number"),
|
|
("title", "The issue or pull request title"),
|
|
("url", "The web link to the issue or pull request"),
|
|
("sender", "The account that caused the event"),
|
|
(
|
|
"author",
|
|
"The account that opened the issue or pull request",
|
|
),
|
|
(
|
|
"draft",
|
|
"true when the pull request is a draft (its title starts WIP: or [WIP]), else false",
|
|
),
|
|
(
|
|
"undrafted",
|
|
"true when the event is a title edit that took the pull request out of draft, else false",
|
|
),
|
|
("assignees", "The agents assigned after the event (a list)"),
|
|
("assignees_added", "The agents the event assigned (a list)"),
|
|
(
|
|
"assignees_removed",
|
|
"The agents the event unassigned (a list)",
|
|
),
|
|
(
|
|
"todo_holders",
|
|
"The agents holding a router todo for the issue or pull request (a list)",
|
|
),
|
|
(
|
|
"mergeability_changed",
|
|
"true when a mergeability event moves the pull request into or out of conflict, else false",
|
|
),
|
|
];
|
|
|
|
/// The forge account of the review classifier, whose review the built-in
|
|
/// `head-classifier` flow requests.
|
|
pub const CLASSIFIER_ACCOUNT: &str = "review-classifier";
|
|
|
|
/// The todo ops a `todo` node's `op` takes, one per [`Op`] variant.
|
|
const OPS: &[(&str, &str)] = &[
|
|
(
|
|
"upsert",
|
|
"Add the todo, or update the one under the same key",
|
|
),
|
|
(
|
|
"clear",
|
|
"Remove the todo under the key, or with prefix every one starting with it",
|
|
),
|
|
];
|
|
|
|
/// The value under [`KEY`].
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
pub struct Document {
|
|
/// [`VERSION`] when written. A reader that does not know the number
|
|
/// leaves the document alone.
|
|
#[serde(default = "version")]
|
|
pub version: u32,
|
|
/// The ids of the built-in flows that are switched off.
|
|
#[serde(default)]
|
|
pub disabled_builtins: Vec<String>,
|
|
/// The flows the operator defined.
|
|
#[serde(default)]
|
|
pub flows: Vec<Flow>,
|
|
}
|
|
|
|
fn version() -> u32 {
|
|
VERSION
|
|
}
|
|
|
|
impl Default for Document {
|
|
/// No built-in switched off and no custom flow, at [`VERSION`].
|
|
fn default() -> Self {
|
|
Self {
|
|
version: VERSION,
|
|
disabled_builtins: Vec::new(),
|
|
flows: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
pub struct Flow {
|
|
/// Unique among the built-in and custom flows. Names the flow in the
|
|
/// todo's source, `router:<id>`.
|
|
pub id: String,
|
|
pub name: String,
|
|
/// A flow switched off is kept and runs on no event. Built-ins ignore it
|
|
/// and are switched off through [`Document::disabled_builtins`].
|
|
#[serde(default = "on")]
|
|
pub enabled: bool,
|
|
pub nodes: Vec<Node>,
|
|
pub edges: Vec<Edge>,
|
|
}
|
|
|
|
fn on() -> bool {
|
|
true
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
pub struct Node {
|
|
/// Unique within its flow.
|
|
pub id: String,
|
|
#[serde(flatten)]
|
|
pub kind: NodeKind,
|
|
}
|
|
|
|
/// A directed edge, parent to child.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
pub struct Edge {
|
|
pub from: String,
|
|
pub to: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
pub enum NodeKind {
|
|
/// The root: the forge event types the flow starts from, from
|
|
/// [`SOURCE_EVENTS`].
|
|
Source { events: Vec<String> },
|
|
/// Passes when every condition holds.
|
|
Match { when: Vec<Condition> },
|
|
/// Emits a todo op to each recipient.
|
|
Todo(TodoSink),
|
|
/// Asks the forge for `reviewer`'s review of the pull request the event
|
|
/// concerns, unless their review of its head already stands. Does nothing
|
|
/// for an issue.
|
|
RequestReviewer {
|
|
/// A forge login.
|
|
reviewer: String,
|
|
},
|
|
}
|
|
|
|
/// `field` equals `value`. A list field holds when one of its items does.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
pub struct Condition {
|
|
/// One of [`FIELDS`].
|
|
pub field: String,
|
|
pub equals: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
pub struct TodoSink {
|
|
pub to: Recipients,
|
|
#[cfg_attr(feature = "schema", schema(value_type = String))]
|
|
pub op: Op,
|
|
/// 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, a template over the event's [`FIELDS`]. Empty for a clear.
|
|
#[serde(default)]
|
|
pub text: String,
|
|
}
|
|
|
|
/// Who a todo op goes to.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Recipients {
|
|
/// The agents the named event field holds, one of [`FIELDS`].
|
|
Field(String),
|
|
/// This agent, whatever the event.
|
|
Agent(String),
|
|
/// Every agent created from the archetype the flow came from. Only an
|
|
/// archetype's own flows take it: the operator's document has no
|
|
/// archetype, so [`Flow::validate`] refuses it there, and
|
|
/// [`Flow::validate_archetype`] refuses every other recipient.
|
|
Members,
|
|
}
|
|
|
|
/// Why [`Document::decode`] could not read a document.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum DecodeError {
|
|
/// 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),
|
|
}
|
|
|
|
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 versions {LEGACY_VERSION} and {VERSION}"
|
|
)
|
|
}
|
|
Self::Malformed(why) => f.write_str(why),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Document {
|
|
/// `bytes`, the stored document, or why it cannot be read.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Not the expected shape, or a [`Document::version`] this build does not
|
|
/// know. The reader keeps what it had. A version 1 document is
|
|
/// converted to [`VERSION`].
|
|
pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> {
|
|
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
|
|
{
|
|
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}")))
|
|
}
|
|
|
|
/// What is wrong with the document, if anything: the check a save passes.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// The first problem found, naming the flow it is in.
|
|
pub fn check(&self) -> Result<(), String> {
|
|
if self.version != VERSION {
|
|
return Err(format!("version {} is not {VERSION}", self.version));
|
|
}
|
|
if self.flows.len() > MAX_FLOWS {
|
|
return Err(format!("{} flows, more than {MAX_FLOWS}", self.flows.len()));
|
|
}
|
|
let builtin = builtins();
|
|
for id in &self.disabled_builtins {
|
|
if !builtin.iter().any(|b| b.id == *id) {
|
|
return Err(format!(
|
|
"disabled_builtins names {id}, which is no built-in flow"
|
|
));
|
|
}
|
|
}
|
|
let mut ids: BTreeSet<&str> = builtin.iter().map(|b| b.id.as_str()).collect();
|
|
for flow in &self.flows {
|
|
if !ids.insert(flow.id.as_str()) {
|
|
return Err(format!(
|
|
"flow id {} is used twice, or is a built-in flow's",
|
|
flow.id
|
|
));
|
|
}
|
|
flow.validate()
|
|
.map_err(|why| format!("flow {}: {why}", flow.id))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The flows to run: the built-ins that are not switched off, then the
|
|
/// enabled custom flows that pass [`Flow::validate`] with an id of their
|
|
/// own. With the reason for each custom flow left out.
|
|
#[must_use]
|
|
pub fn active(&self) -> (Vec<Flow>, Vec<String>) {
|
|
let mut flows: Vec<Flow> = builtins()
|
|
.into_iter()
|
|
.filter(|b| !self.disabled_builtins.contains(&b.id))
|
|
.collect();
|
|
let mut skipped = Vec::new();
|
|
for flow in self.flows.iter().filter(|f| f.enabled) {
|
|
if flows.iter().any(|f| f.id == flow.id) {
|
|
skipped.push(format!("flow {}: its id is already in use", flow.id));
|
|
} else if let Err(why) = flow.validate() {
|
|
skipped.push(format!("flow {}: {why}", flow.id));
|
|
} else {
|
|
flows.push(flow.clone());
|
|
}
|
|
}
|
|
(flows, skipped)
|
|
}
|
|
}
|
|
|
|
/// Whether `id` can name a flow or a node: it ends up in a message id and in
|
|
/// a todo's source.
|
|
fn is_id(id: &str) -> bool {
|
|
!id.is_empty()
|
|
&& id.len() <= 64
|
|
&& id
|
|
.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
|
}
|
|
|
|
/// Whether `login` can name a forge account: Forgejo allows letters, digits,
|
|
/// `-`, `_` and `.`, and no more than 40 of them.
|
|
fn is_login(login: &str) -> bool {
|
|
!login.is_empty()
|
|
&& login.len() <= 40
|
|
&& login
|
|
.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
|
|
}
|
|
|
|
fn is_field(name: &str) -> bool {
|
|
FIELDS.iter().any(|(n, _)| *n == name)
|
|
}
|
|
|
|
fn is_source_event(name: &str) -> bool {
|
|
SOURCE_EVENTS.iter().any(|(n, _)| *n == name)
|
|
}
|
|
|
|
/// A name with a one-line description.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
pub struct Described {
|
|
pub name: String,
|
|
pub description: String,
|
|
}
|
|
|
|
/// `values_from` marker: the names of the agents, as `GET /api/agents` lists
|
|
/// them.
|
|
pub const VALUES_FROM_AGENTS: &str = "agents";
|
|
|
|
/// `values_from` marker: the repositories with an open issue, as
|
|
/// `GET /api/repos` lists them. A repository with none is missing, so a field
|
|
/// that takes this marker is never closed.
|
|
pub const VALUES_FROM_REPOS: &str = "repos";
|
|
|
|
/// The `kind` field's values: what the router puts there, which is not the
|
|
/// words of the field's description.
|
|
const KINDS: &[(&str, &str)] = &[("issue", "An issue"), ("PR", "A pull request")];
|
|
|
|
/// Some of what the forge sends as `action`. Others exist, so it is a list of
|
|
/// suggestions only.
|
|
const ACTIONS: &[(&str, &str)] = &[
|
|
("opened", "Created"),
|
|
("closed", "Closed, or a pull request merged"),
|
|
("reopened", "Opened again after being closed"),
|
|
("edited", "Title or text changed"),
|
|
("assigned", "An assignee added"),
|
|
("unassigned", "An assignee removed"),
|
|
("synchronized", "New commits pushed to a pull request"),
|
|
(
|
|
"conflicting",
|
|
"A pull request does not merge into its target branch",
|
|
),
|
|
("mergeable", "A pull request merges into its target branch"),
|
|
];
|
|
|
|
/// One event field a condition or recipient can name, and what it can hold.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
pub struct EventField {
|
|
pub name: String,
|
|
pub description: String,
|
|
/// Values the field is known to hold; empty when none are listed.
|
|
pub values: Vec<Described>,
|
|
/// Where an editor asks for the values only known live: one of
|
|
/// [`VALUES_FROM_AGENTS`], [`VALUES_FROM_REPOS`]. Absent when there is
|
|
/// none.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub values_from: Option<String>,
|
|
/// Whether `values` or the source is every value the field can hold,
|
|
/// which an editor can show as a dropdown. When false they are
|
|
/// suggestions and the editor still takes any other text.
|
|
pub closed: bool,
|
|
}
|
|
|
|
/// One field of a node kind.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
pub struct KindField {
|
|
pub name: String,
|
|
pub description: String,
|
|
/// Whether a node of the kind must set it.
|
|
pub required: bool,
|
|
/// The accepted values when the field takes one of a fixed few; empty
|
|
/// otherwise.
|
|
pub values: Vec<Described>,
|
|
/// Where an editor asks for the values only known live, as
|
|
/// [`EventField::values_from`].
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub values_from: Option<String>,
|
|
/// Whether the values are every one the field accepts, as
|
|
/// [`EventField::closed`].
|
|
pub closed: bool,
|
|
}
|
|
|
|
/// One kind of node and the fields it takes besides `id` and `kind`.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
pub struct KindSchema {
|
|
pub kind: String,
|
|
pub description: String,
|
|
pub fields: Vec<KindField>,
|
|
}
|
|
|
|
/// The limits [`Document::check`] enforces.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
pub struct Limits {
|
|
pub max_nodes: usize,
|
|
pub max_depth: usize,
|
|
pub max_flows: usize,
|
|
}
|
|
|
|
/// What a flow editor needs to offer only what [`Document::check`] accepts,
|
|
/// built from the constants the check reads.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
|
pub struct Schema {
|
|
pub source_events: Vec<Described>,
|
|
pub fields: Vec<EventField>,
|
|
pub node_kinds: Vec<KindSchema>,
|
|
pub limits: Limits,
|
|
}
|
|
|
|
fn described(list: &[(&str, &str)]) -> Vec<Described> {
|
|
list.iter()
|
|
.map(|(name, description)| Described {
|
|
name: (*name).to_owned(),
|
|
description: (*description).to_owned(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn kind_field(name: &str, description: &str, required: bool) -> KindField {
|
|
KindField {
|
|
name: name.to_owned(),
|
|
description: description.to_owned(),
|
|
required,
|
|
values: Vec::new(),
|
|
values_from: None,
|
|
closed: false,
|
|
}
|
|
}
|
|
|
|
/// [`FIELDS`] with what each is known to hold.
|
|
fn event_fields() -> Vec<EventField> {
|
|
FIELDS
|
|
.iter()
|
|
.map(|(name, description)| {
|
|
let (values, values_from, closed) = match *name {
|
|
"event" => (described(SOURCE_EVENTS), None, true),
|
|
"kind" => (described(KINDS), None, true),
|
|
"action" => (described(ACTIONS), None, false),
|
|
"repo" => (Vec::new(), Some(VALUES_FROM_REPOS), false),
|
|
"assignees" | "assignees_added" | "assignees_removed" | "todo_holders" => {
|
|
(Vec::new(), Some(VALUES_FROM_AGENTS), false)
|
|
}
|
|
_ => (Vec::new(), None, false),
|
|
};
|
|
EventField {
|
|
name: (*name).to_owned(),
|
|
description: (*description).to_owned(),
|
|
values,
|
|
values_from: values_from.map(str::to_owned),
|
|
closed,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The schema of this build's flows.
|
|
#[must_use]
|
|
pub fn schema() -> Schema {
|
|
let mut op = kind_field("op", "What the todo does to the recipient's list", true);
|
|
op.values = described(OPS);
|
|
op.closed = true;
|
|
let mut to = kind_field(
|
|
"to",
|
|
"The recipients: {field: <event field>} or {agent: <agent name>}",
|
|
true,
|
|
);
|
|
to.values_from = Some(VALUES_FROM_AGENTS.to_owned());
|
|
to.closed = true;
|
|
let mut reviewer = kind_field("reviewer", "The forge login to request a review from", true);
|
|
// A login need not belong to an agent: the classifier's does not.
|
|
reviewer.values_from = Some(VALUES_FROM_AGENTS.to_owned());
|
|
Schema {
|
|
source_events: described(SOURCE_EVENTS),
|
|
fields: event_fields(),
|
|
node_kinds: vec![
|
|
KindSchema {
|
|
kind: "source".to_owned(),
|
|
description: "The root: the forge events the flow starts from".to_owned(),
|
|
fields: vec![kind_field(
|
|
"events",
|
|
"One or more of the source events",
|
|
true,
|
|
)],
|
|
},
|
|
KindSchema {
|
|
kind: "match".to_owned(),
|
|
description: "Passes when every condition holds; each child then runs".to_owned(),
|
|
fields: vec![kind_field(
|
|
"when",
|
|
"Conditions, each {field, equals}; a list field holds when one item equals",
|
|
true,
|
|
)],
|
|
},
|
|
KindSchema {
|
|
kind: "todo".to_owned(),
|
|
description: "A leaf: emits one todo op per recipient".to_owned(),
|
|
fields: vec![
|
|
to,
|
|
op,
|
|
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, a template: {{ field }}; required for an upsert, empty for a clear",
|
|
false,
|
|
),
|
|
],
|
|
},
|
|
KindSchema {
|
|
kind: "request_reviewer".to_owned(),
|
|
description: "A leaf: asks the forge for a review of the pull request the event concerns, unless the reviewer's review of its head already stands; does nothing for an issue".to_owned(),
|
|
fields: vec![reviewer],
|
|
},
|
|
],
|
|
limits: Limits {
|
|
max_nodes: MAX_NODES,
|
|
max_depth: MAX_DEPTH,
|
|
max_flows: MAX_FLOWS,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
out.push_str("{{ \"{\" }}");
|
|
rest = after;
|
|
continue;
|
|
};
|
|
out.push_str("{{ ");
|
|
out.push_str(&after[..close]);
|
|
out.push_str(" }}");
|
|
rest = &after[close + 1..];
|
|
}
|
|
out.push_str(rest);
|
|
out
|
|
}
|
|
|
|
impl Flow {
|
|
/// Check the flow is a tree within the caps, and that every node reads
|
|
/// only what exists. This is the check for the operator's document, which
|
|
/// has no archetype, so a [`Recipients::Members`] fails it.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Says why not.
|
|
pub fn validate(&self) -> Result<(), String> {
|
|
self.validate_in(Scope::Document)
|
|
}
|
|
|
|
/// The check for a flow an archetype carries: [`Flow::validate`], except
|
|
/// that every todo goes to [`Recipients::Members`] and no node asks for a
|
|
/// review. A flow that names an agent, or the agents an event field holds,
|
|
/// or asks for a review, belongs in the operator's document.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Says why not.
|
|
pub fn validate_archetype(&self) -> Result<(), String> {
|
|
self.validate_in(Scope::Archetype)
|
|
}
|
|
|
|
fn validate_in(&self, scope: Scope) -> Result<(), String> {
|
|
if !is_id(&self.id) {
|
|
return Err(format!(
|
|
"id {:?} is not 1 to 64 letters, digits, - or _",
|
|
self.id
|
|
));
|
|
}
|
|
if self.name.trim().is_empty() || self.name.len() > MAX_TEXT {
|
|
return Err(format!("name is empty or longer than {MAX_TEXT} bytes"));
|
|
}
|
|
if self.nodes.len() > MAX_NODES {
|
|
return Err(format!("{} nodes, more than {MAX_NODES}", self.nodes.len()));
|
|
}
|
|
let mut ids = BTreeSet::new();
|
|
for node in &self.nodes {
|
|
if !is_id(&node.id) {
|
|
return Err(format!(
|
|
"node id {:?} is not 1 to 64 letters, digits, - or _",
|
|
node.id
|
|
));
|
|
}
|
|
if !ids.insert(node.id.as_str()) {
|
|
return Err(format!("node id {} is used twice", node.id));
|
|
}
|
|
node.kind
|
|
.validate(scope)
|
|
.map_err(|why| format!("node {}: {why}", node.id))?;
|
|
}
|
|
for edge in &self.edges {
|
|
for end in [&edge.from, &edge.to] {
|
|
if !ids.contains(end.as_str()) {
|
|
return Err(format!("an edge names {end}, which is no node"));
|
|
}
|
|
}
|
|
}
|
|
let roots: Vec<&Node> = self
|
|
.nodes
|
|
.iter()
|
|
.filter(|n| matches!(n.kind, NodeKind::Source { .. }))
|
|
.collect();
|
|
let [root] = roots.as_slice() else {
|
|
return Err(format!("{} source nodes, not one", roots.len()));
|
|
};
|
|
for node in &self.nodes {
|
|
if matches!(
|
|
node.kind,
|
|
NodeKind::Todo(_) | NodeKind::RequestReviewer { .. }
|
|
) && !self.children(&node.id).is_empty()
|
|
{
|
|
return Err(format!("{} has children, and is a leaf", node.id));
|
|
}
|
|
}
|
|
// From the one root, every node reached exactly once: a node reached
|
|
// twice has a second parent or sits on a cycle, and one not reached is
|
|
// cut off.
|
|
let mut reached = 0;
|
|
let mut stack = vec![(root.id.as_str(), 0)];
|
|
while let Some((id, depth)) = stack.pop() {
|
|
if depth > MAX_DEPTH {
|
|
return Err(format!("node {id} is deeper than {MAX_DEPTH}"));
|
|
}
|
|
reached += 1;
|
|
if reached > self.nodes.len() {
|
|
return Err(
|
|
"a node is reached twice: it has two parents or sits on a cycle".to_owned(),
|
|
);
|
|
}
|
|
stack.extend(self.children(id).into_iter().map(|c| (c, depth + 1)));
|
|
}
|
|
if reached != self.nodes.len() {
|
|
return Err(format!(
|
|
"{} of {} nodes are not reachable from the source",
|
|
self.nodes.len() - reached,
|
|
self.nodes.len()
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The ids of the children of node `id`.
|
|
#[must_use]
|
|
pub fn children(&self, id: &str) -> Vec<&str> {
|
|
self.edges
|
|
.iter()
|
|
.filter(|e| e.from == id)
|
|
.map(|e| e.to.as_str())
|
|
.collect()
|
|
}
|
|
|
|
/// The node with `id`.
|
|
#[must_use]
|
|
pub fn node(&self, id: &str) -> Option<&Node> {
|
|
self.nodes.iter().find(|n| n.id == id)
|
|
}
|
|
}
|
|
|
|
/// Where a flow lives, which decides who its todos may go to.
|
|
#[derive(Clone, Copy)]
|
|
enum Scope {
|
|
/// The operator's document, or a built-in.
|
|
Document,
|
|
/// An archetype's `meta.flows`.
|
|
Archetype,
|
|
}
|
|
|
|
impl NodeKind {
|
|
fn validate(&self, scope: Scope) -> Result<(), String> {
|
|
match self {
|
|
Self::Source { events } => {
|
|
if events.is_empty() {
|
|
return Err("a source with no events".to_owned());
|
|
}
|
|
if let Some(bad) = events.iter().find(|e| !is_source_event(e)) {
|
|
return Err(format!(
|
|
"event {bad:?} is not one of {}",
|
|
SOURCE_EVENTS
|
|
.iter()
|
|
.map(|(n, _)| *n)
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
));
|
|
}
|
|
}
|
|
Self::Match { when } => {
|
|
if let Some(bad) = when.iter().find(|c| !is_field(&c.field)) {
|
|
return Err(format!(
|
|
"condition on {:?}, which is no event field",
|
|
bad.field
|
|
));
|
|
}
|
|
}
|
|
Self::Todo(sink) => sink.validate(scope)?,
|
|
Self::RequestReviewer { .. } if matches!(scope, Scope::Archetype) => {
|
|
return Err("an archetype's flow cannot request a review: that is the \
|
|
operator's flows document's job"
|
|
.to_owned());
|
|
}
|
|
Self::RequestReviewer { reviewer } => {
|
|
if !is_login(reviewer) {
|
|
return Err(format!("reviewer {reviewer:?} is not a forge login"));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl TodoSink {
|
|
fn validate(&self, scope: Scope) -> Result<(), String> {
|
|
match (&self.to, scope) {
|
|
(Recipients::Members, Scope::Archetype) => {}
|
|
(Recipients::Members, Scope::Document) => {
|
|
return Err("recipients members is for an archetype's flows: the \
|
|
document has no archetype"
|
|
.to_owned());
|
|
}
|
|
(_, Scope::Archetype) => {
|
|
return Err(
|
|
"an archetype's flow sends only to its members: a flow to a \
|
|
named agent or to an event field's agents is the operator's flows \
|
|
document's job"
|
|
.to_owned(),
|
|
);
|
|
}
|
|
(Recipients::Field(f), Scope::Document) if !is_field(f) => {
|
|
return Err(format!("recipients from {f:?}, which is no event field"));
|
|
}
|
|
(Recipients::Agent(a), Scope::Document) if !is_id(a) => {
|
|
return Err(format!("recipient {a:?} is not an agent name"));
|
|
}
|
|
_ => {}
|
|
}
|
|
if self.key.is_empty() || self.key.len() > MAX_TEXT {
|
|
return Err(format!("key is empty or longer than {MAX_TEXT} bytes"));
|
|
}
|
|
if self.text.len() > MAX_TEXT {
|
|
return Err(format!("text is longer than {MAX_TEXT} bytes"));
|
|
}
|
|
for (what, template) in [("key", &self.key), ("text", &self.text)] {
|
|
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 {
|
|
Op::Upsert if self.text.trim().is_empty() => Err("an upsert with no text".to_owned()),
|
|
Op::Upsert if self.prefix => Err("prefix is for a clear".to_owned()),
|
|
Op::Clear if !self.text.is_empty() => Err("a clear takes no text".to_owned()),
|
|
_ => Ok(()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The flows every swarm ships with.
|
|
///
|
|
/// - `assigned`: a newly assigned agent gets a todo for the issue or PR.
|
|
/// - `unassigned`: an agent taken off it gets that todo rewritten to say so.
|
|
/// Both write the same key, so the second replaces the first.
|
|
/// - `closed`: closing it clears every router todo the router queued for it,
|
|
/// then tells each agent that held one with a `closed` todo. The clear is the
|
|
/// first child of the match and runs first: its prefix also covers the
|
|
/// `closed` key.
|
|
/// - `head-classifier`: a PR opened, reopened or pushed to requests the review
|
|
/// classifier's review.
|
|
/// - `ci-failed` and `ci-cancelled`: a CI run on a PR that failed or was
|
|
/// cancelled gives the PR's author a todo. Both write the same key, so the
|
|
/// later run's outcome replaces the earlier one.
|
|
/// - `ci-cleared`: a push to the PR's branch clears that todo, as the new run
|
|
/// replaces the old one.
|
|
/// - `conflict` and `conflict-cleared`: a PR that stops merging into its
|
|
/// target branch gives its author a todo, and the todo clears when it merges
|
|
/// again. Both read `mergeability_changed`, so a repeated finding that the
|
|
/// PR still conflicts writes nothing.
|
|
#[must_use]
|
|
pub fn builtins() -> Vec<Flow> {
|
|
let assignment_key = "forge:{{ repo }}#{{ number }}:assignment";
|
|
let mut flows = vec![
|
|
chain(
|
|
"assigned",
|
|
&["issue_assign", "pull_request_assign"],
|
|
"assigned",
|
|
TodoSink {
|
|
to: Recipients::Field("assignees_added".to_owned()),
|
|
op: Op::Upsert,
|
|
key: assignment_key.to_owned(),
|
|
prefix: false,
|
|
text: "you are assigned to {{ kind }} {{ repo }}#{{ number }}: {{ title }} ({{ url }})".to_owned(),
|
|
},
|
|
),
|
|
chain(
|
|
"unassigned",
|
|
&["issue_assign", "pull_request_assign"],
|
|
"unassigned",
|
|
TodoSink {
|
|
to: Recipients::Field("assignees_removed".to_owned()),
|
|
op: Op::Upsert,
|
|
key: assignment_key.to_owned(),
|
|
prefix: false,
|
|
text: "you are not assigned to {{ kind }} {{ repo }}#{{ number }} anymore: {{ title }} ({{ url }})"
|
|
.to_owned(),
|
|
},
|
|
),
|
|
closed(),
|
|
head_classifier(),
|
|
];
|
|
flows.extend(ci_flows());
|
|
flows.extend(conflict_flows());
|
|
flows
|
|
}
|
|
|
|
/// source(`pull_request_mergeability`) → match(`action` and
|
|
/// `mergeability_changed` = `true`) → the author's `:conflict` todo.
|
|
fn conflict_flows() -> Vec<Flow> {
|
|
let flow = |id: &str, action: &str, op, text: &str| {
|
|
let mut flow = chain(
|
|
id,
|
|
&["pull_request_mergeability"],
|
|
action,
|
|
TodoSink {
|
|
to: Recipients::Field("author".to_owned()),
|
|
op,
|
|
key: "forge:{{ repo }}#{{ number }}:conflict".to_owned(),
|
|
prefix: false,
|
|
text: text.to_owned(),
|
|
},
|
|
);
|
|
for node in &mut flow.nodes {
|
|
if let NodeKind::Match { when } = &mut node.kind {
|
|
when.push(Condition {
|
|
field: "mergeability_changed".to_owned(),
|
|
equals: "true".to_owned(),
|
|
});
|
|
}
|
|
}
|
|
flow
|
|
};
|
|
vec![
|
|
flow(
|
|
"conflict",
|
|
"conflicting",
|
|
Op::Upsert,
|
|
"{{ kind }} {{ repo }}#{{ number }} no longer merges into its target branch: {{ title }} ({{ url }})",
|
|
),
|
|
flow("conflict-cleared", "mergeable", Op::Clear, ""),
|
|
]
|
|
}
|
|
|
|
/// 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 to_author = |op, text: &str| TodoSink {
|
|
to: Recipients::Field("author".to_owned()),
|
|
op,
|
|
key: ci_key.to_owned(),
|
|
prefix: false,
|
|
text: text.to_owned(),
|
|
};
|
|
vec![
|
|
chain(
|
|
"ci-failed",
|
|
&["workflow_run_failure"],
|
|
"failure",
|
|
to_author(
|
|
Op::Upsert,
|
|
"CI failed on {{ kind }} {{ repo }}#{{ number }}: {{ title }} ({{ url }})",
|
|
),
|
|
),
|
|
chain(
|
|
"ci-cancelled",
|
|
&["workflow_run_cancelled"],
|
|
"cancelled",
|
|
to_author(
|
|
Op::Upsert,
|
|
"CI was cancelled on {{ kind }} {{ repo }}#{{ number }}: {{ title }} ({{ url }})",
|
|
),
|
|
),
|
|
chain(
|
|
"ci-cleared",
|
|
&["pull_request_sync"],
|
|
"synchronized",
|
|
to_author(Op::Clear, ""),
|
|
),
|
|
]
|
|
}
|
|
|
|
/// The `closed` flow: the clear, then the todo that says why.
|
|
fn closed() -> Flow {
|
|
let mut flow = chain(
|
|
"closed",
|
|
&["issues", "pull_request"],
|
|
"closed",
|
|
TodoSink {
|
|
to: Recipients::Field("todo_holders".to_owned()),
|
|
op: Op::Clear,
|
|
key: "forge:{{ repo }}#{{ number }}:".to_owned(),
|
|
prefix: true,
|
|
text: String::new(),
|
|
},
|
|
);
|
|
flow.nodes.push(Node {
|
|
id: "inform".to_owned(),
|
|
kind: NodeKind::Todo(TodoSink {
|
|
to: Recipients::Field("todo_holders".to_owned()),
|
|
op: Op::Upsert,
|
|
key: "forge:{{ repo }}#{{ number }}:closed".to_owned(),
|
|
prefix: false,
|
|
text: "{{ kind }} {{ repo }}#{{ number }} was closed: {{ title }} ({{ url }})"
|
|
.to_owned(),
|
|
}),
|
|
});
|
|
flow.edges.push(Edge {
|
|
from: "match".to_owned(),
|
|
to: "inform".to_owned(),
|
|
});
|
|
flow
|
|
}
|
|
|
|
/// source(`pull_request`, `pull_request_sync`) → match(`action` = `opened`,
|
|
/// `reopened` or `synchronized`, and `draft` = `false`) → request the review
|
|
/// classifier's review; and match(`undrafted` = `true`) → the same request,
|
|
/// for a PR whose last push came while it was a draft.
|
|
fn head_classifier() -> Flow {
|
|
let branches = [
|
|
("opened", "action", "opened"),
|
|
("reopened", "action", "reopened"),
|
|
("synchronized", "action", "synchronized"),
|
|
("undrafted", "undrafted", "true"),
|
|
];
|
|
let mut nodes = vec![Node {
|
|
id: "source".to_owned(),
|
|
kind: NodeKind::Source {
|
|
events: vec!["pull_request".to_owned(), "pull_request_sync".to_owned()],
|
|
},
|
|
}];
|
|
let mut edges = Vec::new();
|
|
for (name, field, equals) in branches {
|
|
let (matched, request) = (format!("match-{name}"), format!("request-{name}"));
|
|
let condition = |field: &str, equals: &str| Condition {
|
|
field: field.to_owned(),
|
|
equals: equals.to_owned(),
|
|
};
|
|
let mut when = vec![condition(field, equals)];
|
|
if field == "action" {
|
|
when.push(condition("draft", "false"));
|
|
}
|
|
nodes.push(Node {
|
|
id: matched.clone(),
|
|
kind: NodeKind::Match { when },
|
|
});
|
|
nodes.push(Node {
|
|
id: request.clone(),
|
|
kind: NodeKind::RequestReviewer {
|
|
reviewer: CLASSIFIER_ACCOUNT.to_owned(),
|
|
},
|
|
});
|
|
edges.push(Edge {
|
|
from: "source".to_owned(),
|
|
to: matched.clone(),
|
|
});
|
|
edges.push(Edge {
|
|
from: matched,
|
|
to: request,
|
|
});
|
|
}
|
|
Flow {
|
|
id: "head-classifier".to_owned(),
|
|
name: "head-classifier".to_owned(),
|
|
enabled: true,
|
|
nodes,
|
|
edges,
|
|
}
|
|
}
|
|
|
|
/// source(`events`) → match(`action` = `action`) → `sink`.
|
|
fn chain(id: &str, events: &[&str], action: &str, sink: TodoSink) -> Flow {
|
|
let node = |id: &str, kind| Node {
|
|
id: id.to_owned(),
|
|
kind,
|
|
};
|
|
let edge = |from: &str, to: &str| Edge {
|
|
from: from.to_owned(),
|
|
to: to.to_owned(),
|
|
};
|
|
Flow {
|
|
id: id.to_owned(),
|
|
name: id.to_owned(),
|
|
enabled: true,
|
|
nodes: vec![
|
|
node(
|
|
"source",
|
|
NodeKind::Source {
|
|
events: events.iter().map(|e| (*e).to_owned()).collect(),
|
|
},
|
|
),
|
|
node(
|
|
"match",
|
|
NodeKind::Match {
|
|
when: vec![Condition {
|
|
field: "action".to_owned(),
|
|
equals: action.to_owned(),
|
|
}],
|
|
},
|
|
),
|
|
node("sink", NodeKind::Todo(sink)),
|
|
],
|
|
edges: vec![edge("source", "match"), edge("match", "sink")],
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn edge(from: &str, to: &str) -> Edge {
|
|
Edge {
|
|
from: from.to_owned(),
|
|
to: to.to_owned(),
|
|
}
|
|
}
|
|
|
|
fn builtin(id: &str) -> Flow {
|
|
builtins()
|
|
.into_iter()
|
|
.find(|f| f.id == id)
|
|
.expect("a built-in")
|
|
}
|
|
|
|
/// A custom flow: opened issue or PR → a todo for `triage`.
|
|
fn custom(id: &str) -> Flow {
|
|
let mut f = builtin("assigned");
|
|
f.id = id.to_owned();
|
|
f.name = "new to triage".to_owned();
|
|
f.nodes[2].kind = NodeKind::Todo(TodoSink {
|
|
to: Recipients::Agent("triage".to_owned()),
|
|
op: Op::Upsert,
|
|
key: "forge:{{ repo }}#{{ number }}:triage".to_owned(),
|
|
prefix: false,
|
|
text: "triage {{ repo }}#{{ number }}".to_owned(),
|
|
});
|
|
f
|
|
}
|
|
|
|
#[test]
|
|
fn every_builtin_is_a_valid_tree() {
|
|
for f in builtins() {
|
|
assert_eq!(f.validate(), Ok(()), "{}", f.id);
|
|
}
|
|
}
|
|
|
|
/// Both conflict flows act only when the mergeability event changed the
|
|
/// PR's state, and write the author's `:conflict` key.
|
|
#[test]
|
|
fn the_conflict_flows_read_the_change_and_write_the_conflict_key() {
|
|
for (id, action, op) in [
|
|
("conflict", "conflicting", Op::Upsert),
|
|
("conflict-cleared", "mergeable", Op::Clear),
|
|
] {
|
|
let f = builtin(id);
|
|
assert_eq!(
|
|
f.node("source").map(|n| &n.kind),
|
|
Some(&NodeKind::Source {
|
|
events: vec!["pull_request_mergeability".to_owned()]
|
|
}),
|
|
"{id}"
|
|
);
|
|
let want = |field: &str, equals: &str| Condition {
|
|
field: field.to_owned(),
|
|
equals: equals.to_owned(),
|
|
};
|
|
assert_eq!(
|
|
f.node("match").map(|n| &n.kind),
|
|
Some(&NodeKind::Match {
|
|
when: vec![want("action", action), want("mergeability_changed", "true")]
|
|
}),
|
|
"{id}"
|
|
);
|
|
let Some(NodeKind::Todo(sink)) = f.node("sink").map(|n| &n.kind) else {
|
|
panic!("{id}: no todo");
|
|
};
|
|
assert_eq!(sink.op, op, "{id}");
|
|
assert_eq!(sink.key, "forge:{{ repo }}#{{ number }}:conflict", "{id}");
|
|
assert_eq!(sink.to, Recipients::Field("author".to_owned()), "{id}");
|
|
}
|
|
}
|
|
|
|
/// What the schema offers is what the check accepts, and nothing else is.
|
|
#[test]
|
|
fn the_schema_lists_what_a_flow_may_name() {
|
|
let s = schema();
|
|
let with_event = |name: &str| {
|
|
let mut f = builtin("assigned");
|
|
f.nodes[0].kind = NodeKind::Source {
|
|
events: vec![name.to_owned()],
|
|
};
|
|
f.validate()
|
|
};
|
|
assert!(!s.source_events.is_empty());
|
|
for e in &s.source_events {
|
|
assert_eq!(with_event(&e.name), Ok(()), "{}", e.name);
|
|
assert!(!e.description.is_empty(), "{}", e.name);
|
|
}
|
|
assert!(with_event("no_such_event").is_err(), "control");
|
|
|
|
let with_field = |name: &str| {
|
|
let mut f = builtin("assigned");
|
|
f.nodes[1].kind = NodeKind::Match {
|
|
when: vec![Condition {
|
|
field: name.to_owned(),
|
|
equals: "x".to_owned(),
|
|
}],
|
|
};
|
|
f.validate()
|
|
};
|
|
assert!(!s.fields.is_empty());
|
|
for field in &s.fields {
|
|
assert_eq!(with_field(&field.name), Ok(()), "{}", field.name);
|
|
assert!(!field.description.is_empty(), "{}", field.name);
|
|
}
|
|
assert!(with_field("no_such_field").is_err(), "control");
|
|
|
|
let kinds: Vec<&str> = s.node_kinds.iter().map(|k| k.kind.as_str()).collect();
|
|
assert_eq!(kinds, ["source", "match", "todo", "request_reviewer"]);
|
|
let op_names = |op: &str| -> Option<Op> {
|
|
serde_json::from_value(serde_json::Value::String(op.to_owned())).ok()
|
|
};
|
|
let todo = s
|
|
.node_kinds
|
|
.iter()
|
|
.find(|k| k.kind == "todo")
|
|
.expect("todo");
|
|
let op = todo.fields.iter().find(|f| f.name == "op").expect("op");
|
|
let listed: Vec<Op> = op
|
|
.values
|
|
.iter()
|
|
.map(|v| op_names(&v.name).expect("an op"))
|
|
.collect();
|
|
// Every `Op` variant is listed: this match stops compiling when one is added.
|
|
for o in [Op::Upsert, Op::Clear] {
|
|
match o {
|
|
Op::Upsert | Op::Clear => assert!(listed.contains(&o), "{o:?}"),
|
|
}
|
|
}
|
|
assert_eq!(listed.len(), 2);
|
|
assert!(op_names("nope").is_none(), "control");
|
|
|
|
// What an editor offers for a field is what the router puts there.
|
|
let field = |name: &str| s.fields.iter().find(|f| f.name == name).expect(name);
|
|
let names =
|
|
|f: &EventField| -> Vec<String> { f.values.iter().map(|v| v.name.clone()).collect() };
|
|
assert_eq!(names(field("kind")), ["issue", "PR"]);
|
|
assert!(field("kind").closed);
|
|
let events: Vec<String> = s.source_events.iter().map(|e| e.name.clone()).collect();
|
|
assert_eq!(names(field("event")), events);
|
|
assert!(field("event").closed);
|
|
assert!(!field("action").closed, "the forge may send more actions");
|
|
assert!(names(field("action")).contains(&"closed".to_owned()));
|
|
assert_eq!(
|
|
field("repo").values_from.as_deref(),
|
|
Some(VALUES_FROM_REPOS)
|
|
);
|
|
assert_eq!(
|
|
field("assignees").values_from.as_deref(),
|
|
Some(VALUES_FROM_AGENTS)
|
|
);
|
|
assert!(field("title").values.is_empty() && field("title").values_from.is_none());
|
|
// A marker is one an editor knows to resolve.
|
|
for f in &s.fields {
|
|
if let Some(from) = &f.values_from {
|
|
assert!([VALUES_FROM_AGENTS, VALUES_FROM_REPOS].contains(&from.as_str()));
|
|
}
|
|
}
|
|
let kind_field_of = |kind: &str, name: &str| {
|
|
s.node_kinds
|
|
.iter()
|
|
.find(|k| k.kind == kind)
|
|
.and_then(|k| k.fields.iter().find(|f| f.name == name))
|
|
.expect(name)
|
|
};
|
|
assert!(kind_field_of("todo", "op").closed);
|
|
assert_eq!(
|
|
kind_field_of("todo", "to").values_from.as_deref(),
|
|
Some(VALUES_FROM_AGENTS)
|
|
);
|
|
let reviewer = kind_field_of("request_reviewer", "reviewer");
|
|
assert_eq!(reviewer.values_from.as_deref(), Some(VALUES_FROM_AGENTS));
|
|
assert!(!reviewer.closed, "any forge login is allowed");
|
|
|
|
assert_eq!(
|
|
(s.limits.max_nodes, s.limits.max_depth, s.limits.max_flows),
|
|
(MAX_NODES, MAX_DEPTH, MAX_FLOWS)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_flow_that_is_not_a_tree_is_refused() {
|
|
let mut cycle = builtin("assigned");
|
|
cycle.edges[0] = edge("sink", "match");
|
|
cycle.edges.push(edge("match", "sink"));
|
|
assert!(cycle.validate().is_err(), "a cycle");
|
|
|
|
let mut orphan = builtin("assigned");
|
|
orphan.edges.pop();
|
|
assert!(orphan.validate().is_err(), "an unreachable sink");
|
|
|
|
let mut two_parents = builtin("assigned");
|
|
two_parents.edges.push(edge("source", "sink"));
|
|
assert!(two_parents.validate().is_err(), "a sink with two parents");
|
|
|
|
let mut sink_child = builtin("assigned");
|
|
sink_child.nodes.push(Node {
|
|
id: "m2".to_owned(),
|
|
kind: NodeKind::Match { when: Vec::new() },
|
|
});
|
|
sink_child.edges.push(edge("sink", "m2"));
|
|
assert!(sink_child.validate().is_err(), "a sink with a child");
|
|
|
|
let mut dangling = builtin("assigned");
|
|
dangling.edges.push(edge("match", "nowhere"));
|
|
assert!(dangling.validate().is_err(), "an edge to no node");
|
|
|
|
let mut two_sources = builtin("assigned");
|
|
two_sources.nodes[1].kind = NodeKind::Source {
|
|
events: vec!["issues".to_owned()],
|
|
};
|
|
assert!(two_sources.validate().is_err(), "two sources");
|
|
|
|
let mut twice = builtin("assigned");
|
|
twice.nodes[1].id = "sink".to_owned();
|
|
assert!(twice.validate().is_err(), "a node id used twice");
|
|
}
|
|
|
|
#[test]
|
|
fn the_caps_hold_at_the_limit_and_refuse_one_past_it() {
|
|
// A chain source → m0 → m1 → … of `n` matches under the source.
|
|
let chain_of = |n: usize| {
|
|
let mut f = builtin("assigned");
|
|
f.nodes.truncate(1);
|
|
f.edges.clear();
|
|
let mut parent = "source".to_owned();
|
|
for i in 0..n {
|
|
let id = format!("m{i}");
|
|
f.nodes.push(Node {
|
|
id: id.clone(),
|
|
kind: NodeKind::Match { when: Vec::new() },
|
|
});
|
|
f.edges.push(edge(&parent, &id));
|
|
parent = id;
|
|
}
|
|
f
|
|
};
|
|
assert_eq!(chain_of(MAX_DEPTH).validate(), Ok(()));
|
|
assert!(chain_of(MAX_DEPTH + 1).validate().is_err(), "depth");
|
|
let mut wide = chain_of(0);
|
|
for i in 0..MAX_NODES - 1 {
|
|
wide.nodes.push(Node {
|
|
id: format!("m{i}"),
|
|
kind: NodeKind::Match { when: Vec::new() },
|
|
});
|
|
wide.edges.push(edge("source", &format!("m{i}")));
|
|
}
|
|
assert_eq!(wide.validate(), Ok(()));
|
|
wide.nodes.push(Node {
|
|
id: "extra".to_owned(),
|
|
kind: NodeKind::Match { when: Vec::new() },
|
|
});
|
|
wide.edges.push(edge("source", "extra"));
|
|
assert!(wide.validate().is_err(), "node count");
|
|
}
|
|
|
|
#[test]
|
|
fn a_node_that_reads_what_does_not_exist_is_refused() {
|
|
let mut event = builtin("assigned");
|
|
event.nodes[0].kind = NodeKind::Source {
|
|
events: vec!["pull_request_syn".to_owned()],
|
|
};
|
|
assert!(event.validate().unwrap_err().contains("pull_request_syn"));
|
|
// Control: the spelling that exists passes.
|
|
event.nodes[0].kind = NodeKind::Source {
|
|
events: vec!["pull_request_sync".to_owned()],
|
|
};
|
|
assert_eq!(event.validate(), Ok(()));
|
|
|
|
let mut field = builtin("assigned");
|
|
field.nodes[1].kind = NodeKind::Match {
|
|
when: vec![Condition {
|
|
field: "actoin".to_owned(),
|
|
equals: "closed".to_owned(),
|
|
}],
|
|
};
|
|
assert!(field.validate().unwrap_err().contains("actoin"));
|
|
|
|
let mut to = custom("c");
|
|
to.nodes[2].kind = NodeKind::Todo(TodoSink {
|
|
to: Recipients::Field("nobody".to_owned()),
|
|
op: Op::Upsert,
|
|
key: "k".to_owned(),
|
|
prefix: false,
|
|
text: "t".to_owned(),
|
|
});
|
|
assert!(to.validate().unwrap_err().contains("nobody"));
|
|
// Control: a literal agent and the sources above spelled right pass.
|
|
assert_eq!(custom("c").validate(), Ok(()));
|
|
}
|
|
|
|
/// A reviewer request is a leaf naming a forge login; the wire spells the
|
|
/// kind as the schema lists it.
|
|
#[test]
|
|
fn a_request_reviewer_node_is_a_leaf_naming_a_login() {
|
|
let with = |reviewer: &str| {
|
|
let mut f = custom("c");
|
|
f.nodes[2].kind = NodeKind::RequestReviewer {
|
|
reviewer: reviewer.to_owned(),
|
|
};
|
|
f.validate()
|
|
};
|
|
assert_eq!(with("review-classifier"), Ok(()));
|
|
assert_eq!(with("a.b_c-1"), Ok(()));
|
|
for bad in ["", "two words", "a/b", "x@y", &"a".repeat(41)] {
|
|
assert!(with(bad).is_err(), "{bad:?}");
|
|
}
|
|
|
|
let mut parent = custom("c");
|
|
parent.nodes[2].kind = NodeKind::RequestReviewer {
|
|
reviewer: "review-classifier".to_owned(),
|
|
};
|
|
parent.nodes.push(Node {
|
|
id: "m2".to_owned(),
|
|
kind: NodeKind::Match { when: Vec::new() },
|
|
});
|
|
parent.edges.push(edge("sink", "m2"));
|
|
assert!(
|
|
parent.validate().unwrap_err().contains("leaf"),
|
|
"a reviewer request with a child"
|
|
);
|
|
|
|
let json = serde_json::to_value(Node {
|
|
id: "ask".to_owned(),
|
|
kind: NodeKind::RequestReviewer {
|
|
reviewer: "review-classifier".to_owned(),
|
|
},
|
|
})
|
|
.unwrap();
|
|
assert_eq!(
|
|
json,
|
|
serde_json::json!({
|
|
"id": "ask",
|
|
"kind": "request_reviewer",
|
|
"reviewer": "review-classifier",
|
|
})
|
|
);
|
|
let kind = json["kind"].as_str().unwrap();
|
|
assert!(
|
|
schema().node_kinds.iter().any(|k| k.kind == kind),
|
|
"the schema lists the kind the wire uses"
|
|
);
|
|
let node: Node = serde_json::from_value(json).unwrap();
|
|
assert!(matches!(node.kind, NodeKind::RequestReviewer { .. }));
|
|
}
|
|
|
|
/// The built-in that requests the classifier on an open, a reopen and a push.
|
|
#[test]
|
|
fn head_classifier_requests_the_classifier_on_open_reopen_and_push() {
|
|
let f = builtin("head-classifier");
|
|
assert_eq!(f.validate(), Ok(()));
|
|
let cond = |field: &str, equals: &str| Condition {
|
|
field: field.to_owned(),
|
|
equals: equals.to_owned(),
|
|
};
|
|
assert_eq!(
|
|
f.nodes[0].kind,
|
|
NodeKind::Source {
|
|
events: vec!["pull_request".to_owned(), "pull_request_sync".to_owned()]
|
|
}
|
|
);
|
|
assert_eq!(
|
|
f.children("source"),
|
|
[
|
|
"match-opened",
|
|
"match-reopened",
|
|
"match-synchronized",
|
|
"match-undrafted"
|
|
]
|
|
);
|
|
for action in ["opened", "reopened", "synchronized", "undrafted"] {
|
|
let when = if action == "undrafted" {
|
|
vec![cond("undrafted", "true")]
|
|
} else {
|
|
vec![cond("action", action), cond("draft", "false")]
|
|
};
|
|
assert_eq!(
|
|
f.node(&format!("match-{action}")).map(|n| &n.kind),
|
|
Some(&NodeKind::Match { when })
|
|
);
|
|
assert_eq!(
|
|
f.node(&format!("request-{action}")).map(|n| &n.kind),
|
|
Some(&NodeKind::RequestReviewer {
|
|
reviewer: CLASSIFIER_ACCOUNT.to_owned()
|
|
})
|
|
);
|
|
assert_eq!(
|
|
f.children(&format!("match-{action}")),
|
|
[format!("request-{action}")]
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_placeholder_must_name_an_event_field() {
|
|
let with = |key: &str, text: &str| {
|
|
let mut f = custom("c");
|
|
f.nodes[2].kind = NodeKind::Todo(TodoSink {
|
|
to: Recipients::Agent("triage".to_owned()),
|
|
op: Op::Upsert,
|
|
key: key.to_owned(),
|
|
prefix: false,
|
|
text: text.to_owned(),
|
|
});
|
|
f.validate()
|
|
};
|
|
assert_eq!(
|
|
with(
|
|
"forge:{{ repo }}#{{ number }}:triage",
|
|
"{{ title }} {{ url }}"
|
|
),
|
|
Ok(())
|
|
);
|
|
assert!(
|
|
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");
|
|
assert!(with("k{{ repo", "t").is_err(), "an unclosed tag");
|
|
// A lone brace is text.
|
|
assert_eq!(with("k", "set {x"), Ok(()));
|
|
}
|
|
|
|
#[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]
|
|
fn a_sink_must_say_what_its_op_needs() {
|
|
let sink = |op, prefix, text: &str| {
|
|
let mut f = custom("c");
|
|
f.nodes[2].kind = NodeKind::Todo(TodoSink {
|
|
to: Recipients::Agent("triage".to_owned()),
|
|
op,
|
|
key: "k".to_owned(),
|
|
prefix,
|
|
text: text.to_owned(),
|
|
});
|
|
f.validate()
|
|
};
|
|
assert_eq!(sink(Op::Upsert, false, "t"), Ok(()));
|
|
assert_eq!(sink(Op::Clear, true, ""), Ok(()));
|
|
assert!(sink(Op::Upsert, false, " ").is_err(), "no text");
|
|
assert!(sink(Op::Upsert, true, "t").is_err(), "prefix on an upsert");
|
|
assert!(sink(Op::Clear, false, "t").is_err(), "text on a clear");
|
|
}
|
|
|
|
#[test]
|
|
fn a_document_names_only_real_builtins_and_unique_flow_ids() {
|
|
let doc = |disabled: &[&str], flows: Vec<Flow>| Document {
|
|
version: VERSION,
|
|
disabled_builtins: disabled.iter().map(|s| (*s).to_owned()).collect(),
|
|
flows,
|
|
};
|
|
assert_eq!(doc(&["assigned"], vec![custom("triage")]).check(), Ok(()));
|
|
assert!(doc(&["nope"], vec![]).check().unwrap_err().contains("nope"));
|
|
assert!(
|
|
doc(&[], vec![custom("assigned")]).check().is_err(),
|
|
"a built-in's id"
|
|
);
|
|
assert!(
|
|
doc(&[], vec![custom("a"), custom("a")]).check().is_err(),
|
|
"a repeated id"
|
|
);
|
|
let mut bad = custom("bad");
|
|
bad.edges.pop();
|
|
assert!(
|
|
doc(&[], vec![bad])
|
|
.check()
|
|
.unwrap_err()
|
|
.starts_with("flow bad:")
|
|
);
|
|
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 = 3;
|
|
assert!(old.check().is_err(), "version");
|
|
}
|
|
|
|
#[test]
|
|
fn active_is_the_builtins_minus_the_disabled_plus_the_enabled_valid_custom() {
|
|
let ids = |d: &Document| d.active().0.into_iter().map(|f| f.id).collect::<Vec<_>>();
|
|
assert_eq!(
|
|
ids(&Document::default()),
|
|
[
|
|
"assigned",
|
|
"unassigned",
|
|
"closed",
|
|
"head-classifier",
|
|
"ci-failed",
|
|
"ci-cancelled",
|
|
"ci-cleared",
|
|
"conflict",
|
|
"conflict-cleared"
|
|
],
|
|
"an empty document is the built-ins"
|
|
);
|
|
let mut off = custom("off");
|
|
off.enabled = false;
|
|
let mut broken = custom("broken");
|
|
broken.edges.clear();
|
|
let doc = Document {
|
|
version: VERSION,
|
|
disabled_builtins: vec!["unassigned".to_owned(), "gone".to_owned()],
|
|
flows: vec![custom("triage"), off, broken, custom("assigned")],
|
|
};
|
|
assert_eq!(
|
|
ids(&doc),
|
|
[
|
|
"assigned",
|
|
"closed",
|
|
"head-classifier",
|
|
"ci-failed",
|
|
"ci-cancelled",
|
|
"ci-cleared",
|
|
"conflict",
|
|
"conflict-cleared",
|
|
"triage"
|
|
]
|
|
);
|
|
let (_, skipped) = doc.active();
|
|
assert_eq!(skipped.len(), 2, "{skipped:?}");
|
|
assert!(skipped.iter().any(|s| s.starts_with("flow broken:")));
|
|
assert!(skipped.iter().any(|s| s.contains("assigned")));
|
|
}
|
|
|
|
#[test]
|
|
fn a_document_reads_back_and_a_foreign_one_is_refused() {
|
|
let doc = Document {
|
|
version: VERSION,
|
|
disabled_builtins: vec!["closed".to_owned()],
|
|
flows: vec![custom("triage")],
|
|
};
|
|
let bytes = serde_json::to_vec(&doc).unwrap();
|
|
assert_eq!(Document::decode(&bytes), Ok(doc));
|
|
assert_eq!(Document::decode(b"{}"), Ok(Document::default()));
|
|
assert_eq!(
|
|
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());
|
|
}
|
|
|
|
/// The wire shape the API and the stored document share.
|
|
#[test]
|
|
fn a_node_is_flat_with_its_kind_beside_its_id() {
|
|
let json = serde_json::to_value(&builtin("closed").nodes[2]).unwrap();
|
|
assert_eq!(
|
|
json,
|
|
serde_json::json!({
|
|
"id": "sink",
|
|
"kind": "todo",
|
|
"to": { "field": "todo_holders" },
|
|
"op": "clear",
|
|
"key": "forge:{{ repo }}#{{ number }}:",
|
|
"prefix": true,
|
|
"text": "",
|
|
})
|
|
);
|
|
}
|
|
|
|
/// A copy of the `custom` flow whose todo goes to `to`.
|
|
fn sending_to(id: &str, to: Recipients) -> Flow {
|
|
let mut f = custom(id);
|
|
let NodeKind::Todo(sink) = &mut f.nodes[2].kind else {
|
|
panic!("a todo")
|
|
};
|
|
sink.to = to;
|
|
f
|
|
}
|
|
|
|
/// `members` is an archetype flow's recipient: the operator's document and
|
|
/// the document check refuse it, an archetype flow takes nothing else.
|
|
#[test]
|
|
fn members_is_for_an_archetypes_flows_only() {
|
|
let members = sending_to("m", Recipients::Members);
|
|
// Presence: the same flow passes where `members` belongs.
|
|
assert_eq!(members.validate_archetype(), Ok(()));
|
|
let why = members.validate().expect_err("no archetype in a document");
|
|
assert!(why.contains("no archetype"), "{why}");
|
|
let doc = Document {
|
|
flows: vec![members],
|
|
..Document::default()
|
|
};
|
|
assert!(doc.check().is_err());
|
|
assert!(doc.active().0.iter().all(|f| f.id != "m"), "left out");
|
|
|
|
for other in [
|
|
Recipients::Agent("triage".to_owned()),
|
|
Recipients::Field("author".to_owned()),
|
|
] {
|
|
let f = sending_to("o", other);
|
|
assert_eq!(f.validate(), Ok(()), "control: a document takes it");
|
|
let why = f.validate_archetype().expect_err("not for an archetype");
|
|
assert!(why.contains("only to its members"), "{why}");
|
|
}
|
|
let mut review = custom("r");
|
|
review.nodes[2].kind = NodeKind::RequestReviewer {
|
|
reviewer: "argus".to_owned(),
|
|
};
|
|
assert_eq!(review.validate(), Ok(()), "control: a document takes it");
|
|
assert!(review.validate_archetype().is_err());
|
|
for built in builtins() {
|
|
assert!(built.validate_archetype().is_err(), "{}", built.id);
|
|
}
|
|
}
|
|
|
|
/// An archetype's flow reads its key and text as the same Jinja templates a
|
|
/// document's flow does: an unknown variable or broken syntax is refused.
|
|
#[test]
|
|
fn an_archetypes_templates_are_checked_like_the_documents() {
|
|
let with = |key: &str, text: &str| {
|
|
let mut f = sending_to("t", Recipients::Members);
|
|
let NodeKind::Todo(sink) = &mut f.nodes[2].kind else {
|
|
panic!("a todo")
|
|
};
|
|
sink.key = key.to_owned();
|
|
sink.text = text.to_owned();
|
|
f
|
|
};
|
|
let ok = with("forge:{{ repo }}#{{ number }}:x", "{{ title }} {{ url }}");
|
|
assert_eq!(ok.validate_archetype(), Ok(()), "control");
|
|
for (key, text) in [
|
|
("forge:{{ repo }}#{{ numbr }}:x", "t"),
|
|
("k", "{{ nope }}"),
|
|
("k", "{{ title "),
|
|
] {
|
|
let f = with(key, text);
|
|
assert!(f.validate_archetype().is_err(), "{key} {text}");
|
|
}
|
|
}
|
|
}
|