request the review classifier's review when a PR's head moves
The router gets a request_reviewer node and a pull_request_sync source; the built-in head-classifier flow asks swarm-controller, over $SWARM.router.sink.request-reviewer, to request review-classifier on every push, unless its review of the head already stands. The controller subscribes to pull_request_sync on its pull-request hook and answers the router; nats-auth grants the router the publish and the controller the subscribe.
This commit is contained in:
parent
463c5cbfcc
commit
0b1669a364
1 changed files with 1231 additions and 64 deletions
|
|
@ -4,8 +4,10 @@
|
|||
//! 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. Flows are trees: one source, one parent for every other
|
||||
//! node, every node reachable from the source ([`Flow::validate`]).
|
||||
//! 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
|
||||
|
|
@ -56,6 +58,10 @@ pub const SOURCE_EVENTS: &[(&str, &str)] = &[
|
|||
"pull_request_assign",
|
||||
"A pull request assigned or unassigned",
|
||||
),
|
||||
(
|
||||
"pull_request_sync",
|
||||
"A push to the branch of an open pull request",
|
||||
),
|
||||
];
|
||||
|
||||
/// The fields of an event a condition or a recipient can name, with what each
|
||||
|
|
@ -81,6 +87,10 @@ pub const FIELDS: &[(&str, &str)] = &[
|
|||
),
|
||||
];
|
||||
|
||||
/// 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)] = &[
|
||||
(
|
||||
|
|
@ -171,6 +181,13 @@ pub enum NodeKind {
|
|||
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.
|
||||
|
|
@ -315,6 +332,16 @@ fn is_id(id: &str) -> bool {
|
|||
.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)
|
||||
}
|
||||
|
|
@ -437,6 +464,15 @@ pub fn schema() -> Schema {
|
|||
),
|
||||
],
|
||||
},
|
||||
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![kind_field(
|
||||
"reviewer",
|
||||
"The forge login to request a review from",
|
||||
true,
|
||||
)],
|
||||
},
|
||||
],
|
||||
limits: Limits {
|
||||
max_nodes: MAX_NODES,
|
||||
|
|
@ -512,8 +548,12 @@ impl Flow {
|
|||
return Err(format!("{} source nodes, not one", roots.len()));
|
||||
};
|
||||
for node in &self.nodes {
|
||||
if matches!(node.kind, NodeKind::Todo(_)) && !self.children(&node.id).is_empty() {
|
||||
return Err(format!("todo {} has children", node.id));
|
||||
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
|
||||
|
|
@ -587,6 +627,11 @@ impl NodeKind {
|
|||
}
|
||||
}
|
||||
Self::Todo(sink) => sink.validate()?,
|
||||
Self::RequestReviewer { reviewer } => {
|
||||
if !is_login(reviewer) {
|
||||
return Err(format!("reviewer {reviewer:?} is not a forge login"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -632,6 +677,7 @@ impl TodoSink {
|
|||
/// 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 push to a PR requests the review classifier's review.
|
||||
#[must_use]
|
||||
pub fn builtins() -> Vec<Flow> {
|
||||
let assignment_key = "forge:{repo}#{number}:assignment";
|
||||
|
|
@ -662,6 +708,7 @@ pub fn builtins() -> Vec<Flow> {
|
|||
},
|
||||
),
|
||||
closed(),
|
||||
head_classifier(),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -696,6 +743,33 @@ fn closed() -> Flow {
|
|||
flow
|
||||
}
|
||||
|
||||
/// source(`pull_request_sync`) → request the review classifier's review.
|
||||
fn head_classifier() -> Flow {
|
||||
Flow {
|
||||
id: "head-classifier".to_owned(),
|
||||
name: "head-classifier".to_owned(),
|
||||
enabled: true,
|
||||
nodes: vec![
|
||||
Node {
|
||||
id: "source".to_owned(),
|
||||
kind: NodeKind::Source {
|
||||
events: vec!["pull_request_sync".to_owned()],
|
||||
},
|
||||
},
|
||||
Node {
|
||||
id: "request".to_owned(),
|
||||
kind: NodeKind::RequestReviewer {
|
||||
reviewer: CLASSIFIER_ACCOUNT.to_owned(),
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: vec![Edge {
|
||||
from: "source".to_owned(),
|
||||
to: "request".to_owned(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// source(`events`) → match(`action` = `action`) → `sink`.
|
||||
fn chain(id: &str, events: &[&str], action: &str, sink: TodoSink) -> Flow {
|
||||
let node = |id: &str, kind| Node {
|
||||
|
|
@ -808,7 +882,7 @@ mod tests {
|
|||
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"]);
|
||||
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()
|
||||
};
|
||||
|
|
@ -917,10 +991,15 @@ mod tests {
|
|||
#[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!(event.validate().unwrap_err().contains("pull_request_sync"));
|
||||
assert_eq!(event.validate(), Ok(()));
|
||||
|
||||
let mut field = builtin("assigned");
|
||||
field.nodes[1].kind = NodeKind::Match {
|
||||
|
|
@ -944,6 +1023,81 @@ mod tests {
|
|||
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 every push.
|
||||
#[test]
|
||||
fn head_classifier_requests_the_classifier_on_a_push() {
|
||||
let f = builtin("head-classifier");
|
||||
assert_eq!(f.validate(), Ok(()));
|
||||
assert_eq!(
|
||||
f.nodes[0].kind,
|
||||
NodeKind::Source {
|
||||
events: vec!["pull_request_sync".to_owned()]
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
f.nodes[1].kind,
|
||||
NodeKind::RequestReviewer {
|
||||
reviewer: CLASSIFIER_ACCOUNT.to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(f.children("source"), ["request"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placeholder_must_name_an_event_field() {
|
||||
let with = |key: &str, text: &str| {
|
||||
|
|
@ -1030,7 +1184,7 @@ mod tests {
|
|||
let ids = |d: &Document| d.active().0.into_iter().map(|f| f.id).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
ids(&Document::default()),
|
||||
["assigned", "unassigned", "closed"],
|
||||
["assigned", "unassigned", "closed", "head-classifier"],
|
||||
"an empty document is the built-ins"
|
||||
);
|
||||
let mut off = custom("off");
|
||||
|
|
@ -1042,7 +1196,10 @@ mod tests {
|
|||
disabled_builtins: vec!["unassigned".to_owned(), "gone".to_owned()],
|
||||
flows: vec![custom("triage"), off, broken, custom("assigned")],
|
||||
};
|
||||
assert_eq!(ids(&doc), ["assigned", "closed", "triage"]);
|
||||
assert_eq!(
|
||||
ids(&doc),
|
||||
["assigned", "closed", "head-classifier", "triage"]
|
||||
);
|
||||
let (_, skipped) = doc.active();
|
||||
assert_eq!(skipped.len(), 2, "{skipped:?}");
|
||||
assert!(skipped.iter().any(|s| s.starts_with("flow broken:")));
|
||||
|
|
|
|||
Loading…
Reference in a new issue