diff --git a/src/config.rs b/src/config.rs index fca6b8cf..85170cf5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -47,6 +47,7 @@ pub(crate) struct Config { pub(crate) issue_links: Option, pub(crate) no_mentions: Option, pub(crate) behind_upstream: Option, + pub(crate) backport: Option, } #[derive(PartialEq, Eq, Debug, serde::Deserialize)] @@ -522,6 +523,25 @@ fn default_true() -> bool { true } +#[derive(PartialEq, Eq, Debug, serde::Deserialize)] +pub(crate) struct BackportConfig { + // Config identifier -> labels + #[serde(flatten)] + pub(crate) configs: HashMap, +} + +#[derive(Default, PartialEq, Eq, Debug, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +#[serde(deny_unknown_fields)] +pub(crate) struct BackportRuleConfig { + /// Prerequisite label(s) (one of them) to trigger this handler for a specific team + pub(crate) required_pr_labels: Vec, + /// Prerequisite label for an issue to qualify as regression + pub(crate) required_issue_label: String, + /// Labels to be added to a pull request closing the regression + pub(crate) add_labels: Vec, +} + fn get_cached_config(repo: &str) -> Option, ConfigurationError>> { let cache = CONFIG_CACHE.read().unwrap(); cache.get(repo).and_then(|(config, fetch_time)| { @@ -655,6 +675,11 @@ mod tests { [behind-upstream] days-threshold = 14 + + [backport.teamRed] + required-pr-labels = ["T-libs", "T-libs-api"] + required-issue-label = "regression-from-stable-to-stable" + add-labels = ["stable-nominated"] "#; let config = toml::from_str::(&config).unwrap(); let mut ping_teams = HashMap::new(); @@ -679,6 +704,20 @@ mod tests { nominate_teams.insert("release".to_owned(), "T-release".to_owned()); nominate_teams.insert("core".to_owned(), "T-core".to_owned()); nominate_teams.insert("infra".to_owned(), "T-infra".to_owned()); + + let mut backport_configs = HashMap::new(); + backport_configs.insert( + "teamRed".into(), + BackportRuleConfig { + required_pr_labels: vec!["T-libs".into(), "T-libs-api".into()], + required_issue_label: "regression-from-stable-to-stable".into(), + add_labels: vec!["stable-nominated".into()], + }, + ); + let backport_team_config = BackportConfig { + configs: backport_configs, + }; + assert_eq!( config, Config { @@ -727,6 +766,7 @@ mod tests { concern: Some(ConcernConfig { labels: vec!["has-concerns".to_string()], }), + backport: Some(backport_team_config) } ); } @@ -812,6 +852,7 @@ mod tests { behind_upstream: Some(BehindUpstreamConfig { days_threshold: Some(7), }), + backport: None } ); } diff --git a/src/handlers.rs b/src/handlers.rs index 7b14a5bc..68705259 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -28,6 +28,7 @@ impl fmt::Display for HandlerError { mod assign; mod autolabel; +mod backport; mod bot_pull_requests; mod check_commits; mod close; @@ -225,6 +226,7 @@ macro_rules! issue_handlers { issue_handlers! { assign, autolabel, + backport, issue_links, major_change, mentions, diff --git a/src/handlers/backport.rs b/src/handlers/backport.rs new file mode 100644 index 00000000..eec82550 --- /dev/null +++ b/src/handlers/backport.rs @@ -0,0 +1,242 @@ +use std::collections::HashMap; +use std::sync::LazyLock; + +use crate::config::BackportConfig; +use crate::github::{IssuesAction, IssuesEvent, Label}; +use crate::handlers::Context; +use anyhow::Context as AnyhowContext; +use futures::future::join_all; +use regex::Regex; +use tracing as log; + +// See https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-issues/linking-a-pull-request-to-an-issue +// See tests to see what matches +static CLOSES_ISSUE_REGEXP: LazyLock = LazyLock::new(|| { + Regex::new("(?i)(?Pclose[sd]*|fix([e]*[sd]*)?|resolve[sd]*)(?P:? +)(?P[a-zA-Z0-9_-]*/[a-zA-Z0-9_-]*)?#(?P[0-9]+)").unwrap() +}); + +const BACKPORT_LABELS: [&str; 4] = [ + "beta-nominated", + "beta-accepted", + "stable-nominated", + "stable-accepted", +]; + +const REGRESSION_LABELS: [&str; 3] = [ + "regression-from-stable-to-nightly", + "regression-from-stable-to-beta", + "regression-from-stable-to-stable", +]; + +// auto-nominate for backport only patches fixing high/critical regressions +// For `P-{medium,low}` regressions, let the author decide +const PRIORITY_LABELS: [&str; 2] = ["P-high", "P-critical"]; + +#[derive(Default)] +pub(crate) struct BackportInput { + // Issue(s) fixed by this PR + ids: Vec, + // Handler configuration, it's a compound value of (required_issue_label -> add_labels) + labels: HashMap>, +} + +pub(super) async fn parse_input( + _ctx: &Context, + event: &IssuesEvent, + config: Option<&BackportConfig>, +) -> Result, String> { + let config = match config { + Some(config) => config, + None => return Ok(None), + }; + + // Only handle events when the PR is opened or the first comment is edited + let should_check = matches!(event.action, IssuesAction::Opened | IssuesAction::Edited); + if !should_check || !event.issue.is_pr() { + log::debug!( + "Skipping backport event because: IssuesAction = {:?} issue.is_pr() {}", + event.action, + event.issue.is_pr() + ); + return Ok(None); + } + let pr = &event.issue; + + let pr_labels: Vec<&str> = pr.labels.iter().map(|l| l.name.as_str()).collect(); + if contains_any(&pr_labels, &BACKPORT_LABELS) { + log::debug!("PR #{} already has a backport label", pr.number); + return Ok(None); + } + + // Retrieve backport config for this PR, based on its team label(s) + // If the PR has no team label matching any [backport.*.required-pr-labels] config, the backport labelling will be skipped + let mut input = BackportInput::default(); + let valid_configs: Vec<_> = config + .configs + .iter() + .clone() + .filter(|(_cfg_name, cfg)| { + let required_pr_labels: Vec<&str> = + cfg.required_pr_labels.iter().map(|l| l.as_str()).collect(); + if !contains_any(&pr_labels, &required_pr_labels) { + log::warn!( + "Skipping backport nomination: PR is missing one required label: {:?}", + pr_labels + ); + return false; + } + input + .labels + .insert(cfg.required_issue_label.clone(), cfg.add_labels.clone()); + true + }) + .collect(); + if valid_configs.is_empty() { + log::warn!( + "Skipping backport nomination: could not find a suitable backport config. Please ensure the triagebot.toml has a `[backport.*.required-pr-labels]` section matching the team label(s) for PR #{}.", + pr.number + ); + return Ok(None); + } + + // Check marker text in the opening comment of the PR to retrieve the issue(s) being fixed + for caps in CLOSES_ISSUE_REGEXP.captures_iter(&event.issue.body) { + let id = caps + .name("issue_num") + .expect("failed to get issue_num from") + .as_str(); + let id = match id.parse::() { + Ok(id) => id, + Err(err) => { + return Err(format!("Failed to parse issue id `{id}`, error: {err}")); + } + }; + input.ids.push(id); + } + + if input.ids.is_empty() || input.labels.is_empty() { + return Ok(None); + } + + log::debug!( + "Will handle event action {:?} in backport. Regression IDs found {:?}", + event.action, + input.ids + ); + + Ok(Some(input)) +} + +pub(super) async fn handle_input( + ctx: &Context, + _config: &BackportConfig, + event: &IssuesEvent, + input: BackportInput, +) -> anyhow::Result<()> { + let pr = &event.issue; + + // Retrieve the issue(s) this pull request closes + let issues = input + .ids + .iter() + .copied() + .map(|id| async move { event.repository.get_issue(&ctx.github, id).await }); + let issues = join_all(issues).await; + + // Add backport nomination label to the pull request + for issue in issues { + if let Err(ref err) = issue { + log::warn!("Failed to get issue: {:?}", err); + continue; + } + let issue = issue.context("failed to get issue")?; + let issue_labels: Vec<&str> = issue.labels.iter().map(|l| l.name.as_str()).collect(); + + // Check issue for a prerequisite priority label + // If none, skip this issue + if !contains_any(&issue_labels, &PRIORITY_LABELS) { + continue; + } + + // Get the labels to be added the PR according to the matching (required) regression label + // that is found in the configuration that this handler has received + // If no regression label is found, skip this issue + let add_labels = issue_labels.iter().find_map(|l| input.labels.get(*l)); + if add_labels.is_none() { + log::warn!( + "Skipping backport nomination: nothing to do for issue #{}. No config found for regression label ({:?})", + issue.number, + REGRESSION_LABELS + ); + continue; + } + + // Add backport nomination label(s) to PR + let mut new_labels = pr.labels().to_owned(); + new_labels.extend( + add_labels + .expect("failed to unwrap add_labels") + .iter() + .cloned() + .map(|name| Label { name }), + ); + log::debug!( + "PR#{} adding labels for backport {:?}", + pr.number, + add_labels + ); + let _ = pr + .add_labels(&ctx.github, new_labels) + .await + .context("failed to add backport labels to the PR"); + } + + Ok(()) +} + +fn contains_any(haystack: &[&str], needles: &[&str]) -> bool { + needles.iter().any(|needle| haystack.contains(needle)) +} + +#[cfg(test)] +mod tests { + use crate::handlers::backport::CLOSES_ISSUE_REGEXP; + + #[tokio::test] + async fn backport_match_comment() { + let test_strings = vec![ + ("close #10", vec![10]), + ("closes #10", vec![10]), + ("closed #10", vec![10]), + ("Closes #10", vec![10]), + ("close #10", vec![10]), + ("close rust-lang/rust#10", vec![10]), + ("cLose: rust-lang/rust#10", vec![10]), + ("fix #10", vec![10]), + ("fixes #10", vec![10]), + ("fixed #10", vec![10]), + ("resolve #10", vec![10]), + ("resolves #10", vec![10]), + ("resolved #10", vec![10]), + ( + "Fixes #20, Resolves #21, closed #22, LOL #23", + vec![20, 21, 22], + ), + ("Resolved #10", vec![10]), + ("Fixes #10", vec![10]), + ("Closes #10", vec![10]), + ]; + for test_case in test_strings { + let mut ids: Vec = vec![]; + let test_str = test_case.0; + let expected = test_case.1; + for caps in CLOSES_ISSUE_REGEXP.captures_iter(test_str) { + // eprintln!("caps {:?}", caps); + let id = &caps["issue_num"]; + ids.push(id.parse::().unwrap()); + } + // eprintln!("ids={:?}", ids); + assert_eq!(ids, expected); + } + } +}