Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-10-24 14:28:27 +02:00
commit 0b1669a364

View file

@ -5,8 +5,9 @@
//! type and this walk runs both. A `match` node passes when every one of its
//! conditions holds, and then each of its children runs, so one event can
//! reach several branches, in edge order, so a clear listed before an upsert
//! is queued before it. A `todo` node emits one op per recipient. The router
//! runs only flows that pass [`Flow::validate`].
//! is queued before it. A `todo` node emits one op per recipient and a
//! `request_reviewer` node one request for a review. The router runs only
//! flows that pass [`Flow::validate`].
use std::collections::BTreeMap;
@ -32,17 +33,27 @@ pub struct Action {
pub op: RouterTodoOp,
}
/// Walk `flow` for one event of type `event_type` and return the ops it
/// emits. Assumes [`Flow::validate`] passed.
pub fn evaluate(flow: &Flow, event_type: &str, fields: &Fields) -> Vec<Action> {
/// One request for `reviewer`'s review of pull request `number` of `repo`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReviewRequest {
pub flow: String,
pub node: String,
pub repo: String,
pub number: u64,
pub reviewer: String,
}
/// Walk `flow` for one event of type `event_type`, calling `leaf` on each
/// `todo` and `request_reviewer` node the event reaches. Assumes
/// [`Flow::validate`] passed.
fn walk<'f>(flow: &'f Flow, event_type: &str, fields: &Fields, mut leaf: impl FnMut(&'f Node)) {
let Some(root) = flow
.nodes
.iter()
.find(|n| matches!(&n.kind, NodeKind::Source { events } if events.iter().any(|e| e == event_type)))
else {
return Vec::new();
return;
};
let mut actions = Vec::new();
let mut stack = vec![root.id.as_str()];
while let Some(id) = stack.pop() {
let Some(node) = flow.node(id) else { continue };
@ -53,12 +64,55 @@ pub fn evaluate(flow: &Flow, event_type: &str, fields: &Fields) -> Vec<Action> {
stack.extend(flow.children(id).into_iter().rev());
}
}
NodeKind::Todo(sink) => actions.extend(emit(flow, node, sink, fields)),
NodeKind::Todo(_) | NodeKind::RequestReviewer { .. } => leaf(node),
}
}
}
/// Walk `flow` for one event of type `event_type` and return the todo ops it
/// emits. Assumes [`Flow::validate`] passed.
pub fn evaluate(flow: &Flow, event_type: &str, fields: &Fields) -> Vec<Action> {
let mut actions = Vec::new();
walk(flow, event_type, fields, |node| {
if let NodeKind::Todo(sink) = &node.kind {
actions.extend(emit(flow, node, sink, fields));
}
});
actions
}
/// Walk `flow` for one event of type `event_type` and return the review
/// requests it makes. An event that is not about a pull request makes none.
/// Assumes [`Flow::validate`] passed.
pub fn requests(flow: &Flow, event_type: &str, fields: &Fields) -> Vec<ReviewRequest> {
let mut requests = Vec::new();
walk(flow, event_type, fields, |node| {
if let NodeKind::RequestReviewer { reviewer } = &node.kind
&& let Some(request) = request(flow, node, reviewer, fields)
{
requests.push(request);
}
});
requests
}
fn request(flow: &Flow, node: &Node, reviewer: &str, fields: &Fields) -> Option<ReviewRequest> {
let text = |name: &str| match fields.get(name) {
Some(Value::Text(v)) => Some(v.as_str()),
_ => None,
};
if text("kind") != Some("PR") {
return None;
}
Some(ReviewRequest {
flow: flow.id.clone(),
node: node.id.clone(),
repo: text("repo")?.to_owned(),
number: text("number")?.parse().ok()?,
reviewer: reviewer.to_owned(),
})
}
fn emit(flow: &Flow, node: &Node, sink: &TodoSink, fields: &Fields) -> Vec<Action> {
let recipients = match &sink.to {
Recipients::Agent(agent) => vec![agent.clone()],
@ -333,6 +387,41 @@ mod tests {
assert_eq!(got[0].op.source, "router:assigned");
}
/// A request sits under the same match gating as a todo, and names the
/// pull request the event concerns.
#[test]
fn a_reviewer_request_runs_under_its_match_and_only_for_a_pull_request() {
let mut f = flow("assigned");
f.nodes[0].kind = NodeKind::Source {
events: vec!["pull_request_sync".to_owned()],
};
f.nodes[2].kind = NodeKind::RequestReviewer {
reviewer: "argus".to_owned(),
};
assert_eq!(f.validate(), Ok(()));
let pull = |action: &str, kind: &'static str| {
let mut fields = event(action, &[], &[], &[]);
fields.insert("kind", text(kind));
fields
};
let got = requests(&f, "pull_request_sync", &pull("assigned", "PR"));
assert_eq!(
got,
[ReviewRequest {
flow: "assigned".to_owned(),
node: "sink".to_owned(),
repo: "hyperhive/hyperhive".to_owned(),
number: 7,
reviewer: "argus".to_owned(),
}]
);
assert!(requests(&f, "pull_request_sync", &pull("opened", "PR")).is_empty());
assert!(requests(&f, "pull_request_sync", &pull("assigned", "issue")).is_empty());
assert!(requests(&f, "pull_request", &pull("assigned", "PR")).is_empty());
// A request is no todo.
assert!(evaluate(&f, "pull_request_sync", &pull("assigned", "PR")).is_empty());
}
#[test]
fn a_placeholder_expands_to_its_field_and_an_unknown_one_is_an_error() {
let fields = event("assigned", &["a", "b"], &[], &[]);