Bläddra i källkod

Add nominate command

This allows for the bot to know which labels to add/remove during
nomination and beta backport acceptance, rather than trying to push that
knowledge into all teams. It also enforces adding a team label when
nominating, to aid in folks seeing nominates.

Currently the nominated and beta-nominated/accepted labels are hardcoded
but team name to team label mappings are not. This may want to change to
even less hard coding in the future.
Mark Rousskov 5 år sedan
förälder
incheckning
ba87af0cfa
6 ändrade filer med 300 tillägg och 1 borttagningar
  1. 8 0
      parser/src/command.rs
  2. 134 0
      parser/src/command/nominate.rs
  3. 8 0
      src/config.rs
  4. 1 1
      src/github.rs
  5. 1 0
      src/handlers.rs
  6. 148 0
      src/handlers/nominate.rs

+ 8 - 0
parser/src/command.rs

@@ -3,6 +3,7 @@ use crate::error::Error;
 use crate::token::{Token, Tokenizer};
 
 pub mod assign;
+pub mod nominate;
 pub mod ping;
 pub mod relabel;
 
@@ -15,6 +16,7 @@ pub enum Command<'a> {
     Relabel(Result<relabel::RelabelCommand, Error<'a>>),
     Assign(Result<assign::AssignCommand, Error<'a>>),
     Ping(Result<ping::PingCommand, Error<'a>>),
+    Nominate(Result<nominate::NominateCommand, Error<'a>>),
     None,
 }
 
@@ -88,6 +90,11 @@ impl<'a> Input<'a> {
             Command::Ping,
             &original_tokenizer,
         ));
+        success.extend(parse_single_command(
+            nominate::NominateCommand::parse,
+            Command::Nominate,
+            &original_tokenizer,
+        ));
 
         if success.len() > 1 {
             panic!(
@@ -125,6 +132,7 @@ impl<'a> Command<'a> {
             Command::Relabel(r) => r.is_ok(),
             Command::Assign(r) => r.is_ok(),
             Command::Ping(r) => r.is_ok(),
+            Command::Nominate(r) => r.is_ok(),
             Command::None => true,
         }
     }

+ 134 - 0
parser/src/command/nominate.rs

@@ -0,0 +1,134 @@
+//! The beta nomination command parser.
+//!
+//! The grammar is as follows:
+//!
+//! ```text
+//! Command:
+//! `@bot beta-nominate <team>`.
+//! `@bot nominate <team>`.
+//! `@bot beta-accept`.
+//! `@bot beta-approve`.
+//! ```
+//!
+//! This constrains to just one team; users should issue the command multiple
+//! times if they want to nominate for more than one team. This is to encourage
+//! descriptions of what to do targeted at each team, rather than a general
+//! summary.
+
+use crate::error::Error;
+use crate::token::{Token, Tokenizer};
+use std::fmt;
+
+#[derive(PartialEq, Eq, Debug)]
+pub struct NominateCommand {
+    pub team: String,
+    pub style: Style,
+}
+
+#[derive(Debug, PartialEq, Eq, Copy, Clone)]
+pub enum Style {
+    Beta,
+    BetaApprove,
+    Decision,
+}
+
+#[derive(PartialEq, Eq, Debug)]
+pub enum ParseError {
+    ExpectedEnd,
+    NoTeam,
+}
+
+impl std::error::Error for ParseError {}
+
+impl fmt::Display for ParseError {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            ParseError::ExpectedEnd => write!(f, "expected end of command"),
+            ParseError::NoTeam => write!(f, "no team specified"),
+        }
+    }
+}
+
+impl NominateCommand {
+    pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result<Option<Self>, Error<'a>> {
+        let mut toks = input.clone();
+        let style = match toks.peek_token()? {
+            Some(Token::Word("beta-nominate")) => Style::Beta,
+            Some(Token::Word("nominate")) => Style::Decision,
+            Some(Token::Word("beta-accept")) => Style::BetaApprove,
+            Some(Token::Word("beta-approve")) => Style::BetaApprove,
+            None | Some(_) => return Ok(None),
+        };
+        toks.next_token()?;
+        let team = if style != Style::BetaApprove {
+            if let Some(Token::Word(team)) = toks.next_token()? {
+                team.to_owned()
+            } else {
+                return Err(toks.error(ParseError::NoTeam));
+            }
+        } else {
+            String::new()
+        };
+        if let Some(Token::Dot) | Some(Token::EndOfLine) = toks.peek_token()? {
+            toks.next_token()?;
+            *input = toks;
+            return Ok(Some(NominateCommand { team, style }));
+        } else {
+            return Err(toks.error(ParseError::ExpectedEnd));
+        }
+    }
+}
+
+#[cfg(test)]
+fn parse<'a>(input: &'a str) -> Result<Option<NominateCommand>, Error<'a>> {
+    let mut toks = Tokenizer::new(input);
+    Ok(NominateCommand::parse(&mut toks)?)
+}
+
+#[test]
+fn test_1() {
+    assert_eq!(
+        parse("nominate compiler."),
+        Ok(Some(NominateCommand {
+            team: "compiler".into(),
+            style: Style::Decision,
+        }))
+    );
+}
+
+#[test]
+fn test_2() {
+    assert_eq!(
+        parse("beta-nominate compiler."),
+        Ok(Some(NominateCommand {
+            team: "compiler".into(),
+            style: Style::Beta,
+        }))
+    );
+}
+
+#[test]
+fn test_3() {
+    use std::error::Error;
+    assert_eq!(
+        parse("nominate foo foo")
+            .unwrap_err()
+            .source()
+            .unwrap()
+            .downcast_ref(),
+        Some(&ParseError::ExpectedEnd),
+    );
+}
+
+#[test]
+fn test_4() {
+    use std::error::Error;
+    assert_eq!(
+        parse("nominate")
+            .unwrap_err()
+            .source()
+            .unwrap()
+            .downcast_ref(),
+        Some(&ParseError::NoTeam),
+    );
+}

+ 8 - 0
src/config.rs

@@ -19,6 +19,14 @@ pub(crate) struct Config {
     pub(crate) relabel: Option<RelabelConfig>,
     pub(crate) assign: Option<AssignConfig>,
     pub(crate) ping: Option<PingConfig>,
+    pub(crate) nominate: Option<NominateConfig>,
+}
+
+#[derive(Debug, serde::Deserialize)]
+pub(crate) struct NominateConfig {
+    // team name -> label
+    #[serde(flatten)]
+    pub(crate) teams: HashMap<String, String>,
 }
 
 #[derive(Debug, serde::Deserialize)]

+ 1 - 1
src/github.rs

@@ -88,7 +88,7 @@ pub async fn get_team(
     Ok(map.swap_remove(team))
 }
 
-#[derive(Debug, Clone, serde::Deserialize)]
+#[derive(PartialEq, Eq, Debug, Clone, serde::Deserialize)]
 pub struct Label {
     pub name: String,
 }

+ 1 - 0
src/handlers.rs

@@ -61,6 +61,7 @@ handlers! {
     assign = assign::AssignmentHandler,
     relabel = relabel::RelabelHandler,
     ping = ping::PingHandler,
+    nominate = nominate::NominateHandler,
     //tracking_issue = tracking_issue::TrackingIssueHandler,
 }
 

+ 148 - 0
src/handlers/nominate.rs

@@ -0,0 +1,148 @@
+//! Purpose: Allow team members to nominate issues or PRs.
+
+use crate::{
+    config::NominateConfig,
+    github::{self, Event},
+    handlers::{Context, Handler},
+    interactions::ErrorComment,
+};
+use failure::Error;
+use futures::future::{BoxFuture, FutureExt};
+use parser::command::nominate::{NominateCommand, Style};
+use parser::command::{Command, Input};
+
+pub(super) struct NominateHandler;
+
+impl Handler for NominateHandler {
+    type Input = NominateCommand;
+    type Config = NominateConfig;
+
+    fn parse_input(&self, ctx: &Context, event: &Event) -> Result<Option<Self::Input>, String> {
+        let body = if let Some(b) = event.comment_body() {
+            b
+        } else {
+            // not interested in other events
+            return Ok(None);
+        };
+
+        if let Event::Issue(e) = event {
+            if e.action != github::IssuesAction::Opened {
+                // skip events other than opening the issue to avoid retriggering commands in the
+                // issue body
+                return Ok(None);
+            }
+        }
+
+        let mut input = Input::new(&body, &ctx.username);
+        match input.parse_command() {
+            Command::Nominate(Ok(command)) => Ok(Some(command)),
+            Command::Nominate(Err(err)) => {
+                return Err(format!(
+                    "Parsing nominate command in [comment]({}) failed: {}",
+                    event.html_url().expect("has html url"),
+                    err
+                ));
+            }
+            _ => Ok(None),
+        }
+    }
+
+    fn handle_input<'a>(
+        &self,
+        ctx: &'a Context,
+        config: &'a Self::Config,
+        event: &'a Event,
+        input: Self::Input,
+    ) -> BoxFuture<'a, Result<(), Error>> {
+        handle_input(ctx, config, event, input).boxed()
+    }
+}
+
+async fn handle_input(
+    ctx: &Context,
+    config: &NominateConfig,
+    event: &Event,
+    cmd: NominateCommand,
+) -> Result<(), Error> {
+    let is_team_member = if let Err(_) | Ok(false) = event.user().is_team_member(&ctx.github).await
+    {
+        false
+    } else {
+        true
+    };
+
+    if !is_team_member {
+        let cmnt = ErrorComment::new(
+            &event.issue().unwrap(),
+            format!(
+                "Nominating and approving issues and pull requests is restricted to members of\
+                 the Rust teams."
+            ),
+        );
+        cmnt.post(&ctx.github).await?;
+        return Ok(());
+    }
+
+    let mut issue_labels = event.issue().unwrap().labels().to_owned();
+    if cmd.style == Style::BetaApprove {
+        if !issue_labels.iter().any(|l| l.name == "beta-nominated") {
+            let cmnt = ErrorComment::new(
+                &event.issue().unwrap(),
+                format!(
+                    "This pull request is not beta-nominated, so it cannot be approved yet.\
+                     Perhaps try to beta-nominate it by using `@{} beta-nominate <team>`?",
+                    ctx.username,
+                ),
+            );
+            cmnt.post(&ctx.github).await?;
+            return Ok(());
+        }
+
+        // Add the beta-accepted label, but don't attempt to remove beta-nominated or the team
+        // label.
+        if !issue_labels.iter().any(|l| l.name == "beta-accepted") {
+            issue_labels.push(github::Label {
+                name: "beta-accepted".into(),
+            });
+        }
+    } else {
+        if !config.teams.contains_key(&cmd.team) {
+            let cmnt = ErrorComment::new(
+                &event.issue().unwrap(),
+                format!(
+                    "This team (`{}`) cannot be nominated for via this command;\
+                     it may need to be added to `triagebot.toml` on the master branch.",
+                    cmd.team,
+                ),
+            );
+            cmnt.post(&ctx.github).await?;
+            return Ok(());
+        }
+
+        let label = config.teams[&cmd.team].clone();
+        if !issue_labels.iter().any(|l| l.name == label) {
+            issue_labels.push(github::Label { name: label });
+        }
+
+        let style_label = match cmd.style {
+            Style::Decision => "I-nominated",
+            Style::Beta => "beta-nominated",
+            Style::BetaApprove => unreachable!(),
+        };
+        if !issue_labels.iter().any(|l| l.name == style_label) {
+            issue_labels.push(github::Label {
+                name: style_label.into(),
+            });
+        }
+    }
+
+    if &issue_labels[..] != event.issue().unwrap().labels() {
+        event
+            .issue()
+            .unwrap()
+            .set_labels(&ctx.github, issue_labels)
+            .await?;
+    }
+
+    Ok(())
+}