notification.rs 7.0 KB

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