249 lines
8.9 KiB
Rust
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());
|
|
}
|
|
}
|