From 154a7e764c3d9b9c405006053e67e4f6b3d75bfe Mon Sep 17 00:00:00 2001 From: apiraino Date: Tue, 21 Oct 2025 12:43:17 +0200 Subject: [PATCH] Add alias for compound labels Configure relabel command aliases from the `triagebot.toml`. When a valid alias is parsed, it will be replaced with the labels configured. Example configuration: ``` [relabel.cmd-alias] add-labels = ["Foo", "Bar"] rem-labels = ["Baz"] ``` The command `@rustbot label cmd-alias` translates to: ``` @rustbot label +Foo +Bar -Baz ``` The command `@rustbot label -cmd-alias` translates to: ``` @rustbot label +Baz -Foo -Bar ``` Note: self-canceling labels will be omitted. The command `@rustbot label cmd-alias +Baz` translates to: ``` @rustbot label +Foo +Bar ``` --- parser/src/command/relabel.rs | 1 + src/config.rs | 164 +++++++++++++++++++++++++++++++++- src/github.rs | 3 - src/handlers/relabel.rs | 23 +++-- 4 files changed, 178 insertions(+), 13 deletions(-) diff --git a/parser/src/command/relabel.rs b/parser/src/command/relabel.rs index 7154c7926..7e7abae14 100644 --- a/parser/src/command/relabel.rs +++ b/parser/src/command/relabel.rs @@ -102,6 +102,7 @@ fn delta_empty() { } impl RelabelCommand { + /// Parse and validate command tokens pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result, Error<'a>> { let mut toks = input.clone(); diff --git a/src/config.rs b/src/config.rs index cb43f769b..ccc904aa0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,6 @@ use crate::changelogs::ChangelogFormat; use crate::github::{GithubClient, Repository}; +use parser::command::relabel::{Label, LabelDelta, RelabelCommand}; use std::collections::{HashMap, HashSet}; use std::fmt; use std::sync::{Arc, LazyLock, RwLock}; @@ -250,10 +251,64 @@ pub(crate) struct MentionsEntryConfig { #[derive(PartialEq, Eq, Debug, serde::Deserialize)] #[serde(rename_all = "kebab-case")] -#[serde(deny_unknown_fields)] pub(crate) struct RelabelConfig { #[serde(default)] pub(crate) allow_unauthenticated: Vec, + // alias identifier -> labels + #[serde(flatten)] + pub(crate) aliases: HashMap, +} + +impl RelabelConfig { + pub(crate) fn retrieve_command_from_alias(&self, input: RelabelCommand) -> RelabelCommand { + let mut deltas = vec![]; + if !self.aliases.is_empty() { + // parse all tokens: if one matches an alias, extract the labels + // else, it will assumed to be a valid label + for tk in input.0.into_iter() { + let name = tk.label() as &str; + if let Some(alias) = self.aliases.get(name) { + let cmd = alias.to_command(matches!(tk, LabelDelta::Remove(_))); + deltas.extend(cmd.0); + } else { + deltas.push(tk); + } + } + } + RelabelCommand(deltas) + } +} + +#[derive(Default, PartialEq, Eq, Debug, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +#[serde(deny_unknown_fields)] +pub(crate) struct RelabelAliasConfig { + /// Labels to be added + pub(crate) add_labels: Vec, + /// Labels to be removed + pub(crate) rem_labels: Vec, +} + +impl RelabelAliasConfig { + /// Translate a RelabelAliasConfig into a RelabelCommand for GitHub consumption + fn to_command(&self, inverted: bool) -> RelabelCommand { + let mut deltas = Vec::new(); + let mut add_labels = &self.add_labels; + let mut rem_labels = &self.rem_labels; + + // if the polarity of the alias is inverted, swap labels before parsing the command + if inverted { + std::mem::swap(&mut add_labels, &mut rem_labels); + } + + for l in add_labels.iter() { + deltas.push(LabelDelta::Add(Label(l.into()))); + } + for l in rem_labels.iter() { + deltas.push(LabelDelta::Remove(Label(l.into()))); + } + RelabelCommand(deltas) + } } #[derive(PartialEq, Eq, Debug, serde::Deserialize)] @@ -761,11 +816,11 @@ mod tests { [mentions."src/"] cc = ["@someone"] - + [mentions."target/"] message = "This is a message." cc = ["@someone"] - + [mentions."#[rustc_attr]"] type = "content" message = "This is a message." @@ -835,6 +890,7 @@ mod tests { Config { relabel: Some(RelabelConfig { allow_unauthenticated: vec!["C-*".into()], + aliases: HashMap::new() }), assign: Some(AssignConfig { warn_non_default_branch: WarnNonDefaultBranchConfig::Simple(false), @@ -1033,6 +1089,76 @@ mod tests { ); } + #[test] + fn relabel_alias_config() { + let config = r#" + [relabel.to-stable] + add-labels = ["regression-from-stable-to-stable"] + rem-labels = ["regression-from-stable-to-beta", "regression-from-stable-to-nightly"] + "#; + let config = toml::from_str::(&config).unwrap(); + + let mut relabel_configs = HashMap::new(); + relabel_configs.insert( + "to-stable".into(), + RelabelAliasConfig { + add_labels: vec!["regression-from-stable-to-stable".to_string()], + rem_labels: vec![ + "regression-from-stable-to-beta".to_string(), + "regression-from-stable-to-nightly".to_string(), + ], + }, + ); + + let expected_cfg = RelabelConfig { + allow_unauthenticated: vec![], + aliases: relabel_configs, + }; + + assert_eq!(config.relabel, Some(expected_cfg)); + } + + #[test] + fn relabel_alias() { + // [relabel.my-alias] + // add-labels = ["Alpha"] + // rem-labels = ["Bravo", "Charlie"] + let relabel_cfg = RelabelConfig { + allow_unauthenticated: vec![], + aliases: HashMap::from([( + "my-alias".to_string(), + RelabelAliasConfig { + add_labels: vec!["Alpha".to_string()], + rem_labels: vec!["Bravo".to_string(), "Charlie".to_string()], + }, + )]), + }; + + // @triagebot label my-alias + let deltas = vec![LabelDelta::Add(Label("my-alias".into()))]; + let new_input = relabel_cfg.retrieve_command_from_alias(RelabelCommand(deltas)); + assert_eq!( + new_input, + RelabelCommand(vec![ + LabelDelta::Add(Label("Alpha".into())), + LabelDelta::Remove(Label("Bravo".into())), + LabelDelta::Remove(Label("Charlie".into())), + ]) + ); + + // @triagebot label -my-alias + let deltas = vec![LabelDelta::Remove(Label("my-alias".into()))]; + let new_input = relabel_cfg.retrieve_command_from_alias(RelabelCommand(deltas)); + assert_eq!( + new_input, + RelabelCommand(vec![ + LabelDelta::Add(Label("Bravo".into())), + LabelDelta::Add(Label("Charlie".into())), + LabelDelta::Remove(Label("Alpha".into())), + ]) + ); + } + #[test] fn issue_links_uncanonicalized() { let config = r#" @@ -1093,4 +1219,36 @@ Multi text body with ${mcp_issue} and ${mcp_title} }) ); } + + #[test] + fn relabel_new_config() { + let config = r#" + [relabel] + allow-unauthenticated = ["ABCD-*"] + + [relabel.to-stable] + add-labels = ["regression-from-stable-to-stable"] + rem-labels = ["regression-from-stable-to-beta", "regression-from-stable-to-nightly"] + "#; + let config = toml::from_str::(&config).unwrap(); + + let mut relabel_configs = HashMap::new(); + relabel_configs.insert( + "to-stable".into(), + RelabelAliasConfig { + add_labels: vec!["regression-from-stable-to-stable".to_string()], + rem_labels: vec![ + "regression-from-stable-to-beta".to_string(), + "regression-from-stable-to-nightly".to_string(), + ], + }, + ); + + let expected_cfg = RelabelConfig { + allow_unauthenticated: vec!["ABCD-*".to_string()], + aliases: relabel_configs, + }; + + assert_eq!(config.relabel, Some(expected_cfg)); + } } diff --git a/src/github.rs b/src/github.rs index 5c4c4e3a6..4d63f4c27 100644 --- a/src/github.rs +++ b/src/github.rs @@ -1338,9 +1338,6 @@ impl IssuesEvent { } } -#[derive(Debug, serde::Deserialize)] -struct PullRequestEventFields {} - #[derive(Debug, serde::Deserialize)] pub struct WorkflowRunJob { pub name: String, diff --git a/src/handlers/relabel.rs b/src/handlers/relabel.rs index d76725bc7..d0478650d 100644 --- a/src/handlers/relabel.rs +++ b/src/handlers/relabel.rs @@ -1,7 +1,7 @@ -//! Purpose: Allow any user to modify issue labels on GitHub via comments. +//! Purpose: Allow any user to modify labels on GitHub issues and pull requests via comments. //! -//! Labels are checked against the labels in the project; the bot does not support creating new -//! labels. +//! Labels are checked against the existing set in the git repository; the bot does not support +//! creating new labels. //! //! Parsing is done in the `parser::command::relabel` module. //! @@ -27,13 +27,17 @@ pub(super) async fn handle_command( input: RelabelCommand, ) -> anyhow::Result<()> { let Some(issue) = event.issue() else { - return user_error!("Can only add and remove labels on an issue"); + return user_error!("Can only add and remove labels on issues and pull requests"); }; + // If the input matches a valid alias, read the [relabel] config. + // if any alias matches, extract the alias config (RelabelAliasConfig) and build a new RelabelCommand. + let new_input = config.retrieve_command_from_alias(input); + // Check label authorization for the current user - for delta in &input.0 { + for delta in &new_input.0 { let name = delta.label() as &str; - let err = match check_filter(name, config, is_member(event.user(), &ctx.team).await) { + let err = match check_filter(name, config, is_member(&event.user(), &ctx.team).await) { Ok(CheckFilterResult::Allow) => None, Ok(CheckFilterResult::Deny) => { Some(format!("Label {name} can only be set by Rust team members")) @@ -44,6 +48,7 @@ pub(super) async fn handle_command( )), Err(err) => Some(err), }; + if let Some(err) = err { // bail-out and inform the user why return user_error!(err); @@ -51,7 +56,7 @@ pub(super) async fn handle_command( } // Compute the labels to add and remove - let (to_add, to_remove) = compute_label_deltas(&input.0); + let (to_add, to_remove) = compute_label_deltas(&new_input.0); // Add labels if let Err(e) = issue.add_labels(&ctx.github, to_add.clone()).await { @@ -103,6 +108,8 @@ enum CheckFilterResult { DenyUnknown, } +/// Check if the team member is allowed to apply labels +/// configured in `allow_unauthenticated` fn check_filter( label: &str, config: &RelabelConfig, @@ -194,6 +201,7 @@ fn compute_label_deltas(deltas: &[LabelDelta]) -> (Vec