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

@ -32,7 +32,8 @@ grafana, the controller, authelia, …) →
its repo's own yes/no rules and posts the result as a review. → its repo's own yes/no rules and posts the result as a review. →
[`review-classifier.md`](review-classifier.md) [`review-classifier.md`](review-classifier.md)
- **notification router** — forge events turned into todos on the agents - **notification router** — forge events turned into todos on the agents
they concern: assigned, unassigned, cleared on close. → they concern: assigned, unassigned, cleared on close; and the classifier's
review requested on a push. →
[`notify-router.md`](notify-router.md) [`notify-router.md`](notify-router.md)
- **swarm UI** — the operator's day-to-day surface, on the swarm apex, - **swarm UI** — the operator's day-to-day surface, on the swarm apex,
`admins` only. → [`ui.md`](ui.md) `admins` only. → [`ui.md`](ui.md)
@ -415,7 +416,7 @@ itself, all under `https://<swarm.domain>/webhook/forge/`:
- an instance-wide `pull_request_review_request` system hook, which starts - an instance-wide `pull_request_review_request` system hook, which starts
the [review classifier](review-classifier.md#trigger-and-lifecycle) the [review classifier](review-classifier.md#trigger-and-lifecycle)
- instance-wide `issues` + `issue_assign` and `pull_request` + - instance-wide `issues` + `issue_assign` and `pull_request` +
`pull_request_assign` system hooks, whose deliveries it `pull_request_assign` + `pull_request_sync` system hooks, whose deliveries it
keeps in the `SWARM_EVENTS` stream for the keeps in the `SWARM_EVENTS` stream for the
[notification router](notify-router.md#the-path) [notification router](notify-router.md#the-path)

View file

@ -41,8 +41,9 @@ router notices an unassign only on an issue it has seen an assign event for.
1. swarm-controller registers two instance-wide hooks, one for the `issues` 1. swarm-controller registers two instance-wide hooks, one for the `issues`
and `issue_assign` events at `/webhook/forge/issues` and one for and `issue_assign` events at `/webhook/forge/issues` and one for
`pull_request` and `pull_request_assign` at `/webhook/forge/pull-request`. `pull_request`, `pull_request_assign` and `pull_request_sync` (a push to an
At each start it adds any of these events a registered hook lacks. open PR's branch) at `/webhook/forge/pull-request`. At each start it adds
any of these events a registered hook lacks.
2. On each delivery the controller checks the HMAC and publishes the body, 2. On each delivery the controller checks the HMAC and publishes the body,
unchanged, to the `SWARM_EVENTS` stream on unchanged, to the `SWARM_EVENTS` stream on
`$SWARM.events.forge.<X-Forgejo-Event-Type>`, such as `$SWARM.events.forge.<X-Forgejo-Event-Type>`, such as
@ -68,16 +69,19 @@ The router holds no forge token and no webhook secret, and registers no hook.
A flow is a graph of nodes joined by edges: one `source` node naming the forge A flow is a graph of nodes joined by edges: one `source` node naming the forge
event types it starts from, `match` nodes that pass when every condition event types it starts from, `match` nodes that pass when every condition
holds, and `todo` nodes that emit one todo op per recipient. A match node can holds, `todo` nodes that emit one todo op per recipient, and `request_reviewer`
have more than one child, and each one runs, so one event can reach several nodes that ask for a review. A match node can have more than one child, and
todos. A flow must be a tree: one source, one parent for every other node, each one runs, so one event can reach several leaves. A flow must be a tree:
every node reachable, no children under a `todo`. The most is 64 nodes, 16 one source, one parent for every other node, every node reachable, no children
under a `todo` or a `request_reviewer`. The most is 64 nodes, 16
levels deep, and 100 custom flows. A `todo` node sends to the agents an event levels deep, and 100 custom flows. A `todo` node sends to the agents an event
field holds (`assignees_added`, `todo_holders`, …) or to one named agent. field holds (`assignees_added`, `todo_holders`, …) or to one named agent.
The router ships the three flows above, each a source → match on `action` → The router ships the three flows above, each a source → match on `action` →
todo. The code defines them (`swarm_wire::notify_flows::builtins`), so a todo, and `head-classifier`: source `pull_request_sync` → `request_reviewer`
build that changes one reaches every swarm. A custom flow has the same type and for `review-classifier` ([below](#requesting-a-review)). The code defines them
(`swarm_wire::notify_flows::builtins`), so a build that changes one reaches
every swarm. Each switches off on `/notifications`. A custom flow has the same type and
the same evaluator, so it can do anything a built-in does. the same evaluator, so it can do anything a built-in does.
**The stored document.** swarm-controller keeps the rest in one document, the **The stored document.** swarm-controller keeps the rest in one document, the
@ -86,8 +90,8 @@ switched off, and the custom flows. Only the controller writes it, through
`GET` and `PUT /api/notify/flows`. A `PUT` replaces the whole document in one `GET` and `PUT /api/notify/flows`. A `PUT` replaces the whole document in one
compare-and-set. The controller answers 400 unless every flow passes the tree compare-and-set. The controller answers 400 unless every flow passes the tree
check above and reads only what exists: a source event from `issues`, check above and reads only what exists: a source event from `issues`,
`issue_assign`, `pull_request` or `pull_request_assign`, and condition and `issue_assign`, `pull_request`, `pull_request_assign` or `pull_request_sync`,
recipient fields from the event's field list. The same goes for the `{field}` and condition and recipient fields from the event's field list. The same goes for the `{field}`
placeholders in a todo's key and text. A `PUT` must carry the `revision` it read, `0` for no placeholders in a todo's key and text. A `PUT` must carry the `revision` it read, `0` for no
document, and gets 409 when the stored revision is another. Swarm UI's document, and gets 409 when the stored revision is another. Swarm UI's
[`/notifications`](ui.md#notification-flows) page shows the document and [`/notifications`](ui.md#notification-flows) page shows the document and
@ -100,8 +104,8 @@ accepts:
- `source_events`: `[{name, description}]`, the source events. - `source_events`: `[{name, description}]`, the source events.
- `fields`: `[{name, description}]`, the condition and recipient fields, and - `fields`: `[{name, description}]`, the condition and recipient fields, and
the `{field}` placeholders. the `{field}` placeholders.
- `node_kinds`: `[{kind, description, fields}]` for `source`, `match` and - `node_kinds`: `[{kind, description, fields}]` for `source`, `match`, `todo`
`todo`; each field is `{name, description, required, values}`, and `values` and `request_reviewer`; each field is `{name, description, required, values}`, and `values`
lists the accepted `{name, description}` pairs (the `upsert` and `clear` of a lists the accepted `{name, description}` pairs (the `upsert` and `clear` of a
todo's `op`), empty for other fields. todo's `op`), empty for other fields.
- `limits`: `{max_nodes, max_depth, max_flows}`. - `limits`: `{max_nodes, max_depth, max_flows}`.
@ -109,6 +113,39 @@ accepts:
A controller that predates `schema` leaves it out of the answer; a client reads A controller that predates `schema` leaves it out of the answer; a client reads
it as optional. it as optional.
**Requesting a review.** A `request_reviewer` node has one field, `reviewer`,
a forge login. For the PR the event concerns (`repo` and `number`; an event
about an issue sends nothing), the router sends a NATS request on
`$SWARM.router.sink.request-reviewer` with `{repo, number, reviewer}` and
swarm-controller answers with one line:
| Reply | Meaning |
| --------------- | ---------------------------------------------------------------------------------- |
| `requested` | the forge accepted the request |
| `skipped:<why>` | the reviewer's review of the PR's head already stands; the controller sent nothing |
| `error:<msg>` | the forge refused or didn't answer, or the request didn't decode |
The controller asks the forge under its own token, which the router never
holds. It skips a reviewer whose latest review is neither stale (the PR's head
moved past the commit it reviewed) nor dismissed, because requesting a review
from someone who already submitted one makes the forge dismiss it, approval
included. The controller asks again once the review is older than the head.
`hive-forge pr assign-reviewer` applies the same rule (`hive_types::review`).
The router sends the request once, after it queued the event's todo ops and
wrote the snapshot, and logs the answer. A controller that's down or answers
`error:` doesn't hold up the event or any agent's todos, and the request is
not retried: the next push asks again. A request repeated for the same head is
harmless: a reviewer with a pending request is a no-op for the forge, and one
who has reviewed gets `skipped`.
`head-classifier` requests the review classifier's review on a push to any open
PR. The classifier then runs as it does for any requested review
([`review-classifier.md`](review-classifier.md#trigger-and-lifecycle)), through
its own request queue, which replaces a waiting request for the same PR. A
repo the classifier's account can't read, or a PR that account opened, answers
`error:`.
**What the router runs.** It reads the key at most every 10 seconds, when an **What the router runs.** It reads the key at most every 10 seconds, when an
event arrives, and runs the built-ins you left on, then the custom flows that event arrives, and runs the built-ins you left on, then the custom flows that
are on and pass the tree check. It logs a custom flow that fails the check, are on and pass the tree check. It logs a custom flow that fails the check,
@ -127,20 +164,25 @@ document never leaves it with no flows. The controller refuses to overwrite a
document of a version it doesn't know. document of a version it doesn't know.
**Deploying in any order.** The controller, the router and the UI can be on **Deploying in any order.** The controller, the router and the UI can be on
different hosts. A controller that predates this leaves the key absent, and the different hosts. A router that predates `request_reviewer` reads a document
holding such a node (a custom flow with one) as one it can't read and keeps the
flows it had; it also has no `head-classifier`. A router that has it, with a
controller that predates it, gets no answer to the request and logs that. A controller that predates this leaves the key absent, and the
router runs the built-ins as before. A router that predates this ignores the router runs the built-ins as before. A router that predates this ignores the
document, so the page and the API show a state the router isn't yet running. document, so the page and the API show a state the router isn't yet running.
## Credentials and grants ## Credentials and grants
| Principal | Grant | | Principal | Grant |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `swarm-notify-router`, in its container | store: `read` on `swarm/services/<router client id>/oidc/client`. Queue: its own consumer on `SWARM_EVENTS` (create, pull, ack), publish on `$SWARM.router.todo.*`, create `ROUTER_TODOS`, its own `notify-router-state` bucket, read on the `notify-flows` bucket, and `STREAM.INFO` on the agent-status bucket, which lists the agents | | `swarm-notify-router`, in its container | store: `read` on `swarm/services/<router client id>/oidc/client`. Queue: its own consumer on `SWARM_EVENTS` (create, pull, ack), publish on `$SWARM.router.todo.*` and on `$SWARM.router.sink.request-reviewer`, create `ROUTER_TODOS`, its own `notify-router-state` bucket, read on the `notify-flows` bucket, and `STREAM.INFO` on the agent-status bucket, which lists the agents |
| swarm-controller | publish on `$SWARM.events.forge.*`; creates and writes the `notify-flows` bucket (every bucket, by its `$KV.*.>` grant) | | swarm-controller | publish on `$SWARM.events.forge.*`; subscribe to `$SWARM.router.sink.request-reviewer` and answer each request once, for 30 seconds; creates and writes the `notify-flows` bucket (every bucket, by its `$KV.*.>` grant) |
| an agent, under its own credential | create its own consumer `todo-<agent>` in the form whose filter is `$SWARM.router.todo.<agent>`, pull from it, and ack what it delivered | | an agent, under its own credential | create its own consumer `todo-<agent>` in the form whose filter is `$SWARM.router.todo.<agent>`, pull from it, and ack what it delivered |
No other client may publish on `$SWARM.router.todo.*` or No other client may publish on `$SWARM.router.todo.*`, `$SWARM.router.sink.request-reviewer` or
`$SWARM.events.forge.*`, so each has one writer. The agent's create subject `$SWARM.events.forge.*`, so each has one writer, and the controller alone
hears the review requests. The review request needs no secret of its own: the
controller asks the forge under the token it already holds. The agent's create subject
carries the consumer's filter, and the queue server checks it against the carries the consumer's filter, and the queue server checks it against the
request, so an agent can't create a consumer that delivers another agent's request, so an agent can't create a consumer that delivers another agent's
todos. todos.

View file

@ -86,6 +86,10 @@ as a reviewer on a PR starts a run:
4. The controller posts the result on the PR as the service account's 4. The controller posts the result on the PR as the service account's
review, on the commit it read. review, on the commit it read.
A push starts no run by itself. The notification router's `head-classifier`
flow requests the account's review on every push to an open PR, unless its
review of the new head already stands ([`notify-router.md`](notify-router.md#requesting-a-review)).
When a post can't reach the secret store or the forge, because the When a post can't reach the secret store or the forge, because the
connection fails or a try gets no answer within two minutes, the controller connection fails or a try gets no answer within two minutes, the controller
tries it up to six times over about five minutes, enough to outlast a tries it up to six times over about five minutes, enough to outlast a

View file

@ -491,7 +491,8 @@ swarm-controller keeps every `issues` and `pull_request` delivery from its
instance-wide hooks in the `SWARM_EVENTS` stream. The router reads that instance-wide hooks in the `SWARM_EVENTS` stream. The router reads that
stream and queues todos for the agents an event concerns: an agent assigned to stream and queues todos for the agents an event concerns: an agent assigned to
an issue or pull request gets a todo, one taken off it gets that todo rewritten an issue or pull request gets a todo, one taken off it gets that todo rewritten
to say so, and closing it clears them. Each agent's harness takes its todos to say so, and closing it clears them. A push to an open pull request makes
it ask swarm-controller to request the review classifier's review. Each agent's harness takes its todos
from the `ROUTER_TODOS` stream, so an agent that's down gets them when it's from the `ROUTER_TODOS` stream, so an agent that's down gets them when it's
back. back.

View file

@ -224,7 +224,7 @@ in two groups. Each **built-in** flow has an on/off switch; switching one off
stops the router running it from its next event, and switching it on again stops the router running it from its next event, and switching it on again
restores it. Each **custom** flow shows its name and whether it's on. Every restores it. Each **custom** flow shows its name and whether it's on. Every
flow draws as an indented tree: the forge events it starts from, the flow draws as an indented tree: the forge events it starts from, the
conditions under it, and the todo each branch ends in. conditions under it, and the todo or review request each branch ends in.
A built-in flow can be **duplicated** into a custom one, and **new flow** starts a A built-in flow can be **duplicated** into a custom one, and **new flow** starts a
custom flow with just a source; either opens the flow in the editor. A custom custom flow with just a source; either opens the flow in the editor. A custom

View file

@ -73,6 +73,14 @@ test("describeNode: says what each kind does", () => {
"clear todos forge:{repo}#{number}:* of {todo_holders}", "clear todos forge:{repo}#{number}:* of {todo_holders}",
); );
assert.equal(describeNode(flow.nodes[4]), "todo to triage, key k: look"); assert.equal(describeNode(flow.nodes[4]), "todo to triage, key k: look");
assert.equal(
describeNode({
id: "r",
kind: "request_reviewer",
reviewer: "review-classifier",
}),
"request a review from review-classifier",
);
}); });
test("withBuiltin: switching off adds once, switching on removes", () => { test("withBuiltin: switching off adds once, switching on removes", () => {

View file

@ -55,6 +55,7 @@ pub mod legacy_tokens;
pub mod objects; pub mod objects;
pub mod operator_profiles; pub mod operator_profiles;
pub mod pull_diff; pub mod pull_diff;
pub mod review_request;
pub mod runner_token; pub mod runner_token;
pub mod site_admin; pub mod site_admin;
@ -1760,12 +1761,12 @@ fn hook_registration(kind: DeliveryKind) -> (&'static [&'static str], HookScope<
// classifier's account access can request its review. // classifier's account access can request its review.
DeliveryKind::ReviewRequest => (&["pull_request_review_request"], HookScope::Instance), DeliveryKind::ReviewRequest => (&["pull_request_review_request"], HookScope::Instance),
// Instance-wide, for the notification router, which reads the assign // Instance-wide, for the notification router, which reads the assign
// events. They are named: Forgejo's web form shows assignment as a // events and a push to a PR's branch. They are named: Forgejo's web
// box of its own, so a hook edited there can drop it while keeping // form shows each as a box of its own, so a hook edited there can drop
// `issues` or `pull_request`. // one while keeping `issues` or `pull_request`.
DeliveryKind::Issues => (&["issues", "issue_assign"], HookScope::Instance), DeliveryKind::Issues => (&["issues", "issue_assign"], HookScope::Instance),
DeliveryKind::PullRequest => ( DeliveryKind::PullRequest => (
&["pull_request", "pull_request_assign"], &["pull_request", "pull_request_assign", "pull_request_sync"],
HookScope::Instance, HookScope::Instance,
), ),
} }
@ -2111,7 +2112,7 @@ mod tests {
assert_eq!(events(DeliveryKind::Issues), ["issues", "issue_assign"]); assert_eq!(events(DeliveryKind::Issues), ["issues", "issue_assign"]);
assert_eq!( assert_eq!(
events(DeliveryKind::PullRequest), events(DeliveryKind::PullRequest),
["pull_request", "pull_request_assign"] ["pull_request", "pull_request_assign", "pull_request_sync"]
); );
// The events a stored flow's source may name are the ones the hooks // The events a stored flow's source may name are the ones the hooks
// deliver to the router. // deliver to the router.

View file

@ -0,0 +1,194 @@
//! A PR's reviews, and asking for one more, under the controller's token.
//!
//! The router's `request_reviewer` sink reaches the forge through here
//! ([`crate::request_reviewer`]). Which reviews still stand is
//! [`hive_types::review`], the rule `hive-forge pr assign-reviewer` applies
//! too.
use anyhow::{Context, Result};
use forgejo_api::structs::PullReviewRequestOptions;
use hive_types::review::{Listed, ReviewInfo, latest_reviews};
use super::Client;
/// One review listing takes this many pages at most, 50 reviews a page.
const MAX_PAGES: u32 = 10;
const PAGE_SIZE: u32 = 50;
impl Client {
/// The latest verdict of each reviewer of PR `number` of `owner/repo`,
/// judged against the PR's head as it is now.
///
/// # Errors
/// A forge failure, with the [`forgejo_api::ForgejoError`] underneath.
pub async fn pull_reviews(
&self,
owner: &str,
repo: &str,
number: i64,
) -> Result<Vec<ReviewInfo>> {
let pull = self
.api
.call(|api| api.repo_get_pull_request(owner, repo, number).into_future())
.await
.with_context(|| format!("read PR {number} of {owner}/{repo}"))?;
let head = pull.head.and_then(|h| h.sha);
let mut reviews = Vec::new();
for page in 1..=MAX_PAGES {
let (_, batch) = self
.api
.call(|api| {
api.repo_list_pull_reviews(owner, repo, number)
.page(page)
.page_size(PAGE_SIZE)
.into_future()
})
.await
.with_context(|| format!("list the reviews of PR {number} of {owner}/{repo}"))?;
let short = batch.len() < PAGE_SIZE as usize;
reviews.extend(batch);
if short {
break;
}
}
Ok(latest_reviews(
reviews.iter().map(|r| Listed {
login: r.user.as_ref().and_then(|u| u.login.as_deref()),
state: r.state.as_deref(),
commit_id: r.commit_id.as_deref(),
stale: r.stale,
dismissed: r.dismissed,
}),
head.as_deref(),
))
}
/// Ask `reviewer` to review PR `number` of `owner/repo`. From a reviewer
/// whose review stands, the forge dismisses it: check
/// [`hive_types::review::standing`] first.
///
/// # Errors
/// The forge refused: no such reviewer, the PR's author, a reviewer with
/// no access to the repo.
pub async fn request_review(
&self,
owner: &str,
repo: &str,
number: i64,
reviewer: &str,
) -> Result<()> {
let body = PullReviewRequestOptions {
reviewers: Some(vec![reviewer.to_owned()]),
team_reviewers: None,
};
self.api
.call(|api| {
api.repo_create_pull_review_requests(owner, repo, number, body.clone())
.into_future()
})
.await
.with_context(|| {
format!("request {reviewer}'s review of PR {number} of {owner}/{repo}")
})?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use axum::http::{Method, StatusCode, Uri};
use super::*;
/// A stand-in forge holding PR 7 of `o/r` at head `new`, with `reviews`
/// listed, answering the request post with `post`. Records each request as
/// method, path and body.
async fn forge(
reviews: &'static str,
post: u16,
) -> (Client, Arc<Mutex<Vec<(String, String, String)>>>) {
let seen = Arc::new(Mutex::new(Vec::new()));
let recorded = Arc::clone(&seen);
let app = axum::Router::new().fallback(
move |method: Method, uri: Uri, body: axum::body::Bytes| {
let recorded = Arc::clone(&recorded);
async move {
recorded.lock().unwrap().push((
method.to_string(),
uri.path().to_owned(),
String::from_utf8_lossy(&body).into_owned(),
));
match (method.as_str(), uri.path()) {
("GET", "/api/v1/repos/o/r/pulls/7") => (
StatusCode::OK,
r#"{"number":7,"head":{"sha":"new"},"requested_reviewers":[]}"#
.to_owned(),
),
("GET", "/api/v1/repos/o/r/pulls/7/reviews") => {
(StatusCode::OK, reviews.to_owned())
}
("POST", "/api/v1/repos/o/r/pulls/7/requested_reviewers") => (
StatusCode::from_u16(post).unwrap(),
if post == 201 {
"[]".to_owned()
} else {
r#"{"message":"refused"}"#.to_owned()
},
),
_ => (StatusCode::NOT_FOUND, r#"{"message":"no"}"#.to_owned()),
}
}
},
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = url::Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap();
tokio::spawn(async move { axum::serve(listener, app).await });
(Client::for_test_forge(url), seen)
}
const REVIEWS: &str = r#"[
{"id":1,"user":{"login":"review-classifier"},"state":"APPROVED","commit_id":"old","stale":false,"dismissed":false},
{"id":2,"user":{"login":"argus"},"state":"APPROVED","commit_id":"new","stale":false,"dismissed":false}
]"#;
/// The head is read from the PR, so a review of an older commit is
/// superseded though the forge still reports `stale: false`.
#[tokio::test]
async fn reviews_are_judged_against_the_head_read_now() {
let (client, _) = forge(REVIEWS, 201).await;
let reviews = client.pull_reviews("o", "r", 7).await.unwrap();
let standing = |login| hive_types::review::standing(&reviews, login).is_some();
assert!(
!standing("review-classifier"),
"reviewed `old`, head is `new`"
);
assert!(standing("argus"), "control: reviewed the head");
}
#[tokio::test]
async fn a_request_posts_the_reviewer_to_the_pull_requests_reviewer_list() {
let (client, seen) = forge("[]", 201).await;
client
.request_review("o", "r", 7, "review-classifier")
.await
.unwrap();
let seen = seen.lock().unwrap();
let (method, path, body) = seen.last().unwrap();
assert_eq!(method, "POST");
assert_eq!(path, "/api/v1/repos/o/r/pulls/7/requested_reviewers");
let body: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(body["reviewers"], serde_json::json!(["review-classifier"]));
}
#[tokio::test]
async fn a_refused_request_is_an_error_naming_the_reviewer() {
let (client, _) = forge("[]", 422).await;
let err = client
.request_review("o", "r", 7, "review-classifier")
.await
.unwrap_err();
assert!(format!("{err:#}").contains("review-classifier"), "{err:#}");
}
}

View file

@ -55,6 +55,7 @@ mod otel_http_client;
mod pulls; mod pulls;
mod queue_identity; mod queue_identity;
mod read_policy; mod read_policy;
mod request_reviewer;
mod review_classifier; mod review_classifier;
mod routes; mod routes;
mod schedules; mod schedules;
@ -239,9 +240,11 @@ async fn main() -> Result<()> {
serve(listener, state).await serve(listener, state).await
} }
/// Start taking the queued review requests, then serve `state` on `listener`. /// Start taking the queued review requests and answering the notification
/// router's, then serve `state` on `listener`.
async fn serve(listener: tokio::net::UnixListener, state: AppState) -> Result<()> { async fn serve(listener: tokio::net::UnixListener, state: AppState) -> Result<()> {
review_classifier::review::spawn_queue(&state); review_classifier::review::spawn_queue(&state);
request_reviewer::spawn(&state);
axum::serve(listener, build_app(state)) axum::serve(listener, build_app(state))
.await .await
.context("serving swarm-controller") .context("serving swarm-controller")

View file

@ -294,7 +294,8 @@ mod tests {
let Json(view) = get_flows(State(state.clone())).await.expect("get"); let Json(view) = get_flows(State(state.clone())).await.expect("get");
assert_eq!(view.revision, 0); assert_eq!(view.revision, 0);
assert!(view.disabled_builtins.is_empty() && view.flows.is_empty()); assert!(view.disabled_builtins.is_empty() && view.flows.is_empty());
assert_eq!(view.builtins.len(), 3); assert_eq!(view.builtins.len(), 4);
assert!(view.builtins.iter().any(|f| f.id == "head-classifier"));
let json = serde_json::to_value(&view).expect("serializes"); let json = serde_json::to_value(&view).expect("serializes");
assert_eq!(json["schema"]["limits"]["max_nodes"], 64); assert_eq!(json["schema"]["limits"]["max_nodes"], 64);
assert_eq!(json["schema"]["node_kinds"][0]["kind"], "source"); assert_eq!(json["schema"]["node_kinds"][0]["kind"], "source");
@ -321,7 +322,7 @@ mod tests {
.expect("read") .expect("read")
.expect("stored"); .expect("stored");
let doc = Document::decode(&bytes).expect("the router reads it"); let doc = Document::decode(&bytes).expect("the router reads it");
assert_eq!(doc.active().0.len(), 3, "2 built-ins and the custom flow"); assert_eq!(doc.active().0.len(), 4, "3 built-ins and the custom flow");
// A refused save changes nothing: an orphan node, and a built-in that does not exist. // A refused save changes nothing: an orphan node, and a built-in that does not exist.
let mut orphan = custom("orphan"); let mut orphan = custom("orphan");

View file

@ -0,0 +1,388 @@
//! Requesting a review for the notification router, which holds no forge
//! token.
//!
//! The router sends a [`RequestReviewer`] on
//! `swarm_wire::router_sink::REQUEST_REVIEWER_SUBJECT` and [`spawn`]'s
//! responder answers with an [`Outcome`] on the reply subject, having acted
//! under the controller's token. A reviewer whose review of the PR's head
//! already stands is skipped: requesting it again makes the forge dismiss it,
//! approval included. What stands is [`hive_types::review`], the rule
//! `hive-forge pr assign-reviewer` applies too.
//!
//! The request may be repeated (the router's event comes back after a failed
//! step): a reviewer with a request pending is a no-op for the forge, and one
//! who has reviewed since stands and is skipped.
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use futures_util::StreamExt as _;
use hive_types::review::{ReviewInfo, standing};
use swarm_wire::router_sink::{Outcome, REQUEST_REVIEWER_SUBJECT, RequestReviewer, WORK_DEADLINE};
use crate::forge;
use crate::jobq::Late;
use crate::review_classifier::split_repo;
use crate::routes::AppState;
/// How long to wait before subscribing again.
const RETRY: Duration = Duration::from_secs(5);
/// The forge as the responder uses it: [`forge::Client`] in production, a fake
/// in the tests.
pub(crate) trait ReviewForge {
fn reviews(
&self,
owner: &str,
repo: &str,
number: i64,
) -> impl Future<Output = anyhow::Result<Vec<ReviewInfo>>> + Send;
fn request(
&self,
owner: &str,
repo: &str,
number: i64,
reviewer: &str,
) -> impl Future<Output = anyhow::Result<()>> + Send;
}
impl ReviewForge for forge::Client {
async fn reviews(
&self,
owner: &str,
repo: &str,
number: i64,
) -> anyhow::Result<Vec<ReviewInfo>> {
self.pull_reviews(owner, repo, number).await
}
async fn request(
&self,
owner: &str,
repo: &str,
number: i64,
reviewer: &str,
) -> anyhow::Result<()> {
self.request_review(owner, repo, number, reviewer).await
}
}
/// Answer the router's review requests for as long as the controller runs.
pub(crate) fn spawn(state: &AppState) {
let Some(client) = state.status.as_ref().map(|s| s.queue_client()) else {
tracing::warn!("no swarm queue is configured on this host; review requests go unanswered");
return;
};
let forge = Arc::clone(&state.forge);
tokio::spawn(async move { follow(&client, &forge).await });
}
/// Subscribe to the requests and answer each, and subscribe again if the
/// subscription ends.
async fn follow<F: ReviewForge>(client: &async_nats::Client, forge: &Late<Arc<F>>) {
loop {
match client.subscribe(REQUEST_REVIEWER_SUBJECT).await {
Ok(mut requests) => {
tracing::info!(
subject = REQUEST_REVIEWER_SUBJECT,
"request reviewer: answering requests"
);
while let Some(msg) = requests.next().await {
let Some(reply) = msg.reply.clone() else {
continue;
};
let outcome = answer(forge.get().map(Arc::as_ref), &msg.payload).await;
match &outcome {
Outcome::Error(why) => {
tracing::warn!(%why, "request reviewer: not requested");
}
other => {
tracing::info!(outcome = %other.encode(), "request reviewer: answered");
}
}
if let Err(e) = client.publish(reply, outcome.encode().into()).await {
tracing::warn!(error = %e, "request reviewer: answering failed");
}
}
tracing::warn!("request reviewer: subscription ended");
}
Err(e) => tracing::warn!(error = %e, "request reviewer: subscribe failed"),
}
tokio::time::sleep(RETRY).await;
}
}
/// What to answer to one request `payload`, `forge` being `None` while the
/// controller has no forge access.
async fn answer<F: ReviewForge>(forge: Option<&F>, payload: &[u8]) -> Outcome {
let request: RequestReviewer = match serde_json::from_slice(payload) {
Ok(request) => request,
Err(e) => return Outcome::Error(format!("the request does not decode: {e}")),
};
let Some(forge) = forge else {
return Outcome::Error("this controller has no forge access yet".to_owned());
};
match tokio::time::timeout(WORK_DEADLINE, act(forge, &request)).await {
Ok(outcome) => outcome,
Err(_) => Outcome::Error(format!(
"the forge did not answer within {}s",
WORK_DEADLINE.as_secs()
)),
}
}
async fn act<F: ReviewForge>(forge: &F, request: &RequestReviewer) -> Outcome {
let RequestReviewer {
repo,
number,
reviewer,
} = request;
let (owner, name) = match split_repo(repo) {
Ok(split) => split,
Err(why) => return Outcome::Error(why),
};
let Ok(number_i64) = i64::try_from(*number) else {
return Outcome::Error(format!("PR number {number} is out of range"));
};
if reviewer.trim().is_empty() {
return Outcome::Error("no reviewer named".to_owned());
}
let reviews = match forge.reviews(owner, name, number_i64).await {
Ok(reviews) => reviews,
Err(e) => return Outcome::Error(format!("{e:#}")),
};
if let Some(review) = standing(&reviews, reviewer) {
return Outcome::Skipped(format!(
"{reviewer} already has a standing {} review on {repo}#{number}",
review.state
));
}
match forge.request(owner, name, number_i64, reviewer).await {
Ok(()) => Outcome::Requested,
Err(e) => Outcome::Error(format!("{e:#}")),
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
#[derive(Default)]
struct Fake {
reviews: Vec<ReviewInfo>,
reviews_fail: bool,
request_fail: bool,
asked: Mutex<Vec<(String, i64, String)>>,
}
impl ReviewForge for Fake {
async fn reviews(&self, _: &str, _: &str, _: i64) -> anyhow::Result<Vec<ReviewInfo>> {
if self.reviews_fail {
anyhow::bail!("the forge is down");
}
Ok(self.reviews.clone())
}
async fn request(
&self,
owner: &str,
repo: &str,
number: i64,
reviewer: &str,
) -> anyhow::Result<()> {
if self.request_fail {
anyhow::bail!("422: the reviewer is the PR's author");
}
self.asked.lock().unwrap().push((
format!("{owner}/{repo}"),
number,
reviewer.to_owned(),
));
Ok(())
}
}
fn review(login: &str, verdict: &str, stale: bool, dismissed: bool) -> ReviewInfo {
ReviewInfo {
login: login.to_owned(),
state: verdict.to_owned(),
stale,
dismissed,
}
}
fn payload(repo: &str, number: u64, reviewer: &str) -> Vec<u8> {
serde_json::to_vec(&RequestReviewer {
repo: repo.to_owned(),
number,
reviewer: reviewer.to_owned(),
})
.unwrap()
}
fn asked(fake: &Fake) -> Vec<(String, i64, String)> {
fake.asked.lock().unwrap().clone()
}
#[tokio::test]
async fn a_reviewer_with_no_review_is_requested() {
let fake = Fake::default();
let got = answer(Some(&fake), &payload("o/r", 7, "review-classifier")).await;
assert_eq!(got, Outcome::Requested);
assert_eq!(
asked(&fake),
[("o/r".to_owned(), 7, "review-classifier".to_owned())]
);
}
/// A review that still stands is skipped, because requesting from its
/// reviewer would make the forge dismiss it.
#[tokio::test]
async fn a_standing_review_is_skipped_and_a_stale_or_dismissed_one_is_requested_again() {
let reviewer = "review-classifier";
let standing = Fake {
reviews: vec![review(reviewer, "APPROVED", false, false)],
..Fake::default()
};
let got = answer(Some(&standing), &payload("o/r", 7, reviewer)).await;
assert!(
matches!(&got, Outcome::Skipped(why) if why.contains("APPROVED") && why.contains("o/r#7")),
"{got:?}"
);
assert!(asked(&standing).is_empty(), "nothing was requested");
for (stale, dismissed) in [(true, false), (false, true)] {
let fake = Fake {
reviews: vec![review(reviewer, "APPROVED", stale, dismissed)],
..Fake::default()
};
let got = answer(Some(&fake), &payload("o/r", 7, reviewer)).await;
assert_eq!(
got,
Outcome::Requested,
"stale {stale}, dismissed {dismissed}"
);
assert_eq!(asked(&fake).len(), 1);
}
// Another reviewer's standing review is no reason to skip this one.
let other = Fake {
reviews: vec![review("argus", "APPROVED", false, false)],
..Fake::default()
};
let got = answer(Some(&other), &payload("o/r", 7, reviewer)).await;
assert_eq!(got, Outcome::Requested);
}
#[tokio::test]
async fn a_forge_failure_is_an_error_and_a_failed_read_requests_nothing() {
let down = Fake {
reviews_fail: true,
..Fake::default()
};
let got = answer(Some(&down), &payload("o/r", 7, "review-classifier")).await;
assert!(
matches!(&got, Outcome::Error(e) if e.contains("the forge is down")),
"{got:?}"
);
assert!(asked(&down).is_empty());
let refused = Fake {
request_fail: true,
..Fake::default()
};
let got = answer(Some(&refused), &payload("o/r", 7, "review-classifier")).await;
assert!(
matches!(&got, Outcome::Error(e) if e.contains("422")),
"{got:?}"
);
}
#[tokio::test]
async fn a_request_that_is_malformed_or_has_no_forge_is_an_error() {
let fake = Fake::default();
for bad in [
b"not json".to_vec(),
payload("no-slash", 7, "review-classifier"),
payload("o/r", 7, " "),
payload("o/r", u64::MAX, "review-classifier"),
] {
let got = answer(Some(&fake), &bad).await;
assert!(
matches!(got, Outcome::Error(_)),
"{:?}",
String::from_utf8_lossy(&bad)
);
}
assert!(asked(&fake).is_empty());
let none: Option<&Fake> = None;
let got = answer(none, &payload("o/r", 7, "review-classifier")).await;
assert!(
matches!(&got, Outcome::Error(e) if e.contains("no forge")),
"{got:?}"
);
}
/// The built-in flow's reviewer is the account the controller makes.
#[test]
fn the_built_in_flow_requests_the_classifiers_account() {
assert_eq!(
swarm_wire::notify_flows::CLASSIFIER_ACCOUNT,
forge::classifier_account::LOGIN
);
}
/// Against a real server: a request sent the way the router sends it comes
/// back with the outcome, and a subscriber before the controller is up
/// leaves the sender with no responders.
#[tokio::test]
async fn a_request_over_the_queue_is_answered_with_its_outcome() {
let nats = crate::test_nats::TestNats::start();
let router = nats.client().await;
let ask = |body: Vec<u8>| {
let router = router.clone();
async move {
let sent = async_nats::Request::new()
.payload(body.into())
.timeout(Some(Duration::from_secs(5)));
router.send_request(REQUEST_REVIEWER_SUBJECT, sent).await
}
};
assert!(
ask(payload("o/r", 7, "review-classifier")).await.is_err(),
"no controller listening"
);
let fake = Arc::new(Fake {
reviews: vec![review("argus", "APPROVED", false, false)],
..Fake::default()
});
let late: Late<Arc<Fake>> = Arc::default();
late.set(Arc::clone(&fake)).ok().unwrap();
let controller = nats.client().await;
tokio::spawn(async move { follow(&controller, &late).await });
// The subscription is processed once a request gets through.
let mut reply = None;
for _ in 0..50 {
if let Ok(msg) = ask(payload("o/r", 7, "review-classifier")).await {
reply = Some(msg);
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
let reply = reply.expect("the controller answers");
assert_eq!(
Outcome::decode(&String::from_utf8_lossy(&reply.payload)),
Outcome::Requested
);
let skipped = ask(payload("o/r", 7, "argus")).await.unwrap();
assert!(matches!(
Outcome::decode(&String::from_utf8_lossy(&skipped.payload)),
Outcome::Skipped(_)
));
}
}

View file

@ -2,10 +2,11 @@
//! ([`LOGIN`]) runs the repo's rules against the PR and posts the result as //! ([`LOGIN`]) runs the repo's rules against the PR and posts the result as
//! that account's review, on the commit the run read. //! that account's review, on the commit the run read.
//! //!
//! Only a review request starts a run; a push does not. A fresh request //! Only a review request starts a run. A push makes none itself: the
//! retires the account's earlier review: the forge clears the `official` flag //! notification router's `head-classifier` flow requests the review. A fresh
//! on a reviewer's previous reviews when they are requested again, and only //! request retires the account's earlier review: the forge clears the
//! official reviews count toward approvals or block a merge. //! `official` flag on a reviewer's previous reviews when they are requested
//! again, and only official reviews count toward approvals or block a merge.
//! //!
//! A request for a head whose newest recorded review approved or requested //! A request for a head whose newest recorded review approved or requested
//! changes runs nothing: the account posts that verdict again, saying so. A //! changes runs nothing: the account posts that verdict again, saying so. A

View file

@ -293,6 +293,10 @@ impl Policy {
/// controller creates the bucket and is its one writer. /// controller creates the bucket and is its one writer.
/// - **The roster.** `STREAM.INFO` on the agent-status bucket, which lists /// - **The roster.** `STREAM.INFO` on the agent-status bucket, which lists
/// its keys (`<hive>.<agent>`) and writes none. /// its keys (`<hive>.<agent>`) and writes none.
/// - **Review requests.** Publish on the one sink subject
/// `swarm_wire::router_sink::REQUEST_REVIEWER_SUBJECT` and hear the answer
/// on its own inbox. The controller is the subject's one subscriber and
/// holds the forge token; no wildcard over `$SWARM.router.sink`.
fn notify_router_permissions(client_id: &str) -> Permissions { fn notify_router_permissions(client_id: &str) -> Permissions {
use swarm_wire::{forge_events, notify_flows, notify_router, router_todo}; use swarm_wire::{forge_events, notify_flows, notify_router, router_todo};
@ -320,6 +324,7 @@ impl Policy {
format!("$JS.API.STREAM.INFO.{flows}"), format!("$JS.API.STREAM.INFO.{flows}"),
format!("$JS.API.DIRECT.GET.{flows}.>"), format!("$JS.API.DIRECT.GET.{flows}.>"),
format!("$JS.API.STREAM.INFO.{}", Self::agent_status_stream()), format!("$JS.API.STREAM.INFO.{}", Self::agent_status_stream()),
swarm_wire::router_sink::REQUEST_REVIEWER_SUBJECT.to_owned(),
Self::inbox(client_id), Self::inbox(client_id),
]); ]);
Permissions { Permissions {
@ -347,7 +352,8 @@ impl Policy {
/// What a reader may subscribe to: every agent's terminal, its /// What a reader may subscribe to: every agent's terminal, its
/// subagents' terminals and its turn state, which the controller relays /// subagents' terminals and its turn state, which the controller relays
/// to swarm-ui, every hive's permissions import, and its own reply inbox. /// to swarm-ui, every hive's permissions import, the notification router's
/// review requests, and its own reply inbox.
/// ///
/// The three families are the publishers' (`hive-agent`'s `swarm_term` /// The three families are the publishers' (`hive-agent`'s `swarm_term`
/// and `swarm_agent_state`, `swarm_wire::subagent_term`), spelled /// and `swarm_agent_state`, `swarm_wire::subagent_term`), spelled
@ -359,6 +365,7 @@ impl Policy {
swarm_wire::subagent_term::subject_wildcard(), swarm_wire::subagent_term::subject_wildcard(),
swarm_queue_client::agent_state_subject_wildcard(), swarm_queue_client::agent_state_subject_wildcard(),
swarm_wire::agent_permissions::import_subject_wildcard(), swarm_wire::agent_permissions::import_subject_wildcard(),
swarm_wire::router_sink::REQUEST_REVIEWER_SUBJECT.to_owned(),
Self::inbox(client_id), Self::inbox(client_id),
] ]
} }
@ -1737,7 +1744,7 @@ mod tests {
#[test] #[test]
fn the_notify_router_drains_its_own_consumer_and_writes_only_its_own_state() { fn the_notify_router_drains_its_own_consumer_and_writes_only_its_own_state() {
use swarm_queue_client::client_inbox_prefix; use swarm_queue_client::client_inbox_prefix;
use swarm_wire::{forge_events, notify_flows, notify_router, router_todo}; use swarm_wire::{forge_events, notify_flows, notify_router, router_sink, router_todo};
let p = with_router() let p = with_router()
.permissions(NOTIFY_ROUTER) .permissions(NOTIFY_ROUTER)
@ -1758,6 +1765,7 @@ mod tests {
router_todo::subject("atlas"), router_todo::subject("atlas"),
format!("$KV.{state}.42.5571"), format!("$KV.{state}.42.5571"),
format!("$JS.API.DIRECT.GET.KV_{state}.$KV.{state}.42.5571"), format!("$JS.API.DIRECT.GET.KV_{state}.$KV.{state}.42.5571"),
router_sink::REQUEST_REVIEWER_SUBJECT.to_owned(),
"$JS.API.STREAM.INFO.KV_agent-status".to_owned(), "$JS.API.STREAM.INFO.KV_agent-status".to_owned(),
// The flows document: read it. // The flows document: read it.
format!("$JS.API.STREAM.INFO.KV_{flows}"), format!("$JS.API.STREAM.INFO.KV_{flows}"),
@ -1783,6 +1791,8 @@ mod tests {
forge_events::subject("issues"), forge_events::subject("issues"),
"$KV.agent-status.alpha.atlas".to_owned(), "$KV.agent-status.alpha.atlas".to_owned(),
"$KV.agent-schedules.atlas".to_owned(), "$KV.agent-schedules.atlas".to_owned(),
"$SWARM.router.sink.other".to_owned(),
"$SWARM.router.sink.>".to_owned(),
// The flows document: not write it, nor make the bucket. // The flows document: not write it, nor make the bucket.
format!("$KV.{flows}.{}", notify_flows::KEY), format!("$KV.{flows}.{}", notify_flows::KEY),
format!("$JS.API.STREAM.CREATE.KV_{flows}"), format!("$JS.API.STREAM.CREATE.KV_{flows}"),
@ -1798,6 +1808,48 @@ mod tests {
assert_eq!(policy().permissions(NOTIFY_ROUTER), None); assert_eq!(policy().permissions(NOTIFY_ROUTER), None);
} }
/// The router asks and the controller answers: nobody else may send the
/// request, and nobody else may hear it, since the answer acts under the
/// controller's forge token.
#[test]
fn only_the_router_may_send_a_review_request_and_only_the_reader_may_hear_it() {
use swarm_wire::router_sink::{REPLY_DEADLINE, REQUEST_REVIEWER_SUBJECT};
let p = with_router();
let grants = [
(
"the reader",
p.permissions("swarm-controller").expect("reader"),
),
("a hive", p.permissions("hive-alpha").expect("a hive")),
("the router", p.permissions(NOTIFY_ROUTER).expect("router")),
("the minter", p.permissions(CLAUDE_LOGIN).expect("minter")),
("the builder", p.permissions(BUILDER).expect("builder")),
(
"the classifier",
p.permissions(CLASSIFIER).expect("classifier"),
),
("atlas", p.agent_token_permissions("atlas").expect("atlas")),
];
for (who, grant) in &grants {
let sends = grant
.publish
.iter()
.any(|g| covers(g, REQUEST_REVIEWER_SUBJECT));
let hears = may_subscribe(grant, REQUEST_REVIEWER_SUBJECT);
assert_eq!(sends, *who == "the router", "{who} sends: {sends}");
assert_eq!(hears, *who == "the reader", "{who} hears: {hears}");
}
// The controller answers on the reply subject for as long as the
// router waits.
let reader = &grants[0].1;
assert!(
reader.responses >= Some(REPLY_DEADLINE),
"{:?}",
reader.responses
);
}
#[test] #[test]
fn only_the_router_may_queue_an_agents_todo_and_only_the_reader_may_keep_a_forge_event() { fn only_the_router_may_queue_an_agents_todo_and_only_the_reader_may_keep_a_forge_event() {
use swarm_wire::{forge_events, router_todo}; use swarm_wire::{forge_events, router_todo};

View file

@ -5,8 +5,9 @@
//! type and this walk runs both. A `match` node passes when every one of its //! 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 //! 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 //! 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 //! is queued before it. A `todo` node emits one op per recipient and a
//! runs only flows that pass [`Flow::validate`]. //! `request_reviewer` node one request for a review. The router runs only
//! flows that pass [`Flow::validate`].
use std::collections::BTreeMap; use std::collections::BTreeMap;
@ -32,17 +33,27 @@ pub struct Action {
pub op: RouterTodoOp, pub op: RouterTodoOp,
} }
/// Walk `flow` for one event of type `event_type` and return the ops it /// One request for `reviewer`'s review of pull request `number` of `repo`.
/// emits. Assumes [`Flow::validate`] passed. #[derive(Clone, Debug, PartialEq, Eq)]
pub fn evaluate(flow: &Flow, event_type: &str, fields: &Fields) -> Vec<Action> { 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 let Some(root) = flow
.nodes .nodes
.iter() .iter()
.find(|n| matches!(&n.kind, NodeKind::Source { events } if events.iter().any(|e| e == event_type))) .find(|n| matches!(&n.kind, NodeKind::Source { events } if events.iter().any(|e| e == event_type)))
else { else {
return Vec::new(); return;
}; };
let mut actions = Vec::new();
let mut stack = vec![root.id.as_str()]; let mut stack = vec![root.id.as_str()];
while let Some(id) = stack.pop() { while let Some(id) = stack.pop() {
let Some(node) = flow.node(id) else { continue }; 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()); 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 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> { fn emit(flow: &Flow, node: &Node, sink: &TodoSink, fields: &Fields) -> Vec<Action> {
let recipients = match &sink.to { let recipients = match &sink.to {
Recipients::Agent(agent) => vec![agent.clone()], Recipients::Agent(agent) => vec![agent.clone()],
@ -333,6 +387,41 @@ mod tests {
assert_eq!(got[0].op.source, "router:assigned"); 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] #[test]
fn a_placeholder_expands_to_its_field_and_an_unknown_one_is_an_error() { fn a_placeholder_expands_to_its_field_and_an_unknown_one_is_an_error() {
let fields = event("assigned", &["a", "b"], &[], &[]); 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 //! 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`]). //! 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`]): //! 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 //! 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 //! 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 futures_util::{StreamExt as _, TryStreamExt as _};
use swarm_queue_client::{ClientSecret, QueueConfig}; use swarm_queue_client::{ClientSecret, QueueConfig};
use swarm_secret_client::stored_secret::StoredSecret; use swarm_secret_client::stored_secret::StoredSecret;
use swarm_wire::router_sink::{self, Outcome, RequestReviewer};
use swarm_wire::{forge_events, notify_router, router_todo}; use swarm_wire::{forge_events, notify_router, router_todo};
mod event; mod event;
@ -103,6 +110,7 @@ async fn main() -> Result<()> {
struct Router { struct Router {
js: jetstream::Context, js: jetstream::Context,
nats: async_nats::Client,
events: jetstream::consumer::Consumer<pull::Config>, events: jetstream::consumer::Consumer<pull::Config>,
state: kv::Store, state: kv::Store,
flows: stored::Flows, flows: stored::Flows,
@ -144,6 +152,7 @@ impl Router {
}; };
Ok(Self { Ok(Self {
js, js,
nats: queue.clone(),
events, events,
state, state,
flows: stored::Flows::builtins(), flows: stored::Flows::builtins(),
@ -200,9 +209,52 @@ impl Router {
let bytes = serde_json::to_vec(&routed.next).expect("a snapshot encodes"); let bytes = serde_json::to_vec(&routed.next).expect("a snapshot encodes");
self.state.put(&key, bytes.into()).await.map_err(retry)?; self.state.put(&key, bytes.into()).await.map_err(retry)?;
} }
for request in &routed.requests {
self.request_review(request).await;
}
Ok(()) 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. /// 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<()> { async fn queue(&self, seq: u64, action: &flow::Action) -> Result<()> {
let subject = router_todo::subject(&action.agent); 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 crate::event::{Delivery, Snapshot, fields, is_assign};
use swarm_wire::notify_flows::Flow; use swarm_wire::notify_flows::Flow;
use crate::flow::{Action, evaluate}; use crate::flow::{Action, ReviewRequest, evaluate, requests};
/// What one event comes to. /// What one event comes to.
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub struct Routed { pub struct Routed {
/// The ops to queue, one per agent that exists. /// The ops to queue, one per agent that exists.
pub actions: Vec<Action>, 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. /// Recipients that are not agents of this swarm, so got nothing.
pub skipped: Vec<String>, pub skipped: Vec<String>,
pub next: Snapshot, pub next: Snapshot,
@ -32,6 +35,10 @@ pub fn route(
let fields = fields(event_type, delivery, snapshot); let fields = fields(event_type, delivery, snapshot);
let mut actions = Vec::new(); let mut actions = Vec::new();
let mut skipped = 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)) { for action in flows.iter().flat_map(|f| evaluate(f, event_type, &fields)) {
if is_agent(&action.agent) { if is_agent(&action.agent) {
actions.push(action); actions.push(action);
@ -62,6 +69,7 @@ pub fn route(
next.holders.retain(|h| !cleared.contains(h)); next.holders.retain(|h| !cleared.contains(h));
Routed { Routed {
actions, actions,
requests: review_requests,
skipped, skipped,
next, 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] #[test]
fn an_event_no_flow_matches_changes_nothing() { fn an_event_no_flow_matches_changes_nothing() {
let before = Snapshot { let before = Snapshot {

View file

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

View file

@ -21,6 +21,7 @@ pub mod notices;
pub mod notify_flows; pub mod notify_flows;
pub mod notify_router; pub mod notify_router;
pub mod review_classifier; pub mod review_classifier;
pub mod router_sink;
pub mod router_todo; pub mod router_todo;
pub mod subagent_term; pub mod subagent_term;
pub mod wanted; 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 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 //! 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 //! 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 //! per recipient. A `request_reviewer` node is a leaf that asks the forge for
//! node, every node reachable from the source ([`Flow::validate`]). //! 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 //! 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 //! 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", "pull_request_assign",
"A pull request assigned or unassigned", "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 /// 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. /// The todo ops a `todo` node's `op` takes, one per [`Op`] variant.
const OPS: &[(&str, &str)] = &[ const OPS: &[(&str, &str)] = &[
( (
@ -171,6 +181,13 @@ pub enum NodeKind {
Match { when: Vec<Condition> }, Match { when: Vec<Condition> },
/// Emits a todo op to each recipient. /// Emits a todo op to each recipient.
Todo(TodoSink), 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. /// `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'_') .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 { fn is_field(name: &str) -> bool {
FIELDS.iter().any(|(n, _)| *n == name) 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 { limits: Limits {
max_nodes: MAX_NODES, max_nodes: MAX_NODES,
@ -512,8 +548,12 @@ impl Flow {
return Err(format!("{} source nodes, not one", roots.len())); return Err(format!("{} source nodes, not one", roots.len()));
}; };
for node in &self.nodes { for node in &self.nodes {
if matches!(node.kind, NodeKind::Todo(_)) && !self.children(&node.id).is_empty() { if matches!(
return Err(format!("todo {} has children", node.id)); 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 // From the one root, every node reached exactly once: a node reached
@ -587,6 +627,11 @@ impl NodeKind {
} }
} }
Self::Todo(sink) => sink.validate()?, Self::Todo(sink) => sink.validate()?,
Self::RequestReviewer { reviewer } => {
if !is_login(reviewer) {
return Err(format!("reviewer {reviewer:?} is not a forge login"));
}
}
} }
Ok(()) Ok(())
} }
@ -632,6 +677,7 @@ impl TodoSink {
/// then tells each agent that held one with a `closed` todo. The clear is the /// 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 /// first child of the match and runs first: its prefix also covers the
/// `closed` key. /// `closed` key.
/// - `head-classifier`: a push to a PR requests the review classifier's review.
#[must_use] #[must_use]
pub fn builtins() -> Vec<Flow> { pub fn builtins() -> Vec<Flow> {
let assignment_key = "forge:{repo}#{number}:assignment"; let assignment_key = "forge:{repo}#{number}:assignment";
@ -662,6 +708,7 @@ pub fn builtins() -> Vec<Flow> {
}, },
), ),
closed(), closed(),
head_classifier(),
] ]
} }
@ -696,6 +743,33 @@ fn closed() -> Flow {
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`. /// source(`events`) → match(`action` = `action`) → `sink`.
fn chain(id: &str, events: &[&str], action: &str, sink: TodoSink) -> Flow { fn chain(id: &str, events: &[&str], action: &str, sink: TodoSink) -> Flow {
let node = |id: &str, kind| Node { let node = |id: &str, kind| Node {
@ -808,7 +882,7 @@ mod tests {
assert!(with_field("no_such_field").is_err(), "control"); assert!(with_field("no_such_field").is_err(), "control");
let kinds: Vec<&str> = s.node_kinds.iter().map(|k| k.kind.as_str()).collect(); 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> { let op_names = |op: &str| -> Option<Op> {
serde_json::from_value(serde_json::Value::String(op.to_owned())).ok() serde_json::from_value(serde_json::Value::String(op.to_owned())).ok()
}; };
@ -917,10 +991,15 @@ mod tests {
#[test] #[test]
fn a_node_that_reads_what_does_not_exist_is_refused() { fn a_node_that_reads_what_does_not_exist_is_refused() {
let mut event = builtin("assigned"); 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 { event.nodes[0].kind = NodeKind::Source {
events: vec!["pull_request_sync".to_owned()], 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"); let mut field = builtin("assigned");
field.nodes[1].kind = NodeKind::Match { field.nodes[1].kind = NodeKind::Match {
@ -944,6 +1023,81 @@ mod tests {
assert_eq!(custom("c").validate(), Ok(())); 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] #[test]
fn a_placeholder_must_name_an_event_field() { fn a_placeholder_must_name_an_event_field() {
let with = |key: &str, text: &str| { 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<_>>(); let ids = |d: &Document| d.active().0.into_iter().map(|f| f.id).collect::<Vec<_>>();
assert_eq!( assert_eq!(
ids(&Document::default()), ids(&Document::default()),
["assigned", "unassigned", "closed"], ["assigned", "unassigned", "closed", "head-classifier"],
"an empty document is the built-ins" "an empty document is the built-ins"
); );
let mut off = custom("off"); let mut off = custom("off");
@ -1042,7 +1196,10 @@ mod tests {
disabled_builtins: vec!["unassigned".to_owned(), "gone".to_owned()], disabled_builtins: vec!["unassigned".to_owned(), "gone".to_owned()],
flows: vec![custom("triage"), off, broken, custom("assigned")], 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(); let (_, skipped) = doc.active();
assert_eq!(skipped.len(), 2, "{skipped:?}"); assert_eq!(skipped.len(), 2, "{skipped:?}");
assert!(skipped.iter().any(|s| s.starts_with("flow broken:"))); 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);
}
}