notify_zulip.rs 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. use crate::{
  2. config::{NotifyZulipConfig, NotifyZulipLabelConfig},
  3. github::{Issue, IssuesAction, IssuesEvent, Label},
  4. handlers::Context,
  5. };
  6. pub(super) struct NotifyZulipInput {
  7. notification_type: NotificationType,
  8. /// Label that triggered this notification.
  9. ///
  10. /// For example, if an `I-prioritize` issue is closed,
  11. /// this field will be `I-prioritize`.
  12. label: Label,
  13. }
  14. pub(super) enum NotificationType {
  15. Labeled,
  16. Unlabeled,
  17. Closed,
  18. Reopened,
  19. }
  20. pub(super) fn parse_input(
  21. _ctx: &Context,
  22. event: &IssuesEvent,
  23. config: Option<&NotifyZulipConfig>,
  24. ) -> Result<Option<Vec<NotifyZulipInput>>, String> {
  25. let config = match config {
  26. Some(config) => config,
  27. None => return Ok(None),
  28. };
  29. match event.action {
  30. IssuesAction::Labeled | IssuesAction::Unlabeled => {
  31. let applied_label = event.label.as_ref().expect("label").clone();
  32. Ok(config
  33. .labels
  34. .get(&applied_label.name)
  35. .and_then(|label_config| {
  36. parse_label_change_input(event, applied_label, label_config)
  37. })
  38. .map(|input| vec![input]))
  39. }
  40. IssuesAction::Closed | IssuesAction::Reopened => {
  41. Ok(Some(parse_close_reopen_input(event, config)))
  42. }
  43. _ => Ok(None),
  44. }
  45. }
  46. fn parse_label_change_input(
  47. event: &IssuesEvent,
  48. label: Label,
  49. config: &NotifyZulipLabelConfig,
  50. ) -> Option<NotifyZulipInput> {
  51. if !has_all_required_labels(&event.issue, config) {
  52. // Issue misses a required label, ignore this event
  53. return None;
  54. }
  55. match event.action {
  56. IssuesAction::Labeled if config.message_on_add.is_some() => Some(NotifyZulipInput {
  57. notification_type: NotificationType::Labeled,
  58. label,
  59. }),
  60. IssuesAction::Unlabeled if config.message_on_remove.is_some() => Some(NotifyZulipInput {
  61. notification_type: NotificationType::Unlabeled,
  62. label,
  63. }),
  64. _ => None,
  65. }
  66. }
  67. fn parse_close_reopen_input(
  68. event: &IssuesEvent,
  69. global_config: &NotifyZulipConfig,
  70. ) -> Vec<NotifyZulipInput> {
  71. event
  72. .issue
  73. .labels
  74. .iter()
  75. .cloned()
  76. .filter_map(|label| {
  77. global_config
  78. .labels
  79. .get(&label.name)
  80. .map(|config| (label, config))
  81. })
  82. .flat_map(|(label, config)| {
  83. if !has_all_required_labels(&event.issue, config) {
  84. // Issue misses a required label, ignore this event
  85. return None;
  86. }
  87. match event.action {
  88. IssuesAction::Closed if config.message_on_close.is_some() => {
  89. Some(NotifyZulipInput {
  90. notification_type: NotificationType::Closed,
  91. label,
  92. })
  93. }
  94. IssuesAction::Reopened if config.message_on_reopen.is_some() => {
  95. Some(NotifyZulipInput {
  96. notification_type: NotificationType::Reopened,
  97. label,
  98. })
  99. }
  100. _ => None,
  101. }
  102. })
  103. .collect()
  104. }
  105. fn has_all_required_labels(issue: &Issue, config: &NotifyZulipLabelConfig) -> bool {
  106. for req_label in &config.required_labels {
  107. let pattern = match glob::Pattern::new(req_label) {
  108. Ok(pattern) => pattern,
  109. Err(err) => {
  110. log::error!("Invalid glob pattern: {}", err);
  111. continue;
  112. }
  113. };
  114. if !issue.labels().iter().any(|l| pattern.matches(&l.name)) {
  115. return false;
  116. }
  117. }
  118. true
  119. }
  120. pub(super) async fn handle_input<'a>(
  121. ctx: &Context,
  122. config: &NotifyZulipConfig,
  123. event: &IssuesEvent,
  124. inputs: Vec<NotifyZulipInput>,
  125. ) -> anyhow::Result<()> {
  126. for input in inputs {
  127. let config = &config.labels[&input.label.name];
  128. let mut topic = config.topic.clone();
  129. topic = topic.replace("{number}", &event.issue.number.to_string());
  130. topic = topic.replace("{title}", &event.issue.title);
  131. // Truncate to 60 chars (a Zulip limitation)
  132. let mut chars = topic.char_indices().skip(59);
  133. if let (Some((len, _)), Some(_)) = (chars.next(), chars.next()) {
  134. topic.truncate(len);
  135. topic.push('…');
  136. }
  137. let mut msg = match input.notification_type {
  138. NotificationType::Labeled => config.message_on_add.as_ref().unwrap().clone(),
  139. NotificationType::Unlabeled => config.message_on_remove.as_ref().unwrap().clone(),
  140. NotificationType::Closed => config.message_on_close.as_ref().unwrap().clone(),
  141. NotificationType::Reopened => config.message_on_reopen.as_ref().unwrap().clone(),
  142. };
  143. msg = msg.replace("{number}", &event.issue.number.to_string());
  144. msg = msg.replace("{title}", &event.issue.title);
  145. let zulip_req = crate::zulip::MessageApiRequest {
  146. recipient: crate::zulip::Recipient::Stream {
  147. id: config.zulip_stream,
  148. topic: &topic,
  149. },
  150. content: &msg,
  151. };
  152. zulip_req.send(&ctx.github.raw()).await?;
  153. }
  154. Ok(())
  155. }