relabel.rs 7.9 KB

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