Watch
0
0
Fork
You've already forked hyperhive
0

review-classifier: skip draft prs, classify once when they leave draft

This commit is contained in:
atlas 2026-11-02 17:58:22 +01:00 • committed by mara
commit 49ef93a19e
3 changed files with 280 additions and 34 deletions

View file

@ -28,6 +28,13 @@ pub struct Delivery {
pub sender: String,
pub is_pull: bool,
pub assignees: BTreeSet<String>,
/// The PR is a draft: the forge's `pull_request.draft`, true for a title
/// that starts with one of its work-in-progress prefixes.
pub draft: bool,
/// The delivery is a title edit that took a PR out of draft: `edited`, no
/// longer a draft, and the old title in `changes.title.from` starts with a
/// work-in-progress prefix.
pub undrafted: bool,
}
impl Delivery {
@ -55,8 +62,17 @@ impl Delivery {
.collect()
})
.unwrap_or_default();
let action = text(&json, "action");
let draft = is_pull && item.get("draft").and_then(Json::as_bool).unwrap_or(false);
let undrafted = is_pull
&& action == "edited"
&& !draft
&& json
.pointer("/changes/title/from")
.and_then(Json::as_str)
.is_some_and(is_wip_title);
Some(Self {
action: text(&json, "action"),
action,
repo: text(repository, "full_name"),
repo_id: repository.get("id")?.as_i64()?,
number: item.get("number")?.as_i64()?,
@ -68,6 +84,8 @@ impl Delivery {
.unwrap_or_default(),
is_pull,
assignees,
draft,
undrafted,
})
}
@ -78,6 +96,22 @@ impl Delivery {
}
}
/// The prefixes the forge treats as work-in-progress (its default
/// `WORK_IN_PROGRESS_PREFIXES`), compared without regard to case.
const WIP_PREFIXES: [&str; 2] = ["WIP:", "[WIP]"];
/// Whether `title` starts with a work-in-progress prefix. A title edit's
/// payload carries the old title and no flag for it, so the prefix is the only
/// way to tell what the PR was before.
fn is_wip_title(title: &str) -> bool {
let title = title.trim_start();
WIP_PREFIXES.iter().any(|p| {
title
.get(..p.len())
.is_some_and(|head| head.eq_ignore_ascii_case(p))
})
}
/// What the router keeps per issue in its state bucket.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Snapshot {
@ -119,6 +153,8 @@ pub fn fields(event_type: &str, delivery: &Delivery, snapshot: &Snapshot) -> Fie
("title", text(&delivery.title)),
("url", text(&delivery.url)),
("sender", text(&delivery.sender)),
("draft", text(bool_text(delivery.draft))),
("undrafted", text(bool_text(delivery.undrafted))),
(
"assignees",
Value::List(delivery.assignees.iter().cloned().collect()),
@ -132,6 +168,10 @@ pub fn fields(event_type: &str, delivery: &Delivery, snapshot: &Snapshot) -> Fie
])
}
const fn bool_text(b: bool) -> &'static str {
if b { "true" } else { "false" }
}
#[cfg(test)]
mod tests {
use super::*;
@ -212,6 +252,55 @@ mod tests {
assert_eq!(Delivery::parse(b"{\"ref\":\"main\"}"), None);
}
fn pull_body(action: &str, title: &str, draft: bool, was: Option<&str>) -> Vec<u8> {
let mut body = serde_json::json!({
"action": action,
"number": 7,
"pull_request": { "number": 7, "title": title, "html_url": "u", "draft": draft },
"repository": { "id": 1, "full_name": "o/r" },
"sender": { "login": "s" },
});
if let Some(was) = was {
body["changes"] = serde_json::json!({ "title": { "from": was } });
}
serde_json::to_vec(&body).expect("encodes")
}
#[test]
fn the_forges_draft_flag_marks_a_draft() {
let d = Delivery::parse(&pull_body("synchronized", "WIP: t", true, None)).expect("parses");
assert!(d.draft && !d.undrafted);
let d = Delivery::parse(&pull_body("synchronized", "t", false, None)).expect("parses");
assert!(!d.draft && !d.undrafted);
let f = fields("pull_request_sync", &d, &Snapshot::default());
assert_eq!(f.get("draft"), Some(&Value::Text("false".to_owned())));
}
/// Only an edit that drops a WIP prefix from the title and leaves the PR
/// not a draft is an un-draft.
#[test]
fn a_title_edit_that_drops_the_wip_prefix_is_an_undraft() {
for was in ["WIP: t", "[WIP] t", "wip: t", " [wip] t"] {
let d = Delivery::parse(&pull_body("edited", "t", false, Some(was))).expect("parses");
assert!(d.undrafted, "{was}");
}
for (action, title, draft, was) in [
("edited", "t", false, Some("old title")),
("edited", "t", false, None),
("edited", "WIP: t", true, Some("WIP: u")),
("opened", "t", false, Some("WIP: t")),
] {
let d = Delivery::parse(&pull_body(action, title, draft, was)).expect("parses");
assert!(!d.undrafted, "{action} {title} {was:?}");
}
let f = fields(
"pull_request",
&Delivery::parse(&pull_body("edited", "t", false, Some("WIP: t"))).expect("parses"),
&Snapshot::default(),
);
assert_eq!(f.get("undrafted"), Some(&Value::Text("true".to_owned())));
}
#[test]
fn the_snapshot_turns_the_after_list_into_added_and_removed() {
let d = Delivery::parse(&issue_assign("unassigned", &["iris"])).expect("parses");

View file

@ -221,6 +221,8 @@ mod tests {
("number", text("7")),
("title", text("router")),
("url", text("https://forge/7")),
("draft", text("false")),
("undrafted", text("false")),
("assignees_added", list(added)),
("assignees_removed", list(removed)),
("todo_holders", list(holders)),
@ -458,6 +460,44 @@ mod tests {
assert_eq!(got("pull_request_assign", "opened"), 0);
}
/// A draft PR gets no request on open, reopen or push; the edit that takes
/// it out of draft gets exactly one, and a title edit that does not does not.
#[test]
fn the_classifier_flow_skips_a_draft_and_requests_once_when_it_leaves_draft() {
let f = swarm_wire::notify_flows::builtins()
.into_iter()
.find(|f| f.id == "head-classifier")
.expect("the built-in");
let pull = |action: &str, draft: bool, undrafted: bool| {
let mut fields = event(action, &[], &[], &[]);
fields.insert("kind", text("PR"));
fields.insert("draft", text(if draft { "true" } else { "false" }));
fields.insert("undrafted", text(if undrafted { "true" } else { "false" }));
fields
};
let got = |event_type: &str, fields: &Fields| requests(&f, event_type, fields);
for (event_type, action) in [
("pull_request", "opened"),
("pull_request", "reopened"),
("pull_request_sync", "synchronized"),
] {
assert!(
got(event_type, &pull(action, true, false)).is_empty(),
"{action}"
);
assert_eq!(
got(event_type, &pull(action, false, false)).len(),
1,
"{action}"
);
}
let left = got("pull_request", &pull("edited", false, true));
assert_eq!(left.len(), 1);
assert_eq!(left[0].node, "request-undrafted");
assert!(got("pull_request", &pull("edited", false, false)).is_empty());
assert!(got("pull_request", &pull("edited", true, false)).is_empty());
}
#[test]
fn a_placeholder_expands_to_its_field_and_an_unknown_one_is_an_error() {
let fields = event("assigned", &["a", "b"], &[], &[]);

View file

@ -91,6 +91,8 @@ mod tests {
sender: "mara".to_owned(),
is_pull: false,
assignees: assignees.iter().map(|a| (*a).to_owned()).collect(),
draft: false,
undrafted: false,
}
}
@ -241,6 +243,29 @@ mod tests {
assert!(r.requests.is_empty());
}
/// A push to a draft PR requests nothing; the title edit that takes it out
/// of draft requests once.
#[test]
fn a_draft_gets_no_request_until_it_leaves_draft() {
let flows = builtins();
let before = Snapshot::default();
let draft = Delivery {
is_pull: true,
draft: true,
..delivery("synchronized", &[])
};
let r = route("pull_request_sync", &draft, &before, &flows, |_| false);
assert!(r.requests.is_empty());
let ready = Delivery {
is_pull: true,
undrafted: true,
..delivery("edited", &[])
};
let r = route("pull_request", &ready, &before, &flows, |_| false);
assert_eq!(r.requests.len(), 1);
assert_eq!(r.requests[0].node, "request-undrafted");
}
#[test]
fn an_event_no_flow_matches_changes_nothing() {
let before = Snapshot {