A new swarm service, swarm-notify-router, in its own container on the host
that sets deploy.notify-router.enable (defaulted on by allSwarmServices where
the host has the router's store leaf, like the claude login minter).
- swarm-controller registers instance-wide hooks for `issues` +
`issue_assign` and `pull_request` + `pull_request_assign`, updates the
event list of a hook already registered with fewer (leaving its active flag as it is), and keeps each
verified body unchanged in the SWARM_EVENTS stream on
$SWARM.events.forge.<X-Forgejo-Event-Type>, deduped by X-Forgejo-Delivery.
A delivery the stream did not keep answers 503.
- swarm-notify-router drains its durable consumer on that stream, runs its
flows (source -> match -> todo sink, stored as nodes plus edges and checked
to be a tree) and queues each op on $SWARM.router.todo.<agent> in the
work-queue stream ROUTER_TODOS. Built-in flows: assigned, unassigned (same
key, rewritten) and closed (prefix-clears every holder's router todos). A
per-issue snapshot in its notify-router-state bucket holds the last
assignees and the todo holders; only agents in the agent-status bucket get
ops, and an assignee that was not one is left out of the snapshot so the
next assign event retries it. After the first event that must be retried,
the rest of the batch is nakked so redelivery keeps stream order.
- hive-agent creates its own durable consumer todo-<agent> and applies each
op to its todo store as subsystem `router`, then acks; a refused op stops
the rest of its batch the same way. The store's (subsystem, key) dedup
makes a redelivery harmless. New Todos::clear_prefix.
- swarm-nats-auth: a --notify-router-client arm; the controller may publish
$SWARM.events.forge.*; each agent gets three {agent} templates (extended
consumer create whose filter the server checks, MSG.NEXT, ACK).
- Credentials: a notify-router store leaf minted by glue-bao-tls, a
swarm-notify-router bao policy (read on its own queue client secret), an
authelia machine client, the secret publisher entry, a journal forwarder
route. No manual step.
hive-forge-notify is unchanged, so agents also still get forge's own
notifications.
Refs #5571
233 lines
8.3 KiB
Rust
233 lines
8.3 KiB
Rust
//! A forge delivery as the fields a flow reads, and the per-issue snapshot
|
|
//! that fills in what the delivery leaves out.
|
|
//!
|
|
//! Forgejo's `issue_assign` and `pull_request_assign` deliveries carry the
|
|
//! assignees *after* the change and not which one changed. [`Snapshot`] holds
|
|
//! the assignees the router last saw on an assign delivery, so the added and
|
|
//! removed ones are the difference. With no snapshot yet, every current
|
|
//! assignee counts as added and none as removed: an unassign is noticed only
|
|
//! on an issue the router has seen an assign delivery for.
|
|
|
|
use std::collections::BTreeSet;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value as Json;
|
|
|
|
use crate::flow::{Fields, Value};
|
|
|
|
/// The parts of an `issues` or `pull_request` family delivery the router
|
|
/// uses.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct Delivery {
|
|
pub action: String,
|
|
pub repo: String,
|
|
pub repo_id: i64,
|
|
pub number: i64,
|
|
pub title: String,
|
|
pub url: String,
|
|
pub sender: String,
|
|
pub is_pull: bool,
|
|
pub assignees: BTreeSet<String>,
|
|
}
|
|
|
|
impl Delivery {
|
|
/// Read a delivery body, or `None` when it names no issue or PR.
|
|
pub fn parse(body: &[u8]) -> Option<Self> {
|
|
let json: Json = serde_json::from_slice(body).ok()?;
|
|
let (item, is_pull) = match json.get("pull_request") {
|
|
Some(pr) if pr.is_object() => (pr, true),
|
|
_ => (json.get("issue")?, false),
|
|
};
|
|
let text = |v: &Json, k: &str| {
|
|
v.get(k)
|
|
.and_then(Json::as_str)
|
|
.unwrap_or_default()
|
|
.to_owned()
|
|
};
|
|
let repository = json.get("repository")?;
|
|
let assignees = item
|
|
.get("assignees")
|
|
.and_then(Json::as_array)
|
|
.map(|a| {
|
|
a.iter()
|
|
.filter_map(|u| u.get("login").and_then(Json::as_str))
|
|
.map(str::to_owned)
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
Some(Self {
|
|
action: text(&json, "action"),
|
|
repo: text(repository, "full_name"),
|
|
repo_id: repository.get("id")?.as_i64()?,
|
|
number: item.get("number")?.as_i64()?,
|
|
title: text(item, "title"),
|
|
url: text(item, "html_url"),
|
|
sender: json
|
|
.get("sender")
|
|
.map(|s| text(s, "login"))
|
|
.unwrap_or_default(),
|
|
is_pull,
|
|
assignees,
|
|
})
|
|
}
|
|
|
|
/// The state bucket key for this delivery's issue: `<repo id>.<number>`,
|
|
/// numeric so it is always a valid key whatever the repo is named.
|
|
pub fn state_key(&self) -> String {
|
|
format!("{}.{}", self.repo_id, self.number)
|
|
}
|
|
}
|
|
|
|
/// What the router keeps per issue in its state bucket.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Snapshot {
|
|
/// The assignees after the last assign delivery seen, less those that
|
|
/// were no agent of the roster then, or `None` before the first.
|
|
#[serde(default)]
|
|
pub assignees: Option<BTreeSet<String>>,
|
|
/// The agents holding a router todo for this issue.
|
|
#[serde(default)]
|
|
pub holders: BTreeSet<String>,
|
|
}
|
|
|
|
/// Whether `event_type` is an assign delivery, whose assignee list is the
|
|
/// set after the change.
|
|
pub fn is_assign(event_type: &str) -> bool {
|
|
matches!(event_type, "issue_assign" | "pull_request_assign")
|
|
}
|
|
|
|
/// The fields a flow reads for `delivery`, given the issue's `snapshot`.
|
|
pub fn fields(event_type: &str, delivery: &Delivery, snapshot: &Snapshot) -> Fields {
|
|
let (added, removed) = if is_assign(event_type) {
|
|
match &snapshot.assignees {
|
|
Some(before) => (
|
|
delivery.assignees.difference(before).cloned().collect(),
|
|
before.difference(&delivery.assignees).cloned().collect(),
|
|
),
|
|
None => (delivery.assignees.iter().cloned().collect(), Vec::new()),
|
|
}
|
|
} else {
|
|
(Vec::new(), Vec::new())
|
|
};
|
|
let text = |s: &str| Value::Text(s.to_owned());
|
|
Fields::from([
|
|
("event", text(event_type)),
|
|
("action", text(&delivery.action)),
|
|
("kind", text(if delivery.is_pull { "PR" } else { "issue" })),
|
|
("repo", text(&delivery.repo)),
|
|
("number", Value::Text(delivery.number.to_string())),
|
|
("title", text(&delivery.title)),
|
|
("url", text(&delivery.url)),
|
|
("sender", text(&delivery.sender)),
|
|
(
|
|
"assignees",
|
|
Value::List(delivery.assignees.iter().cloned().collect()),
|
|
),
|
|
("assignees_added", Value::List(added)),
|
|
("assignees_removed", Value::List(removed)),
|
|
(
|
|
"todo_holders",
|
|
Value::List(snapshot.holders.iter().cloned().collect()),
|
|
),
|
|
])
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn issue_assign(action: &str, assignees: &[&str]) -> Vec<u8> {
|
|
let users: Vec<Json> = assignees
|
|
.iter()
|
|
.map(|a| serde_json::json!({ "login": a }))
|
|
.collect();
|
|
serde_json::to_vec(&serde_json::json!({
|
|
"action": action,
|
|
"number": 7,
|
|
"issue": {
|
|
"number": 7,
|
|
"title": "router",
|
|
"html_url": "https://forge/issue-7",
|
|
"assignees": users,
|
|
},
|
|
"repository": { "id": 42, "full_name": "hyperhive/hyperhive" },
|
|
"sender": { "login": "mara" },
|
|
}))
|
|
.expect("encodes")
|
|
}
|
|
|
|
fn list(fields: &Fields, name: &str) -> Vec<String> {
|
|
match fields.get(name) {
|
|
Some(Value::List(v)) => v.clone(),
|
|
other => panic!("{name}: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_issue_delivery_reads_back_its_fields() {
|
|
let d = Delivery::parse(&issue_assign("assigned", &["atlas", "iris"])).expect("parses");
|
|
assert_eq!(d.repo, "hyperhive/hyperhive");
|
|
assert_eq!(d.number, 7);
|
|
assert!(!d.is_pull);
|
|
assert_eq!(d.state_key(), "42.7");
|
|
assert_eq!(
|
|
d.assignees,
|
|
BTreeSet::from(["atlas".to_owned(), "iris".to_owned()])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_pull_request_delivery_reads_the_pull_request_object() {
|
|
let body = serde_json::to_vec(&serde_json::json!({
|
|
"action": "closed",
|
|
"number": 7,
|
|
"pull_request": { "number": 7, "title": "t", "html_url": "u", "assignees": null },
|
|
"repository": { "id": 1, "full_name": "o/r" },
|
|
"sender": { "login": "s" },
|
|
}))
|
|
.expect("encodes");
|
|
let d = Delivery::parse(&body).expect("parses");
|
|
assert!(d.is_pull);
|
|
assert_eq!(d.number, 7);
|
|
assert!(d.assignees.is_empty());
|
|
assert_eq!(Delivery::parse(b"{\"ref\":\"main\"}"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn the_snapshot_turns_the_after_list_into_added_and_removed() {
|
|
let d = Delivery::parse(&issue_assign("unassigned", &["iris"])).expect("parses");
|
|
let before = Snapshot {
|
|
assignees: Some(BTreeSet::from(["atlas".to_owned(), "iris".to_owned()])),
|
|
holders: BTreeSet::new(),
|
|
};
|
|
let f = fields("issue_assign", &d, &before);
|
|
assert_eq!(list(&f, "assignees_removed"), ["atlas"]);
|
|
assert!(list(&f, "assignees_added").is_empty());
|
|
|
|
let d = Delivery::parse(&issue_assign("assigned", &["atlas", "iris"])).expect("parses");
|
|
let before = Snapshot {
|
|
assignees: Some(BTreeSet::from(["iris".to_owned()])),
|
|
holders: BTreeSet::new(),
|
|
};
|
|
assert_eq!(
|
|
list(&fields("issue_assign", &d, &before), "assignees_added"),
|
|
["atlas"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn with_no_snapshot_every_assignee_is_added_and_none_removed() {
|
|
let d = Delivery::parse(&issue_assign("assigned", &["atlas"])).expect("parses");
|
|
let f = fields("issue_assign", &d, &Snapshot::default());
|
|
assert_eq!(list(&f, "assignees_added"), ["atlas"]);
|
|
assert!(list(&f, "assignees_removed").is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_delivery_that_is_not_an_assign_adds_and_removes_nobody() {
|
|
let d = Delivery::parse(&issue_assign("closed", &["atlas"])).expect("parses");
|
|
let f = fields("issues", &d, &Snapshot::default());
|
|
assert!(list(&f, "assignees_added").is_empty());
|
|
assert!(list(&f, "assignees_removed").is_empty());
|
|
}
|
|
}
|