notification.rs 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  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 std::collections::HashSet;
  13. use std::convert::{TryFrom, TryInto};
  14. pub async fn handle(ctx: &Context, event: &Event) -> anyhow::Result<()> {
  15. let body = match event.comment_body() {
  16. Some(v) => v,
  17. // Skip events that don't have comment bodies associated
  18. None => return Ok(()),
  19. };
  20. if let Event::Issue(e) = event {
  21. if !matches!(
  22. e.action,
  23. github::IssuesAction::Opened | github::IssuesAction::Edited
  24. ) {
  25. // no change in issue's body for these events, so skip
  26. return Ok(());
  27. }
  28. }
  29. let short_description = match event {
  30. Event::Issue(e) => e.issue.title.clone(),
  31. Event::IssueComment(e) => format!("Comment on {}", e.issue.title),
  32. };
  33. let mut caps = parser::get_mentions(body)
  34. .into_iter()
  35. .collect::<HashSet<_>>();
  36. // FIXME: Remove this hardcoding. Ideally we need organization-wide
  37. // configuration, but it's unclear where to put it.
  38. if event.issue().unwrap().repository().organization == "serde-rs" {
  39. // Only add dtolnay on new issues/PRs, not on comments to old PRs and
  40. // issues.
  41. if let Event::Issue(e) = event {
  42. if e.action == github::IssuesAction::Opened {
  43. caps.insert("dtolnay");
  44. }
  45. }
  46. }
  47. // Get the list of users already notified by a previous version of this
  48. // comment, so they don't get notified again
  49. let mut users_notified = HashSet::new();
  50. if let Some(from) = event.comment_from() {
  51. for login in parser::get_mentions(from).into_iter() {
  52. if let Some((Ok(users), _)) = id_from_user(ctx, login).await? {
  53. users_notified.extend(users.into_iter().map(|user| user.id.unwrap()));
  54. }
  55. }
  56. };
  57. // We've implicitly notified the user that is submitting the notification:
  58. // they already know that they left this comment.
  59. //
  60. // If the user intended to ping themselves, they can add the GitHub comment
  61. // via the Zulip interface.
  62. match event.user().get_id(&ctx.github).await {
  63. Ok(Some(id)) => {
  64. users_notified.insert(id.try_into().unwrap());
  65. }
  66. Ok(None) => {}
  67. Err(err) => {
  68. log::error!("Failed to query ID for {:?}: {:?}", event.user(), err);
  69. }
  70. }
  71. log::trace!("Captured usernames in comment: {:?}", caps);
  72. for login in caps {
  73. let (users, team_name) = match id_from_user(ctx, login).await? {
  74. Some((users, team_name)) => (users, team_name),
  75. None => continue,
  76. };
  77. let users = match users {
  78. Ok(users) => users,
  79. Err(err) => {
  80. log::error!("getting users failed: {:?}", err);
  81. continue;
  82. }
  83. };
  84. for user in users {
  85. if !users_notified.insert(user.id.unwrap()) {
  86. // Skip users already associated with this event.
  87. continue;
  88. }
  89. if let Err(err) = notifications::record_username(&ctx.db, user.id.unwrap(), user.login)
  90. .await
  91. .context("failed to record username")
  92. {
  93. log::error!("record username: {:?}", err);
  94. }
  95. if let Err(err) = notifications::record_ping(
  96. &ctx.db,
  97. &notifications::Notification {
  98. user_id: user.id.unwrap(),
  99. origin_url: event.html_url().unwrap().to_owned(),
  100. origin_html: body.to_owned(),
  101. time: event.time(),
  102. short_description: Some(short_description.clone()),
  103. team_name: team_name.clone(),
  104. },
  105. )
  106. .await
  107. .context("failed to record ping")
  108. {
  109. log::error!("record ping: {:?}", err);
  110. }
  111. }
  112. }
  113. Ok(())
  114. }
  115. async fn id_from_user(
  116. ctx: &Context,
  117. login: &str,
  118. ) -> anyhow::Result<Option<(anyhow::Result<Vec<github::User>>, Option<String>)>> {
  119. if login.contains('/') {
  120. // This is a team ping. For now, just add it to everyone's agenda on
  121. // that team, but also mark it as such (i.e., a team ping) for
  122. // potentially different prioritization and so forth.
  123. //
  124. // In order to properly handle this down the road, we will want to
  125. // distinguish between "everyone must pay attention" and "someone
  126. // needs to take a look."
  127. //
  128. // We may also want to be able to categorize into these buckets
  129. // *after* the ping occurs and is initially processed.
  130. let mut iter = login.split('/');
  131. let _rust_lang = iter.next().unwrap();
  132. let team = iter.next().unwrap();
  133. let team = match github::get_team(&ctx.github, team).await {
  134. Ok(Some(team)) => team,
  135. Ok(None) => {
  136. log::error!("team ping ({}) failed to resolve to a known team", login);
  137. return Ok(None);
  138. }
  139. Err(err) => {
  140. log::error!(
  141. "team ping ({}) failed to resolve to a known team: {:?}",
  142. login,
  143. err
  144. );
  145. return Ok(None);
  146. }
  147. };
  148. Ok(Some((
  149. team.members
  150. .into_iter()
  151. .map(|member| {
  152. let id = i64::try_from(member.github_id)
  153. .with_context(|| format!("user id {} out of bounds", member.github_id))?;
  154. Ok(github::User {
  155. id: Some(id),
  156. login: member.github,
  157. })
  158. })
  159. .collect::<anyhow::Result<Vec<github::User>>>(),
  160. Some(team.name),
  161. )))
  162. } else {
  163. let user = github::User {
  164. login: login.to_owned(),
  165. id: None,
  166. };
  167. let id = user
  168. .get_id(&ctx.github)
  169. .await
  170. .with_context(|| format!("failed to get user {} ID", user.login))?;
  171. let id = match id {
  172. Some(id) => id,
  173. // If the user was not in the team(s) then just don't record it.
  174. None => {
  175. log::trace!("Skipping {} because no id found", user.login);
  176. return Ok(None);
  177. }
  178. };
  179. let id = i64::try_from(id).with_context(|| format!("user id {} out of bounds", id));
  180. Ok(Some((
  181. id.map(|id| {
  182. vec![github::User {
  183. login: user.login.clone(),
  184. id: Some(id),
  185. }]
  186. }),
  187. None,
  188. )))
  189. }
  190. }