diff --git a/src/config.rs b/src/config.rs index 523092a58..310cc2afe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -265,7 +265,51 @@ pub(crate) struct AutolabelLabelConfig { #[derive(PartialEq, Eq, Debug, serde::Deserialize)] pub(crate) struct NotifyZulipConfig { #[serde(flatten)] - pub(crate) labels: HashMap, + pub(crate) labels: HashMap, +} + +#[derive(PartialEq, Eq, Debug)] +pub(crate) struct NotifyZulipTablesConfig { + pub(crate) subtables: HashMap, +} + +impl<'de> serde::Deserialize<'de> for NotifyZulipTablesConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + use toml::Value; + + // Deserialize into a toml::value::Table for dynamic inspection + let table = toml::Value::deserialize(deserializer)? + .as_table() + .cloned() + .ok_or_else(|| Error::custom("expected a TOML table"))?; + + let mut subtables = HashMap::new(); + let mut direct_fields = toml::value::Table::new(); + + for (k, v) in table { + if let Some(subtable) = v.as_table() { + // This is a subtable; deserialize as NotifyZulipLabelConfig + let sub = NotifyZulipLabelConfig::deserialize(Value::Table(subtable.clone())) + .map_err(Error::custom)?; + subtables.insert(k, sub); + } else { + // This is a direct field; collect for the "" entry + direct_fields.insert(k, v); + } + } + + if !direct_fields.is_empty() { + let direct = NotifyZulipLabelConfig::deserialize(Value::Table(direct_fields)) + .map_err(Error::custom)?; + subtables.insert("".to_string(), direct); + } + + Ok(NotifyZulipTablesConfig { subtables }) + } } #[derive(PartialEq, Eq, Debug, serde::Deserialize)] diff --git a/src/handlers/notify_zulip.rs b/src/handlers/notify_zulip.rs index 1dcfbf182..72f39ae0c 100644 --- a/src/handlers/notify_zulip.rs +++ b/src/handlers/notify_zulip.rs @@ -1,5 +1,5 @@ use crate::{ - config::{NotifyZulipConfig, NotifyZulipLabelConfig}, + config::{NotifyZulipConfig, NotifyZulipLabelConfig, NotifyZulipTablesConfig}, github::{Issue, IssuesAction, IssuesEvent, Label}, handlers::Context, }; @@ -12,6 +12,9 @@ pub(super) struct NotifyZulipInput { /// For example, if an `I-prioritize` issue is closed, /// this field will be `I-prioritize`. label: Label, + /// List of strings for tables such as [notify-zulip."beta-nominated"] + /// and/or [notify-zulip."beta-nominated".compiler] + include_config_names: Vec, } pub(super) enum NotificationType { @@ -52,26 +55,40 @@ pub(super) async fn parse_input( fn parse_label_change_input( event: &IssuesEvent, label: Label, - config: &NotifyZulipLabelConfig, + config: &NotifyZulipTablesConfig, ) -> Option { - if !has_all_required_labels(&event.issue, config) { - // Issue misses a required label, ignore this event + let mut include_config_names: Vec = vec![]; + + for (name, label_config) in &config.subtables { + if has_all_required_labels(&event.issue, &label_config) { + match event.action { + IssuesAction::Labeled { .. } if !label_config.messages_on_add.is_empty() => { + include_config_names.push(name.to_string()); + } + IssuesAction::Unlabeled { .. } if !label_config.messages_on_remove.is_empty() => { + include_config_names.push(name.to_string()); + } + _ => (), + } + } + } + + if include_config_names.is_empty() { + // It seems that there is no match between this event and any notify-zulip config, ignore this event return None; } match event.action { - IssuesAction::Labeled { .. } if !config.messages_on_add.is_empty() => { - Some(NotifyZulipInput { - notification_type: NotificationType::Labeled, - label, - }) - } - IssuesAction::Unlabeled { .. } if !config.messages_on_remove.is_empty() => { - Some(NotifyZulipInput { - notification_type: NotificationType::Unlabeled, - label, - }) - } + IssuesAction::Labeled { .. } => Some(NotifyZulipInput { + notification_type: NotificationType::Labeled, + label, + include_config_names, + }), + IssuesAction::Unlabeled { .. } => Some(NotifyZulipInput { + notification_type: NotificationType::Unlabeled, + label, + include_config_names, + }), _ => None, } } @@ -92,24 +109,38 @@ fn parse_close_reopen_input( .map(|config| (label, config)) }) .flat_map(|(label, config)| { - if !has_all_required_labels(&event.issue, config) { - // Issue misses a required label, ignore this event + let mut include_config_names: Vec = vec![]; + + for (name, label_config) in &config.subtables { + if has_all_required_labels(&event.issue, &label_config) { + match event.action { + IssuesAction::Closed if !label_config.messages_on_close.is_empty() => { + include_config_names.push(name.to_string()); + } + IssuesAction::Reopened if !label_config.messages_on_reopen.is_empty() => { + include_config_names.push(name.to_string()); + } + _ => (), + } + } + } + + if include_config_names.is_empty() { + // It seems that there is no match between this event and any notify-zulip config, ignore this event return None; } match event.action { - IssuesAction::Closed if !config.messages_on_close.is_empty() => { - Some(NotifyZulipInput { - notification_type: NotificationType::Closed, - label, - }) - } - IssuesAction::Reopened if !config.messages_on_reopen.is_empty() => { - Some(NotifyZulipInput { - notification_type: NotificationType::Reopened, - label, - }) - } + IssuesAction::Closed => Some(NotifyZulipInput { + notification_type: NotificationType::Closed, + label, + include_config_names, + }), + IssuesAction::Reopened => Some(NotifyZulipInput { + notification_type: NotificationType::Reopened, + label, + include_config_names, + }), _ => None, } }) @@ -140,41 +171,51 @@ pub(super) async fn handle_input<'a>( inputs: Vec, ) -> anyhow::Result<()> { for input in inputs { - let config = &config.labels[&input.label.name]; - - let topic = &config.topic; - let topic = topic.replace("{number}", &event.issue.number.to_string()); - let mut topic = topic.replace("{title}", &event.issue.title); - // Truncate to 60 chars (a Zulip limitation) - let mut chars = topic.char_indices().skip(59); - if let (Some((len, _)), Some(_)) = (chars.next(), chars.next()) { - topic.truncate(len); - topic.push('…'); + let tables_config = &config.labels[&input.label.name]; + + // Get valid label configs + let mut label_configs: Vec<&NotifyZulipLabelConfig> = vec![]; + for name in input.include_config_names { + label_configs.push(&tables_config.subtables[&name]); } - let msgs = match input.notification_type { - NotificationType::Labeled => &config.messages_on_add, - NotificationType::Unlabeled => &config.messages_on_remove, - NotificationType::Closed => &config.messages_on_close, - NotificationType::Reopened => &config.messages_on_reopen, - }; + for label_config in label_configs { + let config = label_config; - let recipient = crate::zulip::Recipient::Stream { - id: config.zulip_stream, - topic: &topic, - }; + let topic = &config.topic; + let topic = topic.replace("{number}", &event.issue.number.to_string()); + let mut topic = topic.replace("{title}", &event.issue.title); + // Truncate to 60 chars (a Zulip limitation) + let mut chars = topic.char_indices().skip(59); + if let (Some((len, _)), Some(_)) = (chars.next(), chars.next()) { + topic.truncate(len); + topic.push('…'); + } + + let msgs = match input.notification_type { + NotificationType::Labeled => &config.messages_on_add, + NotificationType::Unlabeled => &config.messages_on_remove, + NotificationType::Closed => &config.messages_on_close, + NotificationType::Reopened => &config.messages_on_reopen, + }; - for msg in msgs { - let msg = msg.replace("{number}", &event.issue.number.to_string()); - let msg = msg.replace("{title}", &event.issue.title); - let msg = replace_team_to_be_nominated(&event.issue.labels, msg); + let recipient = crate::zulip::Recipient::Stream { + id: config.zulip_stream, + topic: &topic, + }; - crate::zulip::MessageApiRequest { - recipient, - content: &msg, + for msg in msgs { + let msg = msg.replace("{number}", &event.issue.number.to_string()); + let msg = msg.replace("{title}", &event.issue.title); + let msg = replace_team_to_be_nominated(&event.issue.labels, msg); + + crate::zulip::MessageApiRequest { + recipient, + content: &msg, + } + .send(&ctx.github.raw()) + .await?; } - .send(&ctx.github.raw()) - .await?; } }