Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-notify-router/src/event.rs

249 lines
8.9 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:?}"),
}
}
/// The names a stored flow may read are the ones `fields` produces.
#[test]
fn the_fields_are_the_ones_a_stored_flow_may_name() {
let d = Delivery::parse(&issue_assign("assigned", &["atlas"])).expect("parses");
for event in ["issues", "issue_assign"] {
let mut got: Vec<&str> = fields(event, &d, &Snapshot::default())
.keys()
.copied()
.collect();
let mut want = swarm_wire::notify_flows::FIELDS.to_vec();
got.sort_unstable();
want.sort_unstable();
assert_eq!(got, want, "{event}");
}
}
#[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());
}
}