notification.rs 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. //! Purpose: Allow any user to ping a pre-selected group of people on GitHub via comments.
  2. //!
  3. //! The set of "teams" which can be pinged is intentionally restricted via configuration.
  4. //!
  5. //! Parsing is done in the `parser::command::ping` module.
  6. use crate::db::notifications;
  7. use crate::{
  8. github::{self, Event},
  9. handlers::Context,
  10. };
  11. use anyhow::Context as _;
  12. use regex::Regex;
  13. use std::collections::HashSet;
  14. use std::convert::TryFrom;
  15. lazy_static::lazy_static! {
  16. static ref PING_RE: Regex = Regex::new(r#"@([-\w\d/]+)"#,).unwrap();
  17. static ref ACKNOWLEDGE_RE: Regex = Regex::new(r#"acknowledge (https?://[^ ]+)"#,).unwrap();
  18. }
  19. pub async fn handle(ctx: &Context, event: &Event) -> anyhow::Result<()> {
  20. let body = match event.comment_body() {
  21. Some(v) => v,
  22. // Skip events that don't have comment bodies associated
  23. None => return Ok(()),
  24. };
  25. // Permit editing acknowledgement
  26. let acks = ACKNOWLEDGE_RE
  27. .captures_iter(body)
  28. .map(|c| c.get(1).unwrap().as_str().to_owned())
  29. .collect::<Vec<_>>();
  30. log::trace!("Captured acknowledgements: {:?}", acks);
  31. for url in acks {
  32. let user = match event {
  33. Event::Issue(e) => &e.issue.user,
  34. Event::IssueComment(e) => &e.comment.user,
  35. };
  36. let id = match user.id {
  37. Some(id) => id,
  38. // If the user was not in the team(s) then just don't record it.
  39. None => {
  40. log::trace!("Skipping {} because no id found", user.login);
  41. return Ok(());
  42. }
  43. };
  44. if let Err(e) = notifications::delete_ping(&ctx.db, id, &url).await {
  45. log::warn!(
  46. "failed to delete notification: url={}, user={:?}: {:?}",
  47. url,
  48. user,
  49. e
  50. );
  51. }
  52. }
  53. if let Event::Issue(e) = event {
  54. if e.action != github::IssuesAction::Opened {
  55. // skip events other than opening the issue to avoid retriggering commands in the
  56. // issue body
  57. return Ok(());
  58. }
  59. }
  60. if let Event::IssueComment(e) = event {
  61. if e.action != github::IssueCommentAction::Created {
  62. // skip events other than creating a comment to avoid
  63. // renotifying
  64. //
  65. // FIXME: implement smart tracking to allow rerunning only if
  66. // the notification is "new" (i.e. edit adds a ping)
  67. return Ok(());
  68. }
  69. }
  70. let short_description = match event {
  71. Event::Issue(e) => e.issue.title.clone(),
  72. Event::IssueComment(e) => format!("Comment on {}", e.issue.title),
  73. };
  74. let caps = PING_RE
  75. .captures_iter(body)
  76. .map(|c| c.get(1).unwrap().as_str().to_owned())
  77. .collect::<HashSet<_>>();
  78. log::trace!("Captured usernames in comment: {:?}", caps);
  79. for login in caps {
  80. let (users, team_name) = if login.contains('/') {
  81. // This is a team ping. For now, just add it to everyone's agenda on
  82. // that team, but also mark it as such (i.e., a team ping) for
  83. // potentially different prioritization and so forth.
  84. //
  85. // In order to properly handle this down the road, we will want to
  86. // distinguish between "everyone must pay attention" and "someone
  87. // needs to take a look."
  88. //
  89. // We may also want to be able to categorize into these buckets
  90. // *after* the ping occurs and is initially processed.
  91. let mut iter = login.split('/');
  92. let _rust_lang = iter.next().unwrap();
  93. let team = iter.next().unwrap();
  94. let team = match github::get_team(&ctx.github, team).await {
  95. Ok(Some(team)) => team,
  96. Ok(None) => {
  97. log::error!("team ping ({}) failed to resolve to a known team", login);
  98. continue;
  99. }
  100. Err(err) => {
  101. log::error!(
  102. "team ping ({}) failed to resolve to a known team: {:?}",
  103. login,
  104. err
  105. );
  106. continue;
  107. }
  108. };
  109. (
  110. team.members
  111. .into_iter()
  112. .map(|member| {
  113. let id = i64::try_from(member.github_id).with_context(|| {
  114. format!("user id {} out of bounds", member.github_id)
  115. })?;
  116. Ok(github::User {
  117. id: Some(id),
  118. login: member.github,
  119. })
  120. })
  121. .collect::<anyhow::Result<Vec<github::User>>>(),
  122. Some(team.name),
  123. )
  124. } else {
  125. let user = github::User { login, id: None };
  126. let id = user
  127. .get_id(&ctx.github)
  128. .await
  129. .with_context(|| format!("failed to get user {} ID", user.login))?;
  130. let id = match id {
  131. Some(id) => id,
  132. // If the user was not in the team(s) then just don't record it.
  133. None => {
  134. log::trace!("Skipping {} because no id found", user.login);
  135. continue;
  136. }
  137. };
  138. let id = i64::try_from(id).with_context(|| format!("user id {} out of bounds", id));
  139. (
  140. id.map(|id| {
  141. vec![github::User {
  142. login: user.login.clone(),
  143. id: Some(id),
  144. }]
  145. }),
  146. None,
  147. )
  148. };
  149. let users = match users {
  150. Ok(users) => users,
  151. Err(err) => {
  152. log::error!("getting users failed: {:?}", err);
  153. continue;
  154. }
  155. };
  156. for user in users {
  157. if let Err(err) = notifications::record_ping(
  158. &ctx.db,
  159. &notifications::Notification {
  160. user_id: user.id.unwrap(),
  161. username: user.login,
  162. origin_url: event.html_url().unwrap().to_owned(),
  163. origin_html: body.to_owned(),
  164. time: event.time(),
  165. short_description: Some(short_description.clone()),
  166. team_name: team_name.clone(),
  167. },
  168. )
  169. .await
  170. .context("failed to record ping")
  171. {
  172. log::error!("record ping: {:?}", err);
  173. }
  174. }
  175. }
  176. Ok(())
  177. }