Watch
0
0
Fork
You've already forked hyperhive
0

an agent gets one todo when its pr starts conflicting with the target branch

This commit is contained in:
atlas 2026-11-11 04:36:28 +01:00 • committed by mara
commit 11eb7dac9c
2 changed files with 963 additions and 21 deletions

View file

@ -24,6 +24,12 @@ pub const MAX_AGE: Duration = Duration::from_hours(7 * 24);
/// The router's durable consumer on [`STREAM`].
pub const ROUTER_CONSUMER: &str = "notify-router";
/// The event type swarm-controller publishes, on [`subject`], for a pull request
/// it has checked against its target branch after a push there. Forgejo sends
/// no such delivery. The body is a `pull_request` delivery whose `action` is
/// `conflicting` or `mergeable`.
pub const MERGEABILITY_EVENT: &str = "pull_request_mergeability";
/// The subject a delivery of Forgejo event type `event_type` is kept under.
#[must_use]
pub fn subject(event_type: &str) -> String {

View file

@ -88,6 +88,11 @@ pub const SOURCE_EVENTS: &[(&str, &str)] = &[
"workflow_run_cancelled",
"A CI run on a pull request that was cancelled",
),
(
"pull_request_mergeability",
"What swarm-controller found about an open pull request after a push to its target \
branch: action conflicting, or mergeable",
),
];
/// The fields of an event a condition or a recipient can name, with what each
@ -123,6 +128,10 @@ pub const FIELDS: &[(&str, &str)] = &[
"todo_holders",
"The agents holding a router todo for the issue or pull request (a list)",
),
(
"mergeability_changed",
"true when a mergeability event moves the pull request into or out of conflict, else false",
),
];
/// The forge account of the review classifier, whose review the built-in
@ -426,6 +435,11 @@ const ACTIONS: &[(&str, &str)] = &[
("assigned", "An assignee added"),
("unassigned", "An assignee removed"),
("synchronized", "New commits pushed to a pull request"),
(
"conflicting",
"A pull request does not merge into its target branch",
),
("mergeable", "A pull request merges into its target branch"),
];
/// One event field a condition or recipient can name, and what it can hold.
@ -838,6 +852,10 @@ impl TodoSink {
/// 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.
/// - `conflict` and `conflict-cleared`: a PR that stops merging into its
/// target branch gives its author a todo, and the todo clears when it merges
/// again. Both read `mergeability_changed`, so a repeated finding that the
/// PR still conflicts writes nothing.
#[must_use]
pub fn builtins() -> Vec<Flow> {
let assignment_key = "forge:{{ repo }}#{{ number }}:assignment";
@ -871,9 +889,47 @@ pub fn builtins() -> Vec<Flow> {
head_classifier(),
];
flows.extend(ci_flows());
flows.extend(conflict_flows());
flows
}
/// source(`pull_request_mergeability`) → match(`action` and
/// `mergeability_changed` = `true`) → the author's `:conflict` todo.
fn conflict_flows() -> Vec<Flow> {
let flow = |id: &str, action: &str, op, text: &str| {
let mut flow = chain(
id,
&["pull_request_mergeability"],
action,
TodoSink {
to: Recipients::Field("author".to_owned()),
op,
key: "forge:{{ repo }}#{{ number }}:conflict".to_owned(),
prefix: false,
text: text.to_owned(),
},
);
for node in &mut flow.nodes {
if let NodeKind::Match { when } = &mut node.kind {
when.push(Condition {
field: "mergeability_changed".to_owned(),
equals: "true".to_owned(),
});
}
}
flow
};
vec![
flow(
"conflict",
"conflicting",
Op::Upsert,
"{{ kind }} {{ repo }}#{{ number }} no longer merges into its target branch: {{ title }} ({{ url }})",
),
flow("conflict-cleared", "mergeable", Op::Clear, ""),
]
}
/// 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> {
@ -1077,6 +1133,42 @@ mod tests {
}
}
/// Both conflict flows act only when the mergeability event changed the
/// PR's state, and write the author's `:conflict` key.
#[test]
fn the_conflict_flows_read_the_change_and_write_the_conflict_key() {
for (id, action, op) in [
("conflict", "conflicting", Op::Upsert),
("conflict-cleared", "mergeable", Op::Clear),
] {
let f = builtin(id);
assert_eq!(
f.node("source").map(|n| &n.kind),
Some(&NodeKind::Source {
events: vec!["pull_request_mergeability".to_owned()]
}),
"{id}"
);
let want = |field: &str, equals: &str| Condition {
field: field.to_owned(),
equals: equals.to_owned(),
};
assert_eq!(
f.node("match").map(|n| &n.kind),
Some(&NodeKind::Match {
when: vec![want("action", action), want("mergeability_changed", "true")]
}),
"{id}"
);
let Some(NodeKind::Todo(sink)) = f.node("sink").map(|n| &n.kind) else {
panic!("{id}: no todo");
};
assert_eq!(sink.op, op, "{id}");
assert_eq!(sink.key, "forge:{{ repo }}#{{ number }}:conflict", "{id}");
assert_eq!(sink.to, Recipients::Field("author".to_owned()), "{id}");
}
}
/// What the schema offers is what the check accepts, and nothing else is.
#[test]
fn the_schema_lists_what_a_flow_may_name() {
@ -1519,7 +1611,9 @@ mod tests {
"head-classifier",
"ci-failed",
"ci-cancelled",
"ci-cleared"
"ci-cleared",
"conflict",
"conflict-cleared"
],
"an empty document is the built-ins"
);
@ -1541,6 +1635,8 @@ mod tests {
"ci-failed",
"ci-cancelled",
"ci-cleared",
"conflict",
"conflict-cleared",
"triage"
]
);