//! 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, } impl Delivery { /// Read a delivery body, or `None` when it names no issue or PR. pub fn parse(body: &[u8]) -> Option { 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: `.`, /// 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>, /// The agents holding a router todo for this issue. #[serde(default)] pub holders: BTreeSet, } /// 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 { let users: Vec = 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 { 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()); } }