notify_zulip.rs 5.3 KB

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