Watch
0
0
Fork
You've already forked hyperhive
0

let notification flows start from submitted pull request reviews

This commit is contained in:
atlas 2026-10-25 16:16:07 +01:00 • committed by mara
commit dd2f0d3ece
7 changed files with 142 additions and 11 deletions

View file

@ -420,7 +420,8 @@ itself, all under `https://<swarm.domain>/webhook/forge/`:
- an instance-wide `pull_request_review_request` system hook, which starts
the [review classifier](review-classifier.md#trigger-and-lifecycle)
- instance-wide `issues` + `issue_assign` and `pull_request` +
`pull_request_assign` + `pull_request_sync` system hooks, whose deliveries it
`pull_request_assign` + `pull_request_sync` + `pull_request_review_approved` +
`pull_request_review_rejected` + `pull_request_review_comment` system hooks, whose deliveries it
keeps in the `SWARM_EVENTS` stream for the
[notification router](notify-router.md#the-path)

View file

@ -41,8 +41,9 @@ router notices an unassign only on an issue it has seen an assign event for.
1. swarm-controller registers two instance-wide hooks, one for the `issues`
and `issue_assign` events at `/webhook/forge/issues` and one for
`pull_request`, `pull_request_assign` and `pull_request_sync` (a push to an
open PR's branch) at `/webhook/forge/pull-request`. At each start it adds
`pull_request`, `pull_request_assign`, `pull_request_sync` (a push to an
open PR's branch) and the submitted-review events `pull_request_review_approved`,
`pull_request_review_rejected` and `pull_request_review_comment` at `/webhook/forge/pull-request`. At each start it adds
any of these events a registered hook lacks.
2. On each delivery the controller checks the HMAC and publishes the body,
unchanged, to the `SWARM_EVENTS` stream on
@ -90,8 +91,8 @@ switched off, and the custom flows. Only the controller writes it, through
`GET` and `PUT /api/notify/flows`. A `PUT` replaces the whole document in one
compare-and-set. The controller answers 400 unless every flow passes the tree
check above and reads only what exists: a source event from `issues`,
`issue_assign`, `pull_request`, `pull_request_assign` or `pull_request_sync`,
and condition and recipient fields from the event's field list. The same goes for the `{field}`
`issue_assign`, `pull_request`, `pull_request_assign`, `pull_request_sync` or one of the
three `pull_request_review_*` events, and condition and recipient fields from the event's field list. The same goes for the `{field}`
placeholders in a todo's key and text. A `PUT` must carry the `revision` it read, `0` for no
document, and gets 409 when the stored revision is another. Swarm UI's
[`/notifications`](ui.md#notification-flows) page shows the document and
@ -156,6 +157,21 @@ its own request queue, which replaces a waiting request for the same PR. A
repo the classifier's account can't read, or a PR that account opened, answers
`error:`.
**Reviews as a source.** A submitted review arrives as a pull request
payload: `pull_request` and `repository` as for any PR event, `action` is
`reviewed`, and `sender` is the reviewer. Approving, requesting changes and
commenting are three event types, so a flow picks the verdict with its source
and reads the reviewer with a condition on `sender`. A flow that asks argus to
review once the classifier has approved:
| node | setting |
| ---------------- | ----------------------------------- |
| source | `pull_request_review_approved` |
| match | `sender` equals `review-classifier` |
| request_reviewer | `reviewer` = `argus` |
The skip rule above applies: the router asks for argus's review once per head.
**What the router runs.** It reads the key at most every 10 seconds, when an
event arrives, and runs the built-ins you left on, then the custom flows that
are on and pass the tree check. It logs a custom flow that fails the check,

View file

@ -119,6 +119,10 @@ export const FALLBACK_SCHEMA: FlowSchema = {
"issue_assign",
"pull_request",
"pull_request_assign",
"pull_request_sync",
"pull_request_review_approved",
"pull_request_review_rejected",
"pull_request_review_comment",
].map((name) => ({ name, description: "" })),
fields: [
"event",

View file

@ -1780,12 +1780,19 @@ fn hook_registration(kind: DeliveryKind) -> (&'static [&'static str], HookScope<
// classifier's account access can request its review.
DeliveryKind::ReviewRequest => (&["pull_request_review_request"], HookScope::Instance),
// Instance-wide, for the notification router, which reads the assign
// events and a push to a PR's branch. They are named: Forgejo's web
// events, a push to a PR's branch and submitted reviews. They are named: Forgejo's web
// form shows each as a box of its own, so a hook edited there can drop
// one while keeping `issues` or `pull_request`.
DeliveryKind::Issues => (&["issues", "issue_assign"], HookScope::Instance),
DeliveryKind::PullRequest => (
&["pull_request", "pull_request_assign", "pull_request_sync"],
&[
"pull_request",
"pull_request_assign",
"pull_request_sync",
"pull_request_review_approved",
"pull_request_review_rejected",
"pull_request_review_comment",
],
HookScope::Instance,
),
}
@ -2131,7 +2138,14 @@ mod tests {
assert_eq!(events(DeliveryKind::Issues), ["issues", "issue_assign"]);
assert_eq!(
events(DeliveryKind::PullRequest),
["pull_request", "pull_request_assign", "pull_request_sync"]
[
"pull_request",
"pull_request_assign",
"pull_request_sync",
"pull_request_review_approved",
"pull_request_review_rejected",
"pull_request_review_comment",
]
);
// The events a stored flow's source may name are the ones the hooks
// deliver to the router.

View file

@ -233,8 +233,8 @@ pub(super) enum DeliveryKind {
/// assigned, …), kept for the notification router (`crate::forge_events`).
#[strum(serialize = "issues")]
Issues,
/// Every `pull_request` event on every repo in the instance, kept the
/// same way.
/// Every `pull_request` event, and every submitted review, on every repo in
/// the instance, kept the same way.
#[strum(serialize = "pull-request")]
PullRequest,
}

View file

@ -78,7 +78,7 @@ pub fn route(
#[cfg(test)]
mod tests {
use super::*;
use swarm_wire::notify_flows::builtins;
use swarm_wire::notify_flows::{Condition, Edge, Node, NodeKind, builtins};
fn delivery(action: &str, assignees: &[&str]) -> Delivery {
Delivery {
@ -248,4 +248,88 @@ mod tests {
assert!(r.actions.is_empty());
assert_eq!(r.next, before);
}
/// The body Forgejo sends for a submitted review: a pull request payload
/// whose `sender` is the reviewer.
fn review_body(reviewer: &str) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({
"action": "reviewed",
"number": 9,
"pull_request": {
"number": 9,
"title": "t",
"html_url": "u",
"assignees": [],
},
"repository": { "id": 1, "full_name": "o/r" },
"sender": { "login": reviewer },
"review": { "type": "pull_request_review_approved", "content": "" },
}))
.expect("encodes")
}
/// "A review by the classifier on a PR → request argus's review", as a
/// user builds it: valid, routed from the delivery body, and gated on the
/// reviewer.
#[test]
fn a_review_by_the_classifier_requests_argus() {
let id = |s: &str| s.to_owned();
let flow = Flow {
id: id("classifier-then-argus"),
name: id("classifier-then-argus"),
enabled: true,
nodes: vec![
Node {
id: id("source"),
kind: NodeKind::Source {
events: vec![id("pull_request_review_approved")],
},
},
Node {
id: id("match"),
kind: NodeKind::Match {
when: vec![Condition {
field: id("sender"),
equals: id("review-classifier"),
}],
},
},
Node {
id: id("request"),
kind: NodeKind::RequestReviewer {
reviewer: id("argus"),
},
},
],
edges: vec![
Edge {
from: id("source"),
to: id("match"),
},
Edge {
from: id("match"),
to: id("request"),
},
],
};
assert_eq!(flow.validate(), Ok(()));
let flows = [flow];
let routed = |event: &str, reviewer: &str| {
let d = Delivery::parse(&review_body(reviewer)).expect("parses");
route(event, &d, &Snapshot::default(), &flows, |_| true).requests
};
assert_eq!(
routed("pull_request_review_approved", "review-classifier"),
[ReviewRequest {
flow: "classifier-then-argus".to_owned(),
node: "request".to_owned(),
repo: "o/r".to_owned(),
number: 9,
reviewer: "argus".to_owned(),
}]
);
// Another reviewer, and another review event, request nothing.
assert!(routed("pull_request_review_approved", "mara").is_empty());
assert!(routed("pull_request_review_rejected", "review-classifier").is_empty());
}
}

View file

@ -62,6 +62,18 @@ pub const SOURCE_EVENTS: &[(&str, &str)] = &[
"pull_request_sync",
"A push to the branch of an open pull request",
),
(
"pull_request_review_approved",
"A review submitted on a pull request that approves it",
),
(
"pull_request_review_rejected",
"A review submitted on a pull request that asks for changes",
),
(
"pull_request_review_comment",
"A review submitted on a pull request that only comments",
),
];
/// The fields of an event a condition or a recipient can name, with what each