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
20 changed files with 1231 additions and 64 deletions

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"], &[], &[]);

View file

@ -7,6 +7,12 @@
//! Each agent's harness applies them to its own todo list. The flows are the
//! built-in ones, changed by the `notify-flows` document ([`stored`]).
//!
//! A `request_reviewer` node is a request to swarm-controller, which holds the
//! forge token (`swarm_wire::router_sink`). It is sent once, after the event's
//! ops are queued and its snapshot is written, and the answer is only logged:
//! a controller that is down or slow must not hold up every agent's todos, and
//! the next push asks again.
//!
//! Per issue it keeps a snapshot in its state bucket ([`event::Snapshot`]):
//! the assignees it last saw and the agents it queued a todo for. An event is
//! acked once its ops are queued and the snapshot is written, so a crash
@ -26,6 +32,7 @@ use async_nats::jetstream::{self, consumer::pull, kv, message::PublishMessage};
use futures_util::{StreamExt as _, TryStreamExt as _};
use swarm_queue_client::{ClientSecret, QueueConfig};
use swarm_secret_client::stored_secret::StoredSecret;
use swarm_wire::router_sink::{self, Outcome, RequestReviewer};
use swarm_wire::{forge_events, notify_router, router_todo};
mod event;
@ -103,6 +110,7 @@ async fn main() -> Result<()> {
struct Router {
js: jetstream::Context,
nats: async_nats::Client,
events: jetstream::consumer::Consumer<pull::Config>,
state: kv::Store,
flows: stored::Flows,
@ -144,6 +152,7 @@ impl Router {
};
Ok(Self {
js,
nats: queue.clone(),
events,
state,
flows: stored::Flows::builtins(),
@ -200,9 +209,52 @@ impl Router {
let bytes = serde_json::to_vec(&routed.next).expect("a snapshot encodes");
self.state.put(&key, bytes.into()).await.map_err(retry)?;
}
for request in &routed.requests {
self.request_review(request).await;
}
Ok(())
}
/// Ask swarm-controller for one review and log what it answered. One
/// attempt: nothing here fails the event.
async fn request_review(&self, request: &flow::ReviewRequest) {
let wire = RequestReviewer {
repo: request.repo.clone(),
number: request.number,
reviewer: request.reviewer.clone(),
};
let body = serde_json::to_vec(&wire).expect("a review request encodes");
let sent = async_nats::Request::new()
.payload(body.into())
.timeout(Some(router_sink::REPLY_DEADLINE));
let (flow, repo, number, reviewer) = (
&request.flow,
&request.repo,
request.number,
&request.reviewer,
);
match self
.nats
.send_request(router_sink::REQUEST_REVIEWER_SUBJECT, sent)
.await
{
Ok(reply) => match Outcome::decode(&String::from_utf8_lossy(&reply.payload)) {
Outcome::Requested => {
tracing::info!(%flow, %repo, number, %reviewer, "notify router: review requested");
}
Outcome::Skipped(why) => {
tracing::info!(%flow, %repo, number, %reviewer, %why, "notify router: review not requested");
}
Outcome::Error(why) => {
tracing::warn!(%flow, %repo, number, %reviewer, %why, "notify router: the controller could not request the review");
}
},
Err(e) => {
tracing::warn!(%flow, %repo, number, %reviewer, error = %e, "notify router: no answer to a review request; not retried");
}
}
}
/// Queue one op on its agent's subject and wait for the stream's ack.
async fn queue(&self, seq: u64, action: &flow::Action) -> Result<()> {
let subject = router_todo::subject(&action.agent);

View file

@ -8,13 +8,16 @@ use swarm_wire::router_todo::Op;
use crate::event::{Delivery, Snapshot, fields, is_assign};
use swarm_wire::notify_flows::Flow;
use crate::flow::{Action, evaluate};
use crate::flow::{Action, ReviewRequest, evaluate, requests};
/// What one event comes to.
#[derive(Debug, PartialEq, Eq)]
pub struct Routed {
/// The ops to queue, one per agent that exists.
pub actions: Vec<Action>,
/// The reviews to ask the forge for. Not limited to agents: a reviewer is
/// a forge account.
pub requests: Vec<ReviewRequest>,
/// Recipients that are not agents of this swarm, so got nothing.
pub skipped: Vec<String>,
pub next: Snapshot,
@ -32,6 +35,10 @@ pub fn route(
let fields = fields(event_type, delivery, snapshot);
let mut actions = Vec::new();
let mut skipped = Vec::new();
let mut review_requests = Vec::new();
for flow in flows {
review_requests.extend(requests(flow, event_type, &fields));
}
for action in flows.iter().flat_map(|f| evaluate(f, event_type, &fields)) {
if is_agent(&action.agent) {
actions.push(action);
@ -62,6 +69,7 @@ pub fn route(
next.holders.retain(|h| !cleared.contains(h));
Routed {
actions,
requests: review_requests,
skipped,
next,
}
@ -186,6 +194,44 @@ mod tests {
);
}
/// A push to a PR makes one request for the classifier's review, whether
/// or not the classifier is an agent, and touches nothing else.
#[test]
fn a_push_to_a_pull_request_requests_the_classifiers_review_once() {
let flows = builtins();
let pull = Delivery {
is_pull: true,
..delivery("synchronized", &[])
};
let before = Snapshot {
assignees: Some(BTreeSet::from(["atlas".to_owned()])),
holders: BTreeSet::from(["atlas".to_owned()]),
};
let r = route("pull_request_sync", &pull, &before, &flows, |_| false);
assert_eq!(
r.requests,
[ReviewRequest {
flow: "head-classifier".to_owned(),
node: "request".to_owned(),
repo: "o/r".to_owned(),
number: 9,
reviewer: "review-classifier".to_owned(),
}]
);
assert!(r.actions.is_empty() && r.skipped.is_empty());
assert_eq!(r.next, before);
// The source gates: another PR event requests nothing.
for other in ["pull_request", "pull_request_assign", "issues"] {
let r = route(other, &pull, &before, &flows, |_| true);
assert!(r.requests.is_empty(), "{other}");
}
// Only a PR can be asked to review.
let issue = delivery("synchronized", &[]);
let r = route("pull_request_sync", &issue, &before, &flows, |_| true);
assert!(r.requests.is_empty());
}
#[test]
fn an_event_no_flow_matches_changes_nothing() {
let before = Snapshot {

View file

@ -188,19 +188,28 @@ mod tests {
#[test]
fn with_no_document_the_router_runs_the_builtins() {
let mut f = Flows::builtins();
assert_eq!(ids(&f), ["assigned", "unassigned", "closed"]);
assert_eq!(
ids(&f),
["assigned", "unassigned", "closed", "head-classifier"]
);
f.apply(Read::Absent);
assert_eq!(ids(&f), ["assigned", "unassigned", "closed"]);
assert_eq!(
ids(&f),
["assigned", "unassigned", "closed", "head-classifier"]
);
// An empty document is the same.
f.apply(at(1, doc(&[], &[])));
assert_eq!(ids(&f), ["assigned", "unassigned", "closed"]);
assert_eq!(
ids(&f),
["assigned", "unassigned", "closed", "head-classifier"]
);
}
#[test]
fn a_document_switches_builtins_off_and_adds_custom_flows() {
let mut f = Flows::builtins();
f.apply(at(1, doc(&["unassigned"], &["triage"])));
assert_eq!(ids(&f), ["assigned", "closed", "triage"]);
assert_eq!(ids(&f), ["assigned", "closed", "head-classifier", "triage"]);
}
#[test]
@ -208,7 +217,7 @@ mod tests {
let mut f = Flows::builtins();
f.apply(at(1, doc(&["closed"], &["triage"])));
let before = ids(&f).join(",");
assert_eq!(before, "assigned,unassigned,triage");
assert_eq!(before, "assigned,unassigned,head-classifier,triage");
for bad in [
junk(2, b"not json"),
junk(3, br#"{"version":2,"flows":[]}"#),
@ -221,14 +230,20 @@ mod tests {
// The next good revision replaces them: the control that the bad
// ones were kept out by being bad, not by the loader being stuck.
f.apply(at(5, doc(&[], &[])));
assert_eq!(ids(&f), ["assigned", "unassigned", "closed"]);
assert_eq!(
ids(&f),
["assigned", "unassigned", "closed", "head-classifier"]
);
}
#[test]
fn a_bad_first_document_is_the_builtins_not_nothing() {
let mut f = Flows::builtins();
f.apply(junk(1, br#"{"version":9}"#));
assert_eq!(ids(&f), ["assigned", "unassigned", "closed"]);
assert_eq!(
ids(&f),
["assigned", "unassigned", "closed", "head-classifier"]
);
}
#[test]
@ -236,7 +251,10 @@ mod tests {
let mut f = Flows::builtins();
f.apply(at(1, doc(&["closed"], &["triage"])));
f.apply(Read::Absent);
assert_eq!(ids(&f), ["assigned", "unassigned", "closed"]);
assert_eq!(
ids(&f),
["assigned", "unassigned", "closed", "head-classifier"]
);
}
#[test]
@ -254,6 +272,9 @@ mod tests {
revision: 1,
bytes: serde_json::to_vec(&d).unwrap(),
});
assert_eq!(ids(&f), ["assigned", "unassigned", "closed"]);
assert_eq!(
ids(&f),
["assigned", "unassigned", "closed", "head-classifier"]
);
}
}