swarm: the notification router tells a PR's author when CI on it fails or is cancelled
This commit is contained in:
parent
d4e58f8741
commit
c8eaa93268
12 changed files with 490 additions and 60 deletions
|
|
@ -437,8 +437,9 @@ itself, all under `https://<swarm.domain>/webhook/forge/`:
|
||||||
the [review classifier](../../4-implementation/swarm/review-classifier.md#trigger-and-lifecycle)
|
the [review classifier](../../4-implementation/swarm/review-classifier.md#trigger-and-lifecycle)
|
||||||
- instance-wide `issues` + `issue_assign` and `pull_request` +
|
- instance-wide `issues` + `issue_assign` and `pull_request` +
|
||||||
`pull_request_assign` + `pull_request_sync` + `pull_request_review_approved` +
|
`pull_request_assign` + `pull_request_sync` + `pull_request_review_approved` +
|
||||||
`pull_request_review_rejected` + `pull_request_review_comment` system hooks, whose deliveries it
|
`pull_request_review_rejected` + `pull_request_review_comment` system hooks, and
|
||||||
keeps in the `SWARM_EVENTS` stream for the
|
an instance-wide `workflow_run_failure` + `workflow_run_cancelled` system hook,
|
||||||
|
whose deliveries it keeps in the `SWARM_EVENTS` stream for the
|
||||||
[notification router](../../4-implementation/swarm/notify-router.md#the-path)
|
[notification router](../../4-implementation/swarm/notify-router.md#the-path)
|
||||||
|
|
||||||
The controller **interprets** a delivery and sends hives a specific
|
The controller **interprets** a delivery and sends hives a specific
|
||||||
|
|
|
||||||
|
|
@ -595,11 +595,13 @@ that sets `deploy.notify-router.enable`. `allSwarmServices` defaults it on
|
||||||
wherever the host has the router's store identity, on the review classifier's
|
wherever the host has the router's store identity, on the review classifier's
|
||||||
terms. Run one: it handles each issue's events in order.
|
terms. Run one: it handles each issue's events in order.
|
||||||
|
|
||||||
swarm-controller keeps every `issues` and `pull_request` delivery from its
|
swarm-controller keeps every `issues` and `pull_request` delivery, and every
|
||||||
instance-wide hooks in the `SWARM_EVENTS` stream. The router reads that
|
failed or cancelled CI run, from its instance-wide hooks in the `SWARM_EVENTS`
|
||||||
|
stream. The router reads that
|
||||||
stream and queues todos for the agents an event concerns: an agent assigned to
|
stream and queues todos for the agents an event concerns: an agent assigned to
|
||||||
an issue or pull request gets a todo, one taken off it gets that todo rewritten
|
an issue or pull request gets a todo, one taken off it gets that todo rewritten
|
||||||
to say so, and closing it clears them. A push to an open pull request makes
|
to say so, and closing it clears them. The author of a pull request gets a todo
|
||||||
|
when a CI run on it fails or gets cancelled, and a push to the branch clears it. A push to an open pull request makes
|
||||||
it ask swarm-controller to request the review classifier's review. Each agent's harness takes its todos
|
it ask swarm-controller to request the review classifier's review. Each agent's harness takes its todos
|
||||||
from the `ROUTER_TODOS` stream, so an agent that's down gets them when it's
|
from the `ROUTER_TODOS` stream, so an agent that's down gets them when it's
|
||||||
back.
|
back.
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,14 @@ the way it did. An agent can get the same news from both.
|
||||||
|
|
||||||
## What an agent gets
|
## What an agent gets
|
||||||
|
|
||||||
| Flow | Event | The agent's todo |
|
| Flow | Event | The agent's todo |
|
||||||
| ------------ | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `assigned` | someone assigns an agent to an issue or PR | `you are assigned to issue <repo>#<n>: <title> (<url>)`, key `forge:<repo>#<n>:assignment` |
|
| `assigned` | someone assigns an agent to an issue or PR | `you are assigned to issue <repo>#<n>: <title> (<url>)`, key `forge:<repo>#<n>:assignment` |
|
||||||
| `unassigned` | someone takes an agent off an issue or PR | the same key rewritten: `you are not assigned to issue <repo>#<n> anymore: <title> (<url>)` |
|
| `unassigned` | someone takes an agent off an issue or PR | the same key rewritten: `you are not assigned to issue <repo>#<n> anymore: <title> (<url>)` |
|
||||||
| `closed` | someone closes or merges the issue or PR | the router clears every router todo it queued for it, then queues `closed` on each agent that held one: `<kind> <repo>#<n> was closed: <title> (<url>)`, key `forge:<repo>#<n>:closed` |
|
| `closed` | someone closes or merges the issue or PR | the router clears every router todo it queued for it, then queues `closed` on each agent that held one: `<kind> <repo>#<n> was closed: <title> (<url>)`, key `forge:<repo>#<n>:closed` |
|
||||||
|
| `ci-failed` | a CI run on a PR finishes with a failed job | the PR's author gets `CI failed on PR <repo>#<n>: <title> (<url>)`, key `forge:<repo>#<n>:ci` |
|
||||||
|
| `ci-cancelled` | a CI run on a PR gets cancelled | the same key rewritten: `CI was cancelled on PR <repo>#<n>: <title> (<url>)` |
|
||||||
|
| `ci-cleared` | someone pushes to the PR's branch | the router clears the PR author's `:ci` todo, as the push starts a new run; the author's other todos for the PR stay |
|
||||||
|
|
||||||
The todos are subsystem `router` in the agent's todo list. A todo under the
|
The todos are subsystem `router` in the agent's todo list. A todo under the
|
||||||
same key replaces the one before, so an issue gives an agent one assignment
|
same key replaces the one before, so an issue gives an agent one assignment
|
||||||
|
|
@ -39,12 +42,16 @@ router notices an unassign only on an issue it has seen an assign event for.
|
||||||
|
|
||||||
## The path
|
## The path
|
||||||
|
|
||||||
1. swarm-controller registers two instance-wide hooks, one for the `issues`
|
1. swarm-controller registers three instance-wide hooks, one for the `issues`
|
||||||
and `issue_assign` events at `/webhook/forge/issues` and one for
|
and `issue_assign` events at `/webhook/forge/issues`, one for
|
||||||
`pull_request`, `pull_request_assign`, `pull_request_sync` (a push to an
|
`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`,
|
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
|
`pull_request_review_rejected` and `pull_request_review_comment` at `/webhook/forge/pull-request`,
|
||||||
any of these events a registered hook lacks.
|
and one for `workflow_run_failure` and `workflow_run_cancelled` (a finished CI run) at
|
||||||
|
`/webhook/forge/workflow-run`. At each start it adds
|
||||||
|
any of these events a registered hook lacks, except for the CI run events:
|
||||||
|
Forgejo's hook edit never sets them, so the `workflow-run` hook gets them
|
||||||
|
only when it's created. Adding a run event later means a new hook kind.
|
||||||
2. On each delivery the controller checks the HMAC and publishes the body,
|
2. On each delivery the controller checks the HMAC and publishes the body,
|
||||||
unchanged, to the `SWARM_EVENTS` stream on
|
unchanged, to the `SWARM_EVENTS` stream on
|
||||||
`$SWARM.events.forge.<X-Forgejo-Event-Type>`, such as
|
`$SWARM.events.forge.<X-Forgejo-Event-Type>`, such as
|
||||||
|
|
@ -78,7 +85,7 @@ under a `todo` or a `request_reviewer`. The most is 64 nodes, 16
|
||||||
levels deep, and 100 custom flows. A `todo` node sends to the agents an event
|
levels deep, and 100 custom flows. A `todo` node sends to the agents an event
|
||||||
field holds (`assignees_added`, `todo_holders`, …) or to one named agent.
|
field holds (`assignees_added`, `todo_holders`, …) or to one named agent.
|
||||||
|
|
||||||
The router ships the three flows above, each a source → match on `action` →
|
The router ships the six flows above, each a source → match on `action` →
|
||||||
todo, and `head-classifier`: source `pull_request` and `pull_request_sync` →
|
todo, and `head-classifier`: source `pull_request` and `pull_request_sync` →
|
||||||
match on `action` (`opened`, `reopened`, `synchronized`) and `draft` = `false`, or on
|
match on `action` (`opened`, `reopened`, `synchronized`) and `draft` = `false`, or on
|
||||||
`undrafted` → `request_reviewer` for `review-classifier` ([below](#requesting-a-review)). The code defines them
|
`undrafted` → `request_reviewer` for `review-classifier` ([below](#requesting-a-review)). The code defines them
|
||||||
|
|
@ -93,7 +100,7 @@ switched off, and the custom flows. Only the controller writes it, through
|
||||||
compare-and-set. The controller answers 400 unless every flow passes the tree
|
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`,
|
check above and reads only what exists: a source event from `issues`,
|
||||||
`issue_assign`, `pull_request`, `pull_request_assign`, `pull_request_sync` or one of the
|
`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}`
|
three `pull_request_review_*` events, `workflow_run_failure` or `workflow_run_cancelled`, 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
|
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
|
document, and gets 409 when the stored revision is another. Swarm UI's
|
||||||
[notification router card on `/swarm`](../../2-handbook/swarm/ui.md#notification-flows) shows the document and
|
[notification router card on `/swarm`](../../2-handbook/swarm/ui.md#notification-flows) shows the document and
|
||||||
|
|
@ -179,6 +186,33 @@ review once the classifier has approved:
|
||||||
|
|
||||||
The skip rule above applies: the router asks for argus's review once per head.
|
The skip rule above applies: the router asks for argus's review once per head.
|
||||||
|
|
||||||
|
**CI runs as a source.** A finished CI run arrives as `workflow_run_failure`
|
||||||
|
(a job failed, once every job has ended) or `workflow_run_cancelled`. The body
|
||||||
|
holds the run, and the run holds the webhook that started it as a JSON string
|
||||||
|
in `run.event_payload`. For a run on a PR that's the PR's own delivery, so the
|
||||||
|
router reads the PR from it: `repo`, `number`, `title`, `url` and `author` are
|
||||||
|
the PR's, `action` is the run's outcome (`failure` or `cancelled`), and
|
||||||
|
`sender` is the account that triggered the run. A run that started from
|
||||||
|
anything else, such as a push to a branch with no PR, names no PR and the
|
||||||
|
router ignores it. The `author` field is the account that opened the issue or
|
||||||
|
PR, so a flow can send to it:
|
||||||
|
|
||||||
|
| node | setting |
|
||||||
|
| ------ | ------------------------------------------------------------- |
|
||||||
|
| source | `workflow_run_failure` |
|
||||||
|
| todo | `to` = field `author`, key `forge:{repo}#{number}:ci`, upsert |
|
||||||
|
|
||||||
|
A clear of one key, such as `ci-cleared`'s, leaves the agent in the issue's
|
||||||
|
holders, because it may still hold the assignment todo that `closed` must find.
|
||||||
|
Only a prefix clear drops the holder. An author whose CI ever failed gets
|
||||||
|
the `closed` todo when their PR closes or merges, like an assignee.
|
||||||
|
|
||||||
|
A run reports in when it ends, and a push cancels the runs before it, so an old
|
||||||
|
run can end after the push cleared the `:ci` todo. The router keeps the PR's
|
||||||
|
head commit from the last delivery about the PR that wasn't a run. A run whose
|
||||||
|
delivery names another head began before the latest push, and the router drops
|
||||||
|
it. With no head kept yet, it can't tell and sends the todo.
|
||||||
|
|
||||||
**What the router runs.** It reads the key at most every 10 seconds, when an
|
**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
|
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,
|
are on and pass the tree check. It logs a custom flow that fails the check,
|
||||||
|
|
|
||||||
|
|
@ -1892,6 +1892,14 @@ fn hook_registration(kind: DeliveryKind) -> (&'static [&'static str], HookScope<
|
||||||
],
|
],
|
||||||
HookScope::Instance,
|
HookScope::Instance,
|
||||||
),
|
),
|
||||||
|
// Instance-wide too: the outcome of a CI run, which the router tells a
|
||||||
|
// PR's author about. A hook of its own, because Forgejo's hook edit
|
||||||
|
// never sets `workflow_run_*` events: a hook can only get them when it
|
||||||
|
// is created.
|
||||||
|
DeliveryKind::WorkflowRun => (
|
||||||
|
&["workflow_run_failure", "workflow_run_cancelled"],
|
||||||
|
HookScope::Instance,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2244,10 +2252,15 @@ mod tests {
|
||||||
"pull_request_review_comment",
|
"pull_request_review_comment",
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
events(DeliveryKind::WorkflowRun),
|
||||||
|
["workflow_run_failure", "workflow_run_cancelled"]
|
||||||
|
);
|
||||||
// The events a stored flow's source may name are the ones the hooks
|
// The events a stored flow's source may name are the ones the hooks
|
||||||
// deliver to the router.
|
// deliver to the router.
|
||||||
let mut routed = events(DeliveryKind::Issues);
|
let mut routed = events(DeliveryKind::Issues);
|
||||||
routed.extend(events(DeliveryKind::PullRequest));
|
routed.extend(events(DeliveryKind::PullRequest));
|
||||||
|
routed.extend(events(DeliveryKind::WorkflowRun));
|
||||||
routed.sort_unstable();
|
routed.sort_unstable();
|
||||||
let mut sources = swarm_wire::notify_flows::SOURCE_EVENTS
|
let mut sources = swarm_wire::notify_flows::SOURCE_EVENTS
|
||||||
.iter()
|
.iter()
|
||||||
|
|
|
||||||
|
|
@ -303,7 +303,7 @@ mod tests {
|
||||||
let Json(view) = get_flows(State(state.clone())).await.expect("get");
|
let Json(view) = get_flows(State(state.clone())).await.expect("get");
|
||||||
assert_eq!(view.revision, 0);
|
assert_eq!(view.revision, 0);
|
||||||
assert!(view.disabled_builtins.is_empty() && view.flows.is_empty());
|
assert!(view.disabled_builtins.is_empty() && view.flows.is_empty());
|
||||||
assert_eq!(view.builtins.len(), 4);
|
assert_eq!(view.builtins.len(), notify_flows::builtins().len());
|
||||||
assert!(view.builtins.iter().any(|f| f.id == "head-classifier"));
|
assert!(view.builtins.iter().any(|f| f.id == "head-classifier"));
|
||||||
let json = serde_json::to_value(&view).expect("serializes");
|
let json = serde_json::to_value(&view).expect("serializes");
|
||||||
assert_eq!(json["schema"]["limits"]["max_nodes"], 64);
|
assert_eq!(json["schema"]["limits"]["max_nodes"], 64);
|
||||||
|
|
@ -331,7 +331,11 @@ mod tests {
|
||||||
.expect("read")
|
.expect("read")
|
||||||
.expect("stored");
|
.expect("stored");
|
||||||
let doc = Document::decode(&bytes).expect("the router reads it");
|
let doc = Document::decode(&bytes).expect("the router reads it");
|
||||||
assert_eq!(doc.active().0.len(), 4, "3 built-ins and the custom flow");
|
assert_eq!(
|
||||||
|
doc.active().0.len(),
|
||||||
|
notify_flows::builtins().len(),
|
||||||
|
"the built-ins less the switched-off one, and the custom flow"
|
||||||
|
);
|
||||||
|
|
||||||
// A refused save changes nothing: an orphan node, and a built-in that does not exist.
|
// A refused save changes nothing: an orphan node, and a built-in that does not exist.
|
||||||
let mut orphan = custom("orphan");
|
let mut orphan = custom("orphan");
|
||||||
|
|
|
||||||
|
|
@ -420,6 +420,13 @@ pub(super) enum DeliveryKind {
|
||||||
/// the instance, kept the same way.
|
/// the instance, kept the same way.
|
||||||
#[strum(serialize = "pull-request")]
|
#[strum(serialize = "pull-request")]
|
||||||
PullRequest,
|
PullRequest,
|
||||||
|
/// Every finished CI run that failed or was cancelled, on every repo in
|
||||||
|
/// the instance, kept the same way. Its own hook: Forgejo's hook edit
|
||||||
|
/// ignores the `workflow_run_*` events, so they can only be set when a
|
||||||
|
/// hook is created, and a `pull-request` hook registered earlier could not
|
||||||
|
/// gain them.
|
||||||
|
#[strum(serialize = "workflow-run")]
|
||||||
|
WorkflowRun,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The route prefix a registered `target_url` must point at.
|
/// The route prefix a registered `target_url` must point at.
|
||||||
|
|
@ -435,13 +442,14 @@ impl DeliveryKind {
|
||||||
/// Every kind, for the registration sweep. An array rather than a
|
/// Every kind, for the registration sweep. An array rather than a
|
||||||
/// hand-written list at the call site, so adding a kind cannot leave one
|
/// hand-written list at the call site, so adding a kind cannot leave one
|
||||||
/// hook unregistered.
|
/// hook unregistered.
|
||||||
pub(super) const ALL: [Self; 6] = [
|
pub(super) const ALL: [Self; 7] = [
|
||||||
Self::Knowledge,
|
Self::Knowledge,
|
||||||
Self::ConfigPr,
|
Self::ConfigPr,
|
||||||
Self::VcsActivity,
|
Self::VcsActivity,
|
||||||
Self::ReviewRequest,
|
Self::ReviewRequest,
|
||||||
Self::Issues,
|
Self::Issues,
|
||||||
Self::PullRequest,
|
Self::PullRequest,
|
||||||
|
Self::WorkflowRun,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The `target_url` to register with Forgejo for this kind, given the
|
/// The `target_url` to register with Forgejo for this kind, given the
|
||||||
|
|
@ -549,8 +557,8 @@ pub(super) fn verify(
|
||||||
/// comment for why both this and a periodic poll write the same cache), and a
|
/// comment for why both this and a periodic poll write the same cache), and a
|
||||||
/// merge queues a deploy of the merged commit on the agent's hive. A
|
/// merge queues a deploy of the merged commit on the agent's hive. A
|
||||||
/// `ReviewRequest` delivery naming the review classifier's account starts a
|
/// `ReviewRequest` delivery naming the review classifier's account starts a
|
||||||
/// review run in the background. An `Issues` or `PullRequest` delivery is
|
/// review run in the background. An `Issues`, `PullRequest` or `WorkflowRun`
|
||||||
/// kept in the forge events stream for the notification router
|
/// delivery is kept in the forge events stream for the notification router
|
||||||
/// (`crate::forge_events`).
|
/// (`crate::forge_events`).
|
||||||
///
|
///
|
||||||
/// Returns 200 on an accepted delivery so Forgejo does not retry. A refused
|
/// Returns 200 on an accepted delivery so Forgejo does not retry. A refused
|
||||||
|
|
@ -564,7 +572,8 @@ pub(super) fn verify(
|
||||||
params(
|
params(
|
||||||
("kind" = String, Path, description = "which swarm-wide hook this delivery arrived on: \
|
("kind" = String, Path, description = "which swarm-wide hook this delivery arrived on: \
|
||||||
`knowledge`, `config-pr`, `vcs-activity`, \
|
`knowledge`, `config-pr`, `vcs-activity`, \
|
||||||
`review-request`, `issues` or `pull-request`")
|
`review-request`, `issues`, `pull-request` or \
|
||||||
|
`workflow-run`")
|
||||||
),
|
),
|
||||||
request_body(
|
request_body(
|
||||||
content = String,
|
content = String,
|
||||||
|
|
@ -637,7 +646,7 @@ pub(super) async fn post_webhook_forge(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DeliveryKind::ReviewRequest => crate::review_classifier::review::on_delivery(&state, &body),
|
DeliveryKind::ReviewRequest => crate::review_classifier::review::on_delivery(&state, &body),
|
||||||
DeliveryKind::Issues | DeliveryKind::PullRequest => {
|
DeliveryKind::Issues | DeliveryKind::PullRequest | DeliveryKind::WorkflowRun => {
|
||||||
if let Err(not_kept) = crate::forge_events::publish(&state, &headers, body).await {
|
if let Err(not_kept) = crate::forge_events::publish(&state, &headers, body).await {
|
||||||
tracing::warn!(kind = <&str>::from(kind), reason = ?not_kept, "webhook: forge event not kept");
|
tracing::warn!(kind = <&str>::from(kind), reason = ?not_kept, "webhook: forge event not kept");
|
||||||
return (StatusCode::SERVICE_UNAVAILABLE, "forge event not kept").into_response();
|
return (StatusCode::SERVICE_UNAVAILABLE, "forge event not kept").into_response();
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ pub fn run(flows: &[Flow], events: &[(String, Vec<u8>)]) -> DryRun {
|
||||||
let mut snapshots: BTreeMap<String, Snapshot> = BTreeMap::new();
|
let mut snapshots: BTreeMap<String, Snapshot> = BTreeMap::new();
|
||||||
let mut run = 0;
|
let mut run = 0;
|
||||||
for (event_type, body) in events {
|
for (event_type, body) in events {
|
||||||
let Some(delivery) = Delivery::parse(body) else {
|
let Some(delivery) = Delivery::from_event(event_type, body) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
run += 1;
|
run += 1;
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ pub struct Delivery {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub sender: 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 is_pull: bool,
|
||||||
pub assignees: BTreeSet<String>,
|
pub assignees: BTreeSet<String>,
|
||||||
/// The PR is a draft: the forge's `pull_request.draft`, true for a title
|
/// The PR is a draft: the forge's `pull_request.draft`, true for a title
|
||||||
|
|
@ -82,6 +87,15 @@ impl Delivery {
|
||||||
.get("sender")
|
.get("sender")
|
||||||
.map(|s| text(s, "login"))
|
.map(|s| text(s, "login"))
|
||||||
.unwrap_or_default(),
|
.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,
|
is_pull,
|
||||||
assignees,
|
assignees,
|
||||||
draft,
|
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>`,
|
/// 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.
|
/// numeric so it is always a valid key whatever the repo is named.
|
||||||
pub fn state_key(&self) -> String {
|
pub fn state_key(&self) -> String {
|
||||||
|
|
@ -122,6 +160,11 @@ pub struct Snapshot {
|
||||||
/// The agents holding a router todo for this issue.
|
/// The agents holding a router todo for this issue.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub holders: BTreeSet<String>,
|
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
|
/// 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)),
|
("title", text(&delivery.title)),
|
||||||
("url", text(&delivery.url)),
|
("url", text(&delivery.url)),
|
||||||
("sender", text(&delivery.sender)),
|
("sender", text(&delivery.sender)),
|
||||||
|
("author", text(&delivery.author)),
|
||||||
("draft", text(bool_text(delivery.draft))),
|
("draft", text(bool_text(delivery.draft))),
|
||||||
("undrafted", text(bool_text(delivery.undrafted))),
|
("undrafted", text(bool_text(delivery.undrafted))),
|
||||||
(
|
(
|
||||||
|
|
@ -266,6 +310,89 @@ mod tests {
|
||||||
serde_json::to_vec(&body).expect("encodes")
|
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]
|
#[test]
|
||||||
fn the_forges_draft_flag_marks_a_draft() {
|
fn the_forges_draft_flag_marks_a_draft() {
|
||||||
let d = Delivery::parse(&pull_body("synchronized", "WIP: t", true, None)).expect("parses");
|
let d = Delivery::parse(&pull_body("synchronized", "WIP: t", true, None)).expect("parses");
|
||||||
|
|
@ -307,6 +434,7 @@ mod tests {
|
||||||
let before = Snapshot {
|
let before = Snapshot {
|
||||||
assignees: Some(BTreeSet::from(["atlas".to_owned(), "iris".to_owned()])),
|
assignees: Some(BTreeSet::from(["atlas".to_owned(), "iris".to_owned()])),
|
||||||
holders: BTreeSet::new(),
|
holders: BTreeSet::new(),
|
||||||
|
..Snapshot::default()
|
||||||
};
|
};
|
||||||
let f = fields("issue_assign", &d, &before);
|
let f = fields("issue_assign", &d, &before);
|
||||||
assert_eq!(list(&f, "assignees_removed"), ["atlas"]);
|
assert_eq!(list(&f, "assignees_removed"), ["atlas"]);
|
||||||
|
|
@ -316,6 +444,7 @@ mod tests {
|
||||||
let before = Snapshot {
|
let before = Snapshot {
|
||||||
assignees: Some(BTreeSet::from(["iris".to_owned()])),
|
assignees: Some(BTreeSet::from(["iris".to_owned()])),
|
||||||
holders: BTreeSet::new(),
|
holders: BTreeSet::new(),
|
||||||
|
..Snapshot::default()
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
list(&fields("issue_assign", &d, &before), "assignees_added"),
|
list(&fields("issue_assign", &d, &before), "assignees_added"),
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ impl Router {
|
||||||
let event_type = forge_events::event_type(&message.subject)
|
let event_type = forge_events::event_type(&message.subject)
|
||||||
.ok_or_else(|| Skip::Unusable("not a forge event subject".to_owned()))?
|
.ok_or_else(|| Skip::Unusable("not a forge event subject".to_owned()))?
|
||||||
.to_owned();
|
.to_owned();
|
||||||
let Some(delivery) = event::Delivery::parse(&message.payload) else {
|
let Some(delivery) = event::Delivery::from_event(&event_type, &message.payload) else {
|
||||||
// A delivery that names no issue or PR has nothing to route.
|
// A delivery that names no issue or PR has nothing to route.
|
||||||
return Ok(Handled::Ignored);
|
return Ok(Handled::Ignored);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,21 @@ pub fn route(
|
||||||
flows: &[Flow],
|
flows: &[Flow],
|
||||||
is_agent: impl Fn(&str) -> bool,
|
is_agent: impl Fn(&str) -> bool,
|
||||||
) -> Routed {
|
) -> Routed {
|
||||||
|
let is_run = event_type.starts_with("workflow_run_");
|
||||||
|
// Forgejo cancels the runs of a PR when it is pushed to, and an old run can
|
||||||
|
// finish after the push cleared the PR's CI todo. Such a run began at a
|
||||||
|
// commit the PR has since moved past, so it tells nobody anything.
|
||||||
|
if is_run
|
||||||
|
&& !delivery.head.is_empty()
|
||||||
|
&& snapshot.head.as_deref().is_some_and(|h| h != delivery.head)
|
||||||
|
{
|
||||||
|
return Routed {
|
||||||
|
actions: Vec::new(),
|
||||||
|
requests: Vec::new(),
|
||||||
|
skipped: Vec::new(),
|
||||||
|
next: snapshot.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
let fields = fields(event_type, delivery, snapshot);
|
let fields = fields(event_type, delivery, snapshot);
|
||||||
let mut actions = Vec::new();
|
let mut actions = Vec::new();
|
||||||
let mut skipped = Vec::new();
|
let mut skipped = Vec::new();
|
||||||
|
|
@ -47,6 +62,11 @@ pub fn route(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut next = snapshot.clone();
|
let mut next = snapshot.clone();
|
||||||
|
// A run's delivery carries the PR as it was when the run began, so only
|
||||||
|
// the other deliveries say where the PR's head is now.
|
||||||
|
if !is_run && delivery.is_pull && !delivery.head.is_empty() {
|
||||||
|
next.head = Some(delivery.head.clone());
|
||||||
|
}
|
||||||
if is_assign(event_type) {
|
if is_assign(event_type) {
|
||||||
// A skipped assignee is left out of the snapshot, so the next assign
|
// A skipped assignee is left out of the snapshot, so the next assign
|
||||||
// event counts it as added again: an agent assigned before it is in
|
// event counts it as added again: an agent assigned before it is in
|
||||||
|
|
@ -61,9 +81,13 @@ pub fn route(
|
||||||
Op::Upsert => {
|
Op::Upsert => {
|
||||||
next.holders.insert(action.agent.clone());
|
next.holders.insert(action.agent.clone());
|
||||||
}
|
}
|
||||||
Op::Clear => {
|
// Only a prefix clear can take every todo the agent held for the
|
||||||
|
// issue. A clear of one key leaves it a holder: it may still hold
|
||||||
|
// the assignment, which the `closed` flow must find.
|
||||||
|
Op::Clear if action.op.prefix => {
|
||||||
cleared.insert(action.agent.clone());
|
cleared.insert(action.agent.clone());
|
||||||
}
|
}
|
||||||
|
Op::Clear => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
next.holders.retain(|h| !cleared.contains(h));
|
next.holders.retain(|h| !cleared.contains(h));
|
||||||
|
|
@ -89,6 +113,8 @@ mod tests {
|
||||||
title: "t".to_owned(),
|
title: "t".to_owned(),
|
||||||
url: "u".to_owned(),
|
url: "u".to_owned(),
|
||||||
sender: "mara".to_owned(),
|
sender: "mara".to_owned(),
|
||||||
|
author: "mara".to_owned(),
|
||||||
|
head: String::new(),
|
||||||
is_pull: false,
|
is_pull: false,
|
||||||
assignees: assignees.iter().map(|a| (*a).to_owned()).collect(),
|
assignees: assignees.iter().map(|a| (*a).to_owned()).collect(),
|
||||||
draft: false,
|
draft: false,
|
||||||
|
|
@ -103,6 +129,135 @@ mod tests {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pull_by(author: &str, action: &str) -> Delivery {
|
||||||
|
Delivery {
|
||||||
|
is_pull: true,
|
||||||
|
author: author.to_owned(),
|
||||||
|
..delivery(action, &[])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A failed run tells the PR's author, a cancelled one rewrites that todo,
|
||||||
|
/// and a push clears it. The author keeps holding the PR, so a later close
|
||||||
|
/// still reaches them.
|
||||||
|
#[test]
|
||||||
|
fn a_failed_run_tells_the_author_and_a_push_clears_it() {
|
||||||
|
let flows = builtins();
|
||||||
|
let is_agent = |a: &str| a == "atlas";
|
||||||
|
|
||||||
|
let failed = route(
|
||||||
|
"workflow_run_failure",
|
||||||
|
&pull_by("atlas", "failure"),
|
||||||
|
&Snapshot::default(),
|
||||||
|
&flows,
|
||||||
|
is_agent,
|
||||||
|
);
|
||||||
|
assert_eq!(agents(&failed), [("atlas", Op::Upsert)]);
|
||||||
|
assert_eq!(failed.actions[0].op.key, "forge:o/r#9:ci");
|
||||||
|
assert!(
|
||||||
|
failed.actions[0]
|
||||||
|
.op
|
||||||
|
.summary
|
||||||
|
.starts_with("CI failed on PR o/r#9")
|
||||||
|
);
|
||||||
|
assert_eq!(failed.next.holders, BTreeSet::from(["atlas".to_owned()]));
|
||||||
|
|
||||||
|
let cancelled = route(
|
||||||
|
"workflow_run_cancelled",
|
||||||
|
&pull_by("atlas", "cancelled"),
|
||||||
|
&failed.next,
|
||||||
|
&flows,
|
||||||
|
is_agent,
|
||||||
|
);
|
||||||
|
assert_eq!(agents(&cancelled), [("atlas", Op::Upsert)]);
|
||||||
|
assert_eq!(cancelled.actions[0].op.key, "forge:o/r#9:ci");
|
||||||
|
assert!(
|
||||||
|
cancelled.actions[0]
|
||||||
|
.op
|
||||||
|
.summary
|
||||||
|
.starts_with("CI was cancelled on PR")
|
||||||
|
);
|
||||||
|
|
||||||
|
let pushed = route(
|
||||||
|
"pull_request_sync",
|
||||||
|
&pull_by("atlas", "synchronized"),
|
||||||
|
&cancelled.next,
|
||||||
|
&flows,
|
||||||
|
is_agent,
|
||||||
|
);
|
||||||
|
let ci_clear: Vec<_> = pushed
|
||||||
|
.actions
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.op.op == Op::Clear)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(ci_clear.len(), 1);
|
||||||
|
assert_eq!(ci_clear[0].op.key, "forge:o/r#9:ci");
|
||||||
|
assert!(
|
||||||
|
!ci_clear[0].op.prefix,
|
||||||
|
"only the CI todo goes, not the assignment"
|
||||||
|
);
|
||||||
|
assert_eq!(pushed.next.holders, BTreeSet::from(["atlas".to_owned()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn at_head(mut d: Delivery, head: &str) -> Delivery {
|
||||||
|
head.clone_into(&mut d.head);
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The push that clears the CI todo also cancels the runs before it, and
|
||||||
|
/// one of them can report in after the clear. It began at the old head, so
|
||||||
|
/// it must not bring the todo back.
|
||||||
|
#[test]
|
||||||
|
fn a_run_from_before_the_latest_push_tells_nobody() {
|
||||||
|
let flows = builtins();
|
||||||
|
let is_agent = |a: &str| a == "atlas";
|
||||||
|
let run = |event: &str, head: &str, before: &Snapshot| {
|
||||||
|
// The router names a run's action after its outcome.
|
||||||
|
let outcome = event.trim_start_matches("workflow_run_");
|
||||||
|
route(
|
||||||
|
event,
|
||||||
|
&at_head(pull_by("atlas", outcome), head),
|
||||||
|
before,
|
||||||
|
&flows,
|
||||||
|
is_agent,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Before the router has seen the PR's head it can't tell, so it tells.
|
||||||
|
let unknown = run("workflow_run_cancelled", "old", &Snapshot::default());
|
||||||
|
assert_eq!(agents(&unknown), [("atlas", Op::Upsert)]);
|
||||||
|
assert_eq!(unknown.next.head, None, "a run says nothing about the head");
|
||||||
|
|
||||||
|
let pushed = route(
|
||||||
|
"pull_request_sync",
|
||||||
|
&at_head(pull_by("atlas", "synchronized"), "new"),
|
||||||
|
&unknown.next,
|
||||||
|
&flows,
|
||||||
|
is_agent,
|
||||||
|
);
|
||||||
|
assert_eq!(pushed.next.head.as_deref(), Some("new"));
|
||||||
|
|
||||||
|
let late = run("workflow_run_cancelled", "old", &pushed.next);
|
||||||
|
assert!(late.actions.is_empty() && late.skipped.is_empty());
|
||||||
|
assert_eq!(late.next, pushed.next);
|
||||||
|
|
||||||
|
let current = run("workflow_run_failure", "new", &pushed.next);
|
||||||
|
assert_eq!(agents(¤t), [("atlas", Op::Upsert)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failed_run_on_a_pull_request_by_a_non_agent_queues_nothing() {
|
||||||
|
let got = route(
|
||||||
|
"workflow_run_failure",
|
||||||
|
&pull_by("mara", "failure"),
|
||||||
|
&Snapshot::default(),
|
||||||
|
&builtins(),
|
||||||
|
|a| a == "atlas",
|
||||||
|
);
|
||||||
|
assert!(got.actions.is_empty());
|
||||||
|
assert_eq!(got.skipped, ["mara"]);
|
||||||
|
}
|
||||||
|
|
||||||
/// Assign, reassign, close: the three built-ins in the order an issue
|
/// Assign, reassign, close: the three built-ins in the order an issue
|
||||||
/// meets them, carrying the snapshot from one event to the next.
|
/// meets them, carrying the snapshot from one event to the next.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -208,6 +363,7 @@ mod tests {
|
||||||
let before = Snapshot {
|
let before = Snapshot {
|
||||||
assignees: Some(BTreeSet::from(["atlas".to_owned()])),
|
assignees: Some(BTreeSet::from(["atlas".to_owned()])),
|
||||||
holders: BTreeSet::from(["atlas".to_owned()]),
|
holders: BTreeSet::from(["atlas".to_owned()]),
|
||||||
|
..Snapshot::default()
|
||||||
};
|
};
|
||||||
let r = route("pull_request_sync", &pull, &before, &flows, |_| false);
|
let r = route("pull_request_sync", &pull, &before, &flows, |_| false);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -220,7 +376,10 @@ mod tests {
|
||||||
reviewer: "review-classifier".to_owned(),
|
reviewer: "review-classifier".to_owned(),
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
assert!(r.actions.is_empty() && r.skipped.is_empty());
|
// The push also clears the author's CI todo, but the author is no
|
||||||
|
// agent here, so that goes nowhere.
|
||||||
|
assert!(r.actions.is_empty());
|
||||||
|
assert_eq!(r.skipped, ["mara"]);
|
||||||
assert_eq!(r.next, before);
|
assert_eq!(r.next, before);
|
||||||
|
|
||||||
// The source gates: another PR event requests nothing.
|
// The source gates: another PR event requests nothing.
|
||||||
|
|
@ -271,6 +430,7 @@ mod tests {
|
||||||
let before = Snapshot {
|
let before = Snapshot {
|
||||||
assignees: Some(BTreeSet::from(["atlas".to_owned()])),
|
assignees: Some(BTreeSet::from(["atlas".to_owned()])),
|
||||||
holders: BTreeSet::from(["atlas".to_owned()]),
|
holders: BTreeSet::from(["atlas".to_owned()]),
|
||||||
|
..Snapshot::default()
|
||||||
};
|
};
|
||||||
let r = route(
|
let r = route(
|
||||||
"issue_comment",
|
"issue_comment",
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,17 @@ mod tests {
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// The built-in flows, in the order the router runs them.
|
||||||
|
const BUILTIN_IDS: [&str; 7] = [
|
||||||
|
"assigned",
|
||||||
|
"unassigned",
|
||||||
|
"closed",
|
||||||
|
"head-classifier",
|
||||||
|
"ci-failed",
|
||||||
|
"ci-cancelled",
|
||||||
|
"ci-cleared",
|
||||||
|
];
|
||||||
|
|
||||||
fn ids(f: &Flows) -> Vec<&str> {
|
fn ids(f: &Flows) -> Vec<&str> {
|
||||||
f.active.iter().map(|f| f.id.as_str()).collect()
|
f.active.iter().map(|f| f.id.as_str()).collect()
|
||||||
}
|
}
|
||||||
|
|
@ -188,28 +199,30 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn with_no_document_the_router_runs_the_builtins() {
|
fn with_no_document_the_router_runs_the_builtins() {
|
||||||
let mut f = Flows::builtins();
|
let mut f = Flows::builtins();
|
||||||
assert_eq!(
|
assert_eq!(ids(&f), BUILTIN_IDS);
|
||||||
ids(&f),
|
|
||||||
["assigned", "unassigned", "closed", "head-classifier"]
|
|
||||||
);
|
|
||||||
f.apply(Read::Absent);
|
f.apply(Read::Absent);
|
||||||
assert_eq!(
|
assert_eq!(ids(&f), BUILTIN_IDS);
|
||||||
ids(&f),
|
|
||||||
["assigned", "unassigned", "closed", "head-classifier"]
|
|
||||||
);
|
|
||||||
// An empty document is the same.
|
// An empty document is the same.
|
||||||
f.apply(at(1, doc(&[], &[])));
|
f.apply(at(1, doc(&[], &[])));
|
||||||
assert_eq!(
|
assert_eq!(ids(&f), BUILTIN_IDS);
|
||||||
ids(&f),
|
|
||||||
["assigned", "unassigned", "closed", "head-classifier"]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_document_switches_builtins_off_and_adds_custom_flows() {
|
fn a_document_switches_builtins_off_and_adds_custom_flows() {
|
||||||
let mut f = Flows::builtins();
|
let mut f = Flows::builtins();
|
||||||
f.apply(at(1, doc(&["unassigned"], &["triage"])));
|
f.apply(at(1, doc(&["unassigned"], &["triage"])));
|
||||||
assert_eq!(ids(&f), ["assigned", "closed", "head-classifier", "triage"]);
|
assert_eq!(
|
||||||
|
ids(&f),
|
||||||
|
[
|
||||||
|
"assigned",
|
||||||
|
"closed",
|
||||||
|
"head-classifier",
|
||||||
|
"ci-failed",
|
||||||
|
"ci-cancelled",
|
||||||
|
"ci-cleared",
|
||||||
|
"triage"
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -217,7 +230,10 @@ mod tests {
|
||||||
let mut f = Flows::builtins();
|
let mut f = Flows::builtins();
|
||||||
f.apply(at(1, doc(&["closed"], &["triage"])));
|
f.apply(at(1, doc(&["closed"], &["triage"])));
|
||||||
let before = ids(&f).join(",");
|
let before = ids(&f).join(",");
|
||||||
assert_eq!(before, "assigned,unassigned,head-classifier,triage");
|
assert_eq!(
|
||||||
|
before,
|
||||||
|
"assigned,unassigned,head-classifier,ci-failed,ci-cancelled,ci-cleared,triage"
|
||||||
|
);
|
||||||
for bad in [
|
for bad in [
|
||||||
junk(2, b"not json"),
|
junk(2, b"not json"),
|
||||||
junk(3, br#"{"version":2,"flows":[]}"#),
|
junk(3, br#"{"version":2,"flows":[]}"#),
|
||||||
|
|
@ -230,20 +246,14 @@ mod tests {
|
||||||
// The next good revision replaces them: the control that the bad
|
// The next good revision replaces them: the control that the bad
|
||||||
// ones were kept out by being bad, not by the loader being stuck.
|
// ones were kept out by being bad, not by the loader being stuck.
|
||||||
f.apply(at(5, doc(&[], &[])));
|
f.apply(at(5, doc(&[], &[])));
|
||||||
assert_eq!(
|
assert_eq!(ids(&f), BUILTIN_IDS);
|
||||||
ids(&f),
|
|
||||||
["assigned", "unassigned", "closed", "head-classifier"]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_bad_first_document_is_the_builtins_not_nothing() {
|
fn a_bad_first_document_is_the_builtins_not_nothing() {
|
||||||
let mut f = Flows::builtins();
|
let mut f = Flows::builtins();
|
||||||
f.apply(junk(1, br#"{"version":9}"#));
|
f.apply(junk(1, br#"{"version":9}"#));
|
||||||
assert_eq!(
|
assert_eq!(ids(&f), BUILTIN_IDS);
|
||||||
ids(&f),
|
|
||||||
["assigned", "unassigned", "closed", "head-classifier"]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -251,10 +261,7 @@ mod tests {
|
||||||
let mut f = Flows::builtins();
|
let mut f = Flows::builtins();
|
||||||
f.apply(at(1, doc(&["closed"], &["triage"])));
|
f.apply(at(1, doc(&["closed"], &["triage"])));
|
||||||
f.apply(Read::Absent);
|
f.apply(Read::Absent);
|
||||||
assert_eq!(
|
assert_eq!(ids(&f), BUILTIN_IDS);
|
||||||
ids(&f),
|
|
||||||
["assigned", "unassigned", "closed", "head-classifier"]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -272,9 +279,6 @@ mod tests {
|
||||||
revision: 1,
|
revision: 1,
|
||||||
bytes: serde_json::to_vec(&d).unwrap(),
|
bytes: serde_json::to_vec(&d).unwrap(),
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(ids(&f), BUILTIN_IDS);
|
||||||
ids(&f),
|
|
||||||
["assigned", "unassigned", "closed", "head-classifier"]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,14 @@ pub const SOURCE_EVENTS: &[(&str, &str)] = &[
|
||||||
"pull_request_review_comment",
|
"pull_request_review_comment",
|
||||||
"A review submitted on a pull request that only comments",
|
"A review submitted on a pull request that only comments",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"workflow_run_failure",
|
||||||
|
"A CI run on a pull request that finished with a failed job",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workflow_run_cancelled",
|
||||||
|
"A CI run on a pull request that was cancelled",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The fields of an event a condition or a recipient can name, with what each
|
/// The fields of an event a condition or a recipient can name, with what each
|
||||||
|
|
@ -87,6 +95,10 @@ pub const FIELDS: &[(&str, &str)] = &[
|
||||||
("title", "The issue or pull request title"),
|
("title", "The issue or pull request title"),
|
||||||
("url", "The web link to the issue or pull request"),
|
("url", "The web link to the issue or pull request"),
|
||||||
("sender", "The account that caused the event"),
|
("sender", "The account that caused the event"),
|
||||||
|
(
|
||||||
|
"author",
|
||||||
|
"The account that opened the issue or pull request",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"draft",
|
"draft",
|
||||||
"true when the pull request is a draft (its title starts WIP: or [WIP]), else false",
|
"true when the pull request is a draft (its title starts WIP: or [WIP]), else false",
|
||||||
|
|
@ -781,10 +793,15 @@ impl TodoSink {
|
||||||
/// `closed` key.
|
/// `closed` key.
|
||||||
/// - `head-classifier`: a PR opened, reopened or pushed to requests the review
|
/// - `head-classifier`: a PR opened, reopened or pushed to requests the review
|
||||||
/// classifier's review.
|
/// classifier's review.
|
||||||
|
/// - `ci-failed` and `ci-cancelled`: a CI run on a PR that failed or was
|
||||||
|
/// cancelled gives the PR's author a todo. Both write the same key, so the
|
||||||
|
/// later run's outcome replaces the earlier one.
|
||||||
|
/// - `ci-cleared`: a push to the PR's branch clears that todo, as the new run
|
||||||
|
/// replaces the old one.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn builtins() -> Vec<Flow> {
|
pub fn builtins() -> Vec<Flow> {
|
||||||
let assignment_key = "forge:{repo}#{number}:assignment";
|
let assignment_key = "forge:{repo}#{number}:assignment";
|
||||||
vec![
|
let mut flows = vec![
|
||||||
chain(
|
chain(
|
||||||
"assigned",
|
"assigned",
|
||||||
&["issue_assign", "pull_request_assign"],
|
&["issue_assign", "pull_request_assign"],
|
||||||
|
|
@ -812,6 +829,47 @@ pub fn builtins() -> Vec<Flow> {
|
||||||
),
|
),
|
||||||
closed(),
|
closed(),
|
||||||
head_classifier(),
|
head_classifier(),
|
||||||
|
];
|
||||||
|
flows.extend(ci_flows());
|
||||||
|
flows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The flows that tell a PR's author about its CI: the todo for a failed run,
|
||||||
|
/// the same key rewritten for a cancelled one, and the clear a push makes.
|
||||||
|
fn ci_flows() -> Vec<Flow> {
|
||||||
|
let ci_key = "forge:{repo}#{number}:ci";
|
||||||
|
let to_author = |op, text: &str| TodoSink {
|
||||||
|
to: Recipients::Field("author".to_owned()),
|
||||||
|
op,
|
||||||
|
key: ci_key.to_owned(),
|
||||||
|
prefix: false,
|
||||||
|
text: text.to_owned(),
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
chain(
|
||||||
|
"ci-failed",
|
||||||
|
&["workflow_run_failure"],
|
||||||
|
"failure",
|
||||||
|
to_author(
|
||||||
|
Op::Upsert,
|
||||||
|
"CI failed on {kind} {repo}#{number}: {title} ({url})",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
chain(
|
||||||
|
"ci-cancelled",
|
||||||
|
&["workflow_run_cancelled"],
|
||||||
|
"cancelled",
|
||||||
|
to_author(
|
||||||
|
Op::Upsert,
|
||||||
|
"CI was cancelled on {kind} {repo}#{number}: {title} ({url})",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
chain(
|
||||||
|
"ci-cleared",
|
||||||
|
&["pull_request_sync"],
|
||||||
|
"synchronized",
|
||||||
|
to_author(Op::Clear, ""),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1385,7 +1443,15 @@ mod tests {
|
||||||
let ids = |d: &Document| d.active().0.into_iter().map(|f| f.id).collect::<Vec<_>>();
|
let ids = |d: &Document| d.active().0.into_iter().map(|f| f.id).collect::<Vec<_>>();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ids(&Document::default()),
|
ids(&Document::default()),
|
||||||
["assigned", "unassigned", "closed", "head-classifier"],
|
[
|
||||||
|
"assigned",
|
||||||
|
"unassigned",
|
||||||
|
"closed",
|
||||||
|
"head-classifier",
|
||||||
|
"ci-failed",
|
||||||
|
"ci-cancelled",
|
||||||
|
"ci-cleared"
|
||||||
|
],
|
||||||
"an empty document is the built-ins"
|
"an empty document is the built-ins"
|
||||||
);
|
);
|
||||||
let mut off = custom("off");
|
let mut off = custom("off");
|
||||||
|
|
@ -1399,7 +1465,15 @@ mod tests {
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ids(&doc),
|
ids(&doc),
|
||||||
["assigned", "closed", "head-classifier", "triage"]
|
[
|
||||||
|
"assigned",
|
||||||
|
"closed",
|
||||||
|
"head-classifier",
|
||||||
|
"ci-failed",
|
||||||
|
"ci-cancelled",
|
||||||
|
"ci-cleared",
|
||||||
|
"triage"
|
||||||
|
]
|
||||||
);
|
);
|
||||||
let (_, skipped) = doc.active();
|
let (_, skipped) = doc.active();
|
||||||
assert_eq!(skipped.len(), 2, "{skipped:?}");
|
assert_eq!(skipped.len(), 2, "{skipped:?}");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue