relabel.rs 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  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,
  14. interactions::ErrorComment,
  15. };
  16. use parser::command::relabel::{LabelDelta, RelabelCommand};
  17. pub(super) async fn handle_command(
  18. ctx: &Context,
  19. config: &RelabelConfig,
  20. event: &Event,
  21. input: RelabelCommand,
  22. ) -> anyhow::Result<()> {
  23. let mut results = vec![];
  24. let mut to_add = vec![];
  25. for delta in &input.0 {
  26. let name = delta.label().as_str();
  27. let err = match check_filter(name, config, is_member(&event.user(), &ctx.github).await) {
  28. Ok(CheckFilterResult::Allow) => None,
  29. Ok(CheckFilterResult::Deny) => Some(format!(
  30. "Label {} can only be set by Rust team members",
  31. name
  32. )),
  33. Ok(CheckFilterResult::DenyUnknown) => Some(format!(
  34. "Label {} can only be set by Rust team members;\
  35. we were unable to check if you are a team member.",
  36. name
  37. )),
  38. Err(err) => Some(err),
  39. };
  40. if let Some(msg) = err {
  41. let cmnt = ErrorComment::new(&event.issue().unwrap(), msg);
  42. cmnt.post(&ctx.github).await?;
  43. return Ok(());
  44. }
  45. match delta {
  46. LabelDelta::Add(label) => {
  47. to_add.push(github::Label {
  48. name: label.to_string(),
  49. });
  50. }
  51. LabelDelta::Remove(label) => {
  52. results.push((
  53. label,
  54. event.issue().unwrap().remove_label(&ctx.github, &label),
  55. ));
  56. }
  57. }
  58. }
  59. if let Err(e) = event
  60. .issue()
  61. .unwrap()
  62. .add_labels(&ctx.github, to_add.clone())
  63. .await
  64. {
  65. tracing::error!(
  66. "failed to add {:?} from issue {}: {:?}",
  67. to_add,
  68. event.issue().unwrap().global_id(),
  69. e
  70. );
  71. return Err(e);
  72. }
  73. for (label, res) in results {
  74. if let Err(e) = res.await {
  75. tracing::error!(
  76. "failed to remove {:?} from issue {}: {:?}",
  77. label,
  78. event.issue().unwrap().global_id(),
  79. e
  80. );
  81. return Err(e);
  82. }
  83. }
  84. Ok(())
  85. }
  86. #[derive(Debug, PartialEq, Eq)]
  87. enum TeamMembership {
  88. Member,
  89. Outsider,
  90. Unknown,
  91. }
  92. async fn is_member(user: &github::User, client: &GithubClient) -> TeamMembership {
  93. match user.is_team_member(client).await {
  94. Ok(true) => TeamMembership::Member,
  95. Ok(false) => TeamMembership::Outsider,
  96. Err(err) => {
  97. eprintln!("failed to check team membership: {:?}", err);
  98. TeamMembership::Unknown
  99. }
  100. }
  101. }
  102. #[cfg_attr(test, derive(Debug, PartialEq, Eq))]
  103. enum CheckFilterResult {
  104. Allow,
  105. Deny,
  106. DenyUnknown,
  107. }
  108. fn check_filter(
  109. label: &str,
  110. config: &RelabelConfig,
  111. is_member: TeamMembership,
  112. ) -> Result<CheckFilterResult, String> {
  113. if is_member == TeamMembership::Member {
  114. return Ok(CheckFilterResult::Allow);
  115. }
  116. let mut matched = false;
  117. for pattern in &config.allow_unauthenticated {
  118. match match_pattern(pattern, label) {
  119. Ok(MatchPatternResult::Allow) => matched = true,
  120. Ok(MatchPatternResult::Deny) => {
  121. // An explicit deny overrides any allowed pattern
  122. matched = false;
  123. break;
  124. }
  125. Ok(MatchPatternResult::NoMatch) => {}
  126. Err(err) => {
  127. eprintln!("failed to match pattern {}: {}", pattern, err);
  128. return Err(format!("failed to match pattern {}", pattern));
  129. }
  130. }
  131. }
  132. if matched {
  133. return Ok(CheckFilterResult::Allow);
  134. } else if is_member == TeamMembership::Outsider {
  135. return Ok(CheckFilterResult::Deny);
  136. } else {
  137. return Ok(CheckFilterResult::DenyUnknown);
  138. }
  139. }
  140. #[cfg_attr(test, derive(Debug, PartialEq, Eq))]
  141. enum MatchPatternResult {
  142. Allow,
  143. Deny,
  144. NoMatch,
  145. }
  146. fn match_pattern(pattern: &str, label: &str) -> anyhow::Result<MatchPatternResult> {
  147. let (pattern, inverse) = if pattern.starts_with('!') {
  148. (&pattern[1..], true)
  149. } else {
  150. (pattern, false)
  151. };
  152. let glob = glob::Pattern::new(pattern)?;
  153. let mut matchopts = glob::MatchOptions::default();
  154. matchopts.case_sensitive = false;
  155. Ok(match (glob.matches_with(label, matchopts), inverse) {
  156. (true, false) => MatchPatternResult::Allow,
  157. (true, true) => MatchPatternResult::Deny,
  158. (false, _) => MatchPatternResult::NoMatch,
  159. })
  160. }
  161. #[cfg(test)]
  162. mod tests {
  163. use super::{
  164. check_filter, match_pattern, CheckFilterResult, MatchPatternResult, TeamMembership,
  165. };
  166. use crate::config::RelabelConfig;
  167. #[test]
  168. fn test_match_pattern() -> anyhow::Result<()> {
  169. assert_eq!(
  170. match_pattern("I-*", "I-nominated")?,
  171. MatchPatternResult::Allow
  172. );
  173. assert_eq!(
  174. match_pattern("i-*", "I-nominated")?,
  175. MatchPatternResult::Allow
  176. );
  177. assert_eq!(
  178. match_pattern("!I-no*", "I-nominated")?,
  179. MatchPatternResult::Deny
  180. );
  181. assert_eq!(
  182. match_pattern("I-*", "T-infra")?,
  183. MatchPatternResult::NoMatch
  184. );
  185. assert_eq!(
  186. match_pattern("!I-no*", "T-infra")?,
  187. MatchPatternResult::NoMatch
  188. );
  189. Ok(())
  190. }
  191. #[test]
  192. fn test_check_filter() -> anyhow::Result<()> {
  193. macro_rules! t {
  194. ($($member:ident { $($label:expr => $res:ident,)* })*) => {
  195. let config = RelabelConfig {
  196. allow_unauthenticated: vec!["T-*".into(), "I-*".into(), "!I-*nominated".into()],
  197. };
  198. $($(assert_eq!(
  199. check_filter($label, &config, TeamMembership::$member),
  200. Ok(CheckFilterResult::$res)
  201. );)*)*
  202. }
  203. }
  204. t! {
  205. Member {
  206. "T-release" => Allow,
  207. "I-slow" => Allow,
  208. "I-lang-nominated" => Allow,
  209. "I-nominated" => Allow,
  210. "A-spurious" => Allow,
  211. }
  212. Outsider {
  213. "T-release" => Allow,
  214. "I-slow" => Allow,
  215. "I-lang-nominated" => Deny,
  216. "I-nominated" => Deny,
  217. "A-spurious" => Deny,
  218. }
  219. Unknown {
  220. "T-release" => Allow,
  221. "I-slow" => Allow,
  222. "I-lang-nominated" => DenyUnknown,
  223. "I-nominated" => DenyUnknown,
  224. "A-spurious" => DenyUnknown,
  225. }
  226. }
  227. Ok(())
  228. }
  229. }