Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions parser/src/command/relabel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ fn delta_empty() {
}

impl RelabelCommand {
/// Parse and validate command tokens
pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result<Option<Self>, Error<'a>> {
let mut toks = input.clone();

Expand Down
164 changes: 161 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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<String>,
// alias identifier -> labels
#[serde(flatten)]
pub(crate) aliases: HashMap<String, RelabelAliasConfig>,
}

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<String>,
/// Labels to be removed
pub(crate) rem_labels: Vec<String>,
}

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)]
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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>(&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#"
Expand Down Expand Up @@ -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>(&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));
}
}
3 changes: 0 additions & 3 deletions src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1338,9 +1338,6 @@ impl IssuesEvent {
}
}

#[derive(Debug, serde::Deserialize)]
struct PullRequestEventFields {}

#[derive(Debug, serde::Deserialize)]
pub struct WorkflowRunJob {
pub name: String,
Expand Down
23 changes: 16 additions & 7 deletions src/handlers/relabel.rs
Original file line number Diff line number Diff line change
@@ -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.
//!
Expand All @@ -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"))
Expand All @@ -44,14 +48,15 @@ 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);
}
}

// 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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -194,6 +201,7 @@ fn compute_label_deltas(deltas: &[LabelDelta]) -> (Vec<Label>, Vec<Label>) {
#[cfg(test)]
mod tests {
use parser::command::relabel::{Label, LabelDelta};
use std::collections::HashMap;

use super::{
CheckFilterResult, MatchPatternResult, TeamMembership, check_filter, compute_label_deltas,
Expand Down Expand Up @@ -232,6 +240,7 @@ mod tests {
($($member:ident { $($label:expr => $res:ident,)* })*) => {
let config = RelabelConfig {
allow_unauthenticated: vec!["T-*".into(), "I-*".into(), "!I-*nominated".into()],
aliases: HashMap::new()
};
$($(assert_eq!(
check_filter($label, &config, TeamMembership::$member),
Expand Down