relabel.rs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. //! Purpose: Allow any user to modify issue labels on GitHub via comments.
  2. //!
  3. //! Labels are checked against the labels in the project; the bot does not support creating new
  4. //! labels.
  5. //!
  6. //! Parsing is done in the `parser::command::relabel` module.
  7. //!
  8. //! If the command was successful, there will be no feedback beyond the label change to reduce
  9. //! notification noise.
  10. use crate::{
  11. config::RelabelConfig,
  12. github::{self, Event, GithubClient},
  13. handlers::{Context, Handler},
  14. interactions::ErrorComment,
  15. };
  16. use failure::Error;
  17. use futures::future::{BoxFuture, FutureExt};
  18. use parser::command::relabel::{LabelDelta, RelabelCommand};
  19. use parser::command::{Command, Input};
  20. pub(super) struct RelabelHandler;
  21. impl Handler for RelabelHandler {
  22. type Input = RelabelCommand;
  23. type Config = RelabelConfig;
  24. fn parse_input(&self, ctx: &Context, event: &Event) -> Result<Option<Self::Input>, String> {
  25. let body = if let Some(b) = event.comment_body() {
  26. b
  27. } else {
  28. // not interested in other events
  29. return Ok(None);
  30. };
  31. if let Event::Issue(e) = event {
  32. if e.action != github::IssuesAction::Opened {
  33. // skip events other than opening the issue to avoid retriggering commands in the
  34. // issue body
  35. return Ok(None);
  36. }
  37. }
  38. let mut input = Input::new(&body, &ctx.username);
  39. match input.parse_command() {
  40. Command::Relabel(Ok(command)) => Ok(Some(command)),
  41. Command::Relabel(Err(err)) => {
  42. return Err(format!(
  43. "Parsing label command in [comment]({}) failed: {}",
  44. event.html_url().expect("has html url"),
  45. err
  46. ));
  47. }
  48. _ => Ok(None),
  49. }
  50. }
  51. fn handle_input<'a>(
  52. &self,
  53. ctx: &'a Context,
  54. config: &'a RelabelConfig,
  55. event: &'a Event,
  56. input: RelabelCommand,
  57. ) -> BoxFuture<'a, Result<(), Error>> {
  58. handle_input(ctx, config, event, input).boxed()
  59. }
  60. }
  61. async fn handle_input(
  62. ctx: &Context,
  63. config: &RelabelConfig,
  64. event: &Event,
  65. input: RelabelCommand,
  66. ) -> Result<(), Error> {
  67. let mut issue_labels = event.issue().unwrap().labels().to_owned();
  68. let mut changed = false;
  69. for delta in &input.0 {
  70. let name = delta.label().as_str();
  71. let err = match check_filter(name, config, is_member(&event.user(), &ctx.github).await) {
  72. Ok(CheckFilterResult::Allow) => None,
  73. Ok(CheckFilterResult::Deny) => Some(format!("Label {} can only be set by Rust team members", name)),
  74. Ok(CheckFilterResult::DenyUnknown) => Some(format!(
  75. "Label {} can only be set by Rust team members;\
  76. we were unable to check if you are a team member.",
  77. name
  78. )),
  79. Err(err) => Some(err),
  80. };
  81. if let Some(msg) = err {
  82. let cmnt = ErrorComment::new(&event.issue().unwrap(), msg);
  83. cmnt.post(&ctx.github).await?;
  84. return Ok(());
  85. }
  86. match delta {
  87. LabelDelta::Add(label) => {
  88. if !issue_labels.iter().any(|l| l.name == label.as_str()) {
  89. changed = true;
  90. issue_labels.push(github::Label {
  91. name: label.to_string(),
  92. });
  93. }
  94. }
  95. LabelDelta::Remove(label) => {
  96. if let Some(pos) = issue_labels.iter().position(|l| l.name == label.as_str()) {
  97. changed = true;
  98. issue_labels.remove(pos);
  99. }
  100. }
  101. }
  102. }
  103. if changed {
  104. event
  105. .issue()
  106. .unwrap()
  107. .set_labels(&ctx.github, issue_labels)
  108. .await?;
  109. }
  110. Ok(())
  111. }
  112. #[derive(Debug, PartialEq, Eq)]
  113. enum TeamMembership {
  114. Member,
  115. Outsider,
  116. Unknown,
  117. }
  118. async fn is_member(user: &github::User, client: &GithubClient) -> TeamMembership {
  119. match user.is_team_member(client).await {
  120. Ok(true) => TeamMembership::Member,
  121. Ok(false) => TeamMembership::Outsider,
  122. Err(err) => {
  123. eprintln!("failed to check team membership: {:?}", err);
  124. TeamMembership::Unknown
  125. }
  126. }
  127. }
  128. #[cfg_attr(test, derive(Debug, PartialEq, Eq))]
  129. enum CheckFilterResult {
  130. Allow,
  131. Deny,
  132. DenyUnknown,
  133. }
  134. fn check_filter(
  135. label: &str,
  136. config: &RelabelConfig,
  137. is_member: TeamMembership,
  138. ) -> Result<CheckFilterResult, String> {
  139. if is_member == TeamMembership::Member {
  140. return Ok(CheckFilterResult::Allow);
  141. }
  142. let mut matched = false;
  143. for pattern in &config.allow_unauthenticated {
  144. match match_pattern(pattern, label) {
  145. Ok(MatchPatternResult::Allow) => matched = true,
  146. Ok(MatchPatternResult::Deny) => {
  147. // An explicit deny overrides any allowed pattern
  148. matched = false;
  149. break;
  150. }
  151. Ok(MatchPatternResult::NoMatch) => {}
  152. Err(err) => {
  153. eprintln!("failed to match pattern {}: {}", pattern, err);
  154. return Err(format!("failed to match pattern {}", pattern));
  155. }
  156. }
  157. }
  158. if matched {
  159. return Ok(CheckFilterResult::Allow);
  160. } else if is_member == TeamMembership::Outsider {
  161. return Ok(CheckFilterResult::Deny);
  162. } else {
  163. return Ok(CheckFilterResult::DenyUnknown);
  164. }
  165. }
  166. #[cfg_attr(test, derive(Debug, PartialEq, Eq))]
  167. enum MatchPatternResult {
  168. Allow,
  169. Deny,
  170. NoMatch,
  171. }
  172. fn match_pattern(pattern: &str, label: &str) -> Result<MatchPatternResult, Error> {
  173. let (pattern, inverse) = if pattern.starts_with('!') {
  174. (&pattern[1..], true)
  175. } else {
  176. (pattern, false)
  177. };
  178. let glob = glob::Pattern::new(pattern)?;
  179. Ok(match (glob.matches(label), inverse) {
  180. (true, false) => MatchPatternResult::Allow,
  181. (true, true) => MatchPatternResult::Deny,
  182. (false, _) => MatchPatternResult::NoMatch,
  183. })
  184. }
  185. #[cfg(test)]
  186. mod tests {
  187. use super::{TeamMembership, match_pattern, MatchPatternResult, check_filter, CheckFilterResult};
  188. use crate::config::RelabelConfig;
  189. use failure::Error;
  190. #[test]
  191. fn test_match_pattern() -> Result<(), Error> {
  192. assert_eq!(match_pattern("I-*", "I-nominated")?, MatchPatternResult::Allow);
  193. assert_eq!(match_pattern("!I-no*", "I-nominated")?, MatchPatternResult::Deny);
  194. assert_eq!(match_pattern("I-*", "T-infra")?, MatchPatternResult::NoMatch);
  195. assert_eq!(match_pattern("!I-no*", "T-infra")?, MatchPatternResult::NoMatch);
  196. Ok(())
  197. }
  198. #[test]
  199. fn test_check_filter() -> Result<(), Error> {
  200. macro_rules! t {
  201. ($($member:ident { $($label:expr => $res:ident,)* })*) => {
  202. let config = RelabelConfig {
  203. allow_unauthenticated: vec!["T-*".into(), "I-*".into(), "!I-nominated".into()],
  204. };
  205. $($(assert_eq!(
  206. check_filter($label, &config, TeamMembership::$member).map_err(|e| failure::err_msg(e))?,
  207. CheckFilterResult::$res
  208. );)*)*
  209. }
  210. }
  211. t! {
  212. Member {
  213. "T-release" => Allow,
  214. "I-slow" => Allow,
  215. "I-nominated" => Allow,
  216. "A-spurious" => Allow,
  217. }
  218. Outsider {
  219. "T-release" => Allow,
  220. "I-slow" => Allow,
  221. "I-nominated" => Deny,
  222. "A-spurious" => Deny,
  223. }
  224. Unknown {
  225. "T-release" => Allow,
  226. "I-slow" => Allow,
  227. "I-nominated" => DenyUnknown,
  228. "A-spurious" => DenyUnknown,
  229. }
  230. }
  231. Ok(())
  232. }
  233. }