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
|
|
@ -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"], &[], &[]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue