Skip to content

feat(notify-zulip): support notify-zulip.<label> getting mapped to multiple actions #1853

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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
46 changes: 45 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,51 @@ pub(crate) struct AutolabelLabelConfig {
#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
pub(crate) struct NotifyZulipConfig {
#[serde(flatten)]
pub(crate) labels: HashMap<String, NotifyZulipLabelConfig>,
pub(crate) labels: HashMap<String, NotifyZulipTablesConfig>,
}

#[derive(PartialEq, Eq, Debug)]
pub(crate) struct NotifyZulipTablesConfig {
pub(crate) subtables: HashMap<String, NotifyZulipLabelConfig>,
}

impl<'de> serde::Deserialize<'de> for NotifyZulipTablesConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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)]
Expand Down
159 changes: 100 additions & 59 deletions src/handlers/notify_zulip.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::{
config::{NotifyZulipConfig, NotifyZulipLabelConfig},
config::{NotifyZulipConfig, NotifyZulipLabelConfig, NotifyZulipTablesConfig},
github::{Issue, IssuesAction, IssuesEvent, Label},
handlers::Context,
};
Expand All @@ -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<String>,
}

pub(super) enum NotificationType {
Expand Down Expand Up @@ -52,26 +55,40 @@ pub(super) async fn parse_input(
fn parse_label_change_input(
event: &IssuesEvent,
label: Label,
config: &NotifyZulipLabelConfig,
config: &NotifyZulipTablesConfig,
) -> Option<NotifyZulipInput> {
if !has_all_required_labels(&event.issue, config) {
// Issue misses a required label, ignore this event
let mut include_config_names: Vec<String> = 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,
}
}
Expand All @@ -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<String> = 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,
}
})
Expand Down Expand Up @@ -140,41 +171,51 @@ pub(super) async fn handle_input<'a>(
inputs: Vec<NotifyZulipInput>,
) -> 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?;
}
}

Expand Down