review-classifier: skip draft prs, classify once when they leave draft
This commit is contained in:
parent
65d75da8db
commit
49ef93a19e
3 changed files with 280 additions and 34 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"], &[], &[]);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue