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
20 changed files with 1231 additions and 64 deletions
|
|
@ -21,6 +21,7 @@ pub mod notices;
|
|||
pub mod notify_flows;
|
||||
pub mod notify_router;
|
||||
pub mod review_classifier;
|
||||
pub mod router_sink;
|
||||
pub mod router_todo;
|
||||
pub mod subagent_term;
|
||||
pub mod wanted;
|
||||
|
|
|
|||
|
|
@ -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:")));
|
||||
|
|
|
|||
105
swarm-wire/src/router_sink.rs
Normal file
105
swarm-wire/src/router_sink.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
//! The notification router's sinks that act on the forge, which it asks
|
||||
//! swarm-controller to run.
|
||||
//!
|
||||
//! The router has no forge token. For a `request_reviewer` node
|
||||
//! ([`crate::notify_flows::NodeKind::RequestReviewer`]) it sends a request on
|
||||
//! [`REQUEST_REVIEWER_SUBJECT`] and swarm-controller answers on the request's
|
||||
//! reply subject with an [`Outcome`], having acted under its own token.
|
||||
//!
|
||||
//! Three crates name these: the router sends, the controller answers, and the
|
||||
//! auth-callout responder grants each its side. The controller is the
|
||||
//! subject's one subscriber and the router its one publisher.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The subject a request for a review goes to.
|
||||
pub const REQUEST_REVIEWER_SUBJECT: &str = "$SWARM.router.sink.request-reviewer";
|
||||
|
||||
/// How long the router waits for the answer. The controller's reply grant
|
||||
/// lasts this long, so its own work stops sooner ([`WORK_DEADLINE`]).
|
||||
pub const REPLY_DEADLINE: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How long the controller works on one request before it answers
|
||||
/// [`Outcome::Error`].
|
||||
pub const WORK_DEADLINE: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Ask for `reviewer`'s review of pull request `number` of `repo`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RequestReviewer {
|
||||
/// `owner/name`.
|
||||
pub repo: String,
|
||||
pub number: u64,
|
||||
/// The reviewer's forge login.
|
||||
pub reviewer: String,
|
||||
}
|
||||
|
||||
/// What the controller did with a [`RequestReviewer`], on the wire as one
|
||||
/// line: `requested`, `skipped:<why>` or `error:<message>`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
/// The forge accepted the request.
|
||||
Requested,
|
||||
/// Nothing was sent, and why.
|
||||
Skipped(String),
|
||||
/// The request failed, and why.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
/// The wire line.
|
||||
#[must_use]
|
||||
pub fn encode(&self) -> String {
|
||||
match self {
|
||||
Self::Requested => "requested".to_owned(),
|
||||
Self::Skipped(why) => format!("skipped:{why}"),
|
||||
Self::Error(why) => format!("error:{why}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome a reply line says; a line that is none of the three is an
|
||||
/// [`Outcome::Error`] saying so.
|
||||
#[must_use]
|
||||
pub fn decode(line: &str) -> Self {
|
||||
if line == "requested" {
|
||||
Self::Requested
|
||||
} else if let Some(why) = line.strip_prefix("skipped:") {
|
||||
Self::Skipped(why.to_owned())
|
||||
} else if let Some(why) = line.strip_prefix("error:") {
|
||||
Self::Error(why.to_owned())
|
||||
} else {
|
||||
Self::Error(format!("unreadable reply {line:?}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_outcome_round_trips_its_line() {
|
||||
for outcome in [
|
||||
Outcome::Requested,
|
||||
Outcome::Skipped("a review stands".to_owned()),
|
||||
Outcome::Error("the forge said: no: why".to_owned()),
|
||||
] {
|
||||
assert_eq!(Outcome::decode(&outcome.encode()), outcome);
|
||||
}
|
||||
assert_eq!(Outcome::Requested.encode(), "requested");
|
||||
assert_eq!(Outcome::Skipped("x".to_owned()).encode(), "skipped:x");
|
||||
assert_eq!(Outcome::Error("x".to_owned()).encode(), "error:x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_is_no_outcome_reads_as_an_error() {
|
||||
assert!(matches!(Outcome::decode("ok"), Outcome::Error(e) if e.contains("ok")));
|
||||
}
|
||||
|
||||
/// The reply grant covers the work and the router's wait covers the grant.
|
||||
#[test]
|
||||
fn the_controller_stops_working_before_the_router_stops_waiting() {
|
||||
assert!(WORK_DEADLINE < REPLY_DEADLINE);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue