Watch
0
0
Fork
You've already forked hyperhive
0

swarm: the notification router tells a PR's author when CI on it fails or is cancelled

This commit is contained in:
damocles 2026-11-06 21:25:08 +01:00 • committed by mara
commit c8eaa93268

View file

@ -26,6 +26,11 @@ pub struct Delivery {
pub title: String,
pub url: String,
pub sender: String,
/// The account that opened the issue or PR.
pub author: String,
/// The commit at the PR's head when the delivery was made; empty for an
/// issue. A CI run's delivery carries the PR as it was when the run began.
pub head: String,
pub is_pull: bool,
pub assignees: BTreeSet<String>,
/// The PR is a draft: the forge's `pull_request.draft`, true for a title
@ -82,6 +87,15 @@ impl Delivery {
.get("sender")
.map(|s| text(s, "login"))
.unwrap_or_default(),
author: item
.get("user")
.map(|u| text(u, "login"))
.unwrap_or_default(),
head: item
.pointer("/head/sha")
.and_then(Json::as_str)
.unwrap_or_default()
.to_owned(),
is_pull,
assignees,
draft,
@ -89,6 +103,30 @@ impl Delivery {
})
}
/// Read the delivery of Forgejo event type `event_type`. A `workflow_run_*`
/// delivery carries the webhook that started the run as a JSON string in
/// `run.event_payload`; for a run on a PR that is the PR's own delivery, so
/// the PR is read from it, `action` is the run's outcome (`failure`,
/// `cancelled`) and `sender` is the account that triggered the run. A run
/// on anything else (a push to a branch with no PR) names no PR and gives
/// `None`.
pub fn from_event(event_type: &str, body: &[u8]) -> Option<Self> {
let Some(outcome) = event_type.strip_prefix("workflow_run_") else {
return Self::parse(body);
};
let json: Json = serde_json::from_slice(body).ok()?;
let run = json.get("run")?;
let mut delivery = Self::parse(run.get("event_payload")?.as_str()?.as_bytes())?;
if !delivery.is_pull {
return None;
}
outcome.clone_into(&mut delivery.action);
if let Some(trigger) = run.pointer("/trigger_user/login").and_then(Json::as_str) {
trigger.clone_into(&mut delivery.sender);
}
Some(delivery)
}
/// 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 {
@ -122,6 +160,11 @@ pub struct Snapshot {
/// The agents holding a router todo for this issue.
#[serde(default)]
pub holders: BTreeSet<String>,
/// The commit at the head of the PR as of the last delivery about the PR
/// that was no CI run. A run that began at another commit is superseded
/// and tells nobody anything.
#[serde(default)]
pub head: Option<String>,
}
/// Whether `event_type` is an assign delivery, whose assignee list is the
@ -153,6 +196,7 @@ pub fn fields(event_type: &str, delivery: &Delivery, snapshot: &Snapshot) -> Fie
("title", text(&delivery.title)),
("url", text(&delivery.url)),
("sender", text(&delivery.sender)),
("author", text(&delivery.author)),
("draft", text(bool_text(delivery.draft))),
("undrafted", text(bool_text(delivery.undrafted))),
(
@ -266,6 +310,89 @@ mod tests {
serde_json::to_vec(&body).expect("encodes")
}
/// A `workflow_run_*` delivery: the run, holding the webhook that started
/// it as a JSON string.
fn run_body(started_by: &Json) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({
"action": "failure",
"run": {
"id": 5,
"trigger_user": { "login": "atlas" },
"event_payload": serde_json::to_string(started_by).expect("encodes"),
},
}))
.expect("encodes")
}
fn pull_payload(author: &str) -> Json {
serde_json::json!({
"action": "synchronized",
"number": 7,
"pull_request": {
"number": 7, "title": "t", "html_url": "u", "user": { "login": author },
"head": { "sha": "abc123" },
},
"repository": { "id": 1, "full_name": "o/r" },
"sender": { "login": "pusher" },
})
}
#[test]
fn a_run_on_a_pull_request_reads_as_that_pull_request_with_the_runs_outcome() {
let body = run_body(&pull_payload("iris"));
for (event, outcome) in [
("workflow_run_failure", "failure"),
("workflow_run_cancelled", "cancelled"),
] {
let d = Delivery::from_event(event, &body).expect("a PR run");
assert!(d.is_pull);
assert_eq!((d.repo.as_str(), d.number), ("o/r", 7));
assert_eq!(d.action, outcome);
assert_eq!(d.author, "iris");
assert_eq!(d.head, "abc123", "the commit the run began at");
assert_eq!(d.sender, "atlas", "the account that triggered the run");
assert_eq!(d.state_key(), "1.7");
let f = fields(event, &d, &Snapshot::default());
assert_eq!(f.get("author"), Some(&Value::Text("iris".to_owned())));
assert_eq!(f.get("kind"), Some(&Value::Text("PR".to_owned())));
}
}
#[test]
fn a_run_that_names_no_pull_request_is_ignored() {
let push = serde_json::json!({
"ref": "refs/heads/main",
"repository": { "id": 1, "full_name": "o/r" },
});
assert_eq!(
Delivery::from_event("workflow_run_failure", &run_body(&push)),
None
);
let issue: Json =
serde_json::from_slice(&issue_assign("assigned", &["atlas"])).expect("json");
assert_eq!(
Delivery::from_event("workflow_run_failure", &run_body(&issue)),
None
);
assert_eq!(
Delivery::from_event("workflow_run_failure", b"not json"),
None
);
assert_eq!(
Delivery::from_event("workflow_run_failure", b"{\"run\":{}}"),
None
);
}
#[test]
fn other_events_read_the_body_directly() {
let body = pull_body("opened", "t", false, None);
assert_eq!(
Delivery::from_event("pull_request", &body),
Delivery::parse(&body)
);
}
#[test]
fn the_forges_draft_flag_marks_a_draft() {
let d = Delivery::parse(&pull_body("synchronized", "WIP: t", true, None)).expect("parses");
@ -307,6 +434,7 @@ mod tests {
let before = Snapshot {
assignees: Some(BTreeSet::from(["atlas".to_owned(), "iris".to_owned()])),
holders: BTreeSet::new(),
..Snapshot::default()
};
let f = fields("issue_assign", &d, &before);
assert_eq!(list(&f, "assignees_removed"), ["atlas"]);
@ -316,6 +444,7 @@ mod tests {
let before = Snapshot {
assignees: Some(BTreeSet::from(["iris".to_owned()])),
holders: BTreeSet::new(),
..Snapshot::default()
};
assert_eq!(
list(&fields("issue_assign", &d, &before), "assignees_added"),