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

@ -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;

View file

@ -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:")));

View 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);
}
}