Skip to content

implement the shortcut handler #1381

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 18, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions parser/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub mod ping;
pub mod prioritize;
pub mod relabel;
pub mod second;
pub mod shortcut;

pub fn find_command_start(input: &str, bot: &str) -> Option<usize> {
input.to_ascii_lowercase().find(&format!("@{}", bot))
Expand All @@ -24,6 +25,7 @@ pub enum Command<'a> {
Prioritize(Result<prioritize::PrioritizeCommand, Error<'a>>),
Second(Result<second::SecondCommand, Error<'a>>),
Glacier(Result<glacier::GlacierCommand, Error<'a>>),
Shortcut(Result<shortcut::ShortcutCommand, Error<'a>>),
Close(Result<close::CloseCommand, Error<'a>>),
}

Expand Down Expand Up @@ -119,6 +121,11 @@ impl<'a> Input<'a> {
Command::Glacier,
&original_tokenizer,
));
success.extend(parse_single_command(
shortcut::ShortcutCommand::parse,
Command::Shortcut,
&original_tokenizer,
));
success.extend(parse_single_command(
close::CloseCommand::parse,
Command::Close,
Expand Down Expand Up @@ -182,6 +189,7 @@ impl<'a> Command<'a> {
Command::Prioritize(r) => r.is_ok(),
Command::Second(r) => r.is_ok(),
Command::Glacier(r) => r.is_ok(),
Command::Shortcut(r) => r.is_ok(),
Command::Close(r) => r.is_ok(),
}
}
Expand Down
94 changes: 94 additions & 0 deletions parser/src/command/shortcut.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//! The shortcut command parser.
//!
//! This can parse predefined shortcut input, single word commands.
//!
//! The grammar is as follows:
//!
//! ```text
//! Command: `@bot ready`, or `@bot author`.
//! ```

use crate::error::Error;
use crate::token::{Token, Tokenizer};
use std::collections::HashMap;
use std::fmt;

#[derive(PartialEq, Eq, Debug, Copy, Clone)]
pub enum ShortcutCommand {
Ready,
Author,
}

#[derive(PartialEq, Eq, Debug)]
pub enum ParseError {
ExpectedEnd,
}

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"),
}
}
}

impl ShortcutCommand {
pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result<Option<Self>, Error<'a>> {
let mut shortcuts = HashMap::new();
shortcuts.insert("ready", ShortcutCommand::Ready);
shortcuts.insert("author", ShortcutCommand::Author);

let mut toks = input.clone();
if let Some(Token::Word(word)) = toks.peek_token()? {
if !shortcuts.contains_key(word) {
return Ok(None);
}
toks.next_token()?;
if let Some(Token::Dot) | Some(Token::EndOfLine) = toks.peek_token()? {
toks.next_token()?;
*input = toks;
let command = shortcuts.get(word).unwrap();
return Ok(Some(*command));
} else {
return Err(toks.error(ParseError::ExpectedEnd));
}
}
Ok(None)
}
}

#[cfg(test)]
fn parse(input: &str) -> Result<Option<ShortcutCommand>, Error<'_>> {
let mut toks = Tokenizer::new(input);
Ok(ShortcutCommand::parse(&mut toks)?)
}

#[test]
fn test_1() {
assert_eq!(parse("ready."), Ok(Some(ShortcutCommand::Ready)),);
}

#[test]
fn test_2() {
assert_eq!(parse("ready"), Ok(Some(ShortcutCommand::Ready)),);
}

#[test]
fn test_3() {
assert_eq!(parse("author"), Ok(Some(ShortcutCommand::Author)),);
}

#[test]
fn test_4() {
use std::error::Error;
assert_eq!(
parse("ready word")
.unwrap_err()
.source()
.unwrap()
.downcast_ref(),
Some(&ParseError::ExpectedEnd),
);
}
10 changes: 10 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub(crate) struct Config {
pub(crate) notify_zulip: Option<NotifyZulipConfig>,
pub(crate) github_releases: Option<GitHubReleasesConfig>,
pub(crate) review_submitted: Option<ReviewSubmittedConfig>,
pub(crate) shortcut: Option<ShortcutConfig>,
}

#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
Expand Down Expand Up @@ -82,6 +83,12 @@ pub(crate) struct RelabelConfig {
pub(crate) allow_unauthenticated: Vec<String>,
}

#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
pub(crate) struct ShortcutConfig {
#[serde(default)]
_empty: (),
}

#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
pub(crate) struct PrioritizeConfig {
pub(crate) label: String,
Expand Down Expand Up @@ -255,6 +262,8 @@ mod tests {
release = "T-release"
core = "T-core"
infra = "T-infra"

[shortcut]
"#;
let config = toml::from_str::<Config>(&config).unwrap();
let mut ping_teams = HashMap::new();
Expand Down Expand Up @@ -290,6 +299,7 @@ mod tests {
nominate: Some(NominateConfig {
teams: nominate_teams
}),
shortcut: Some(ShortcutConfig { _empty: () }),
prioritize: None,
major_change: None,
glacier: None,
Expand Down
2 changes: 2 additions & 0 deletions src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ mod prioritize;
mod relabel;
mod review_submitted;
mod rustc_commits;
mod shortcut;

pub async fn handle(ctx: &Context, event: &Event) -> Vec<HandlerError> {
let config = config::get(&ctx.github, event.repo_name()).await;
Expand Down Expand Up @@ -240,6 +241,7 @@ command_handlers! {
prioritize: Prioritize,
relabel: Relabel,
major_change: Second,
shortcut: Shortcut,
close: Close,
}

Expand Down
128 changes: 128 additions & 0 deletions src/handlers/shortcut.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! Purpose: Allow the use of single words shortcut to do specific actions on GitHub via comments.
//!
//! Parsing is done in the `parser::command::shortcut` module.

use crate::{
config::ShortcutConfig,
github::{Event, Label},
handlers::Context,
interactions::{ErrorComment, PingComment},
};
use parser::command::shortcut::ShortcutCommand;

pub(super) async fn handle_command(
ctx: &Context,
_config: &ShortcutConfig,
event: &Event,
input: ShortcutCommand,
) -> anyhow::Result<()> {
let issue = event.issue().unwrap();
// NOTE: if shortcuts available to issues are created, they need to be allowed here
if !issue.is_pr() {
let msg = format!("The \"{:?}\" shortcut only works on pull requests.", input);
let cmnt = ErrorComment::new(&issue, msg);
cmnt.post(&ctx.github).await?;
return Ok(());
}

let mut issue_labels = issue.labels().to_owned();
let waiting_on_review = "S-waiting-on-review";
let waiting_on_author = "S-waiting-on-author";

match input {
ShortcutCommand::Ready => {
if assign_and_remove_label(&mut issue_labels, waiting_on_review, waiting_on_author)
.is_some()
{
return Ok(());
}
issue.set_labels(&ctx.github, issue_labels).await?;

let to_ping: Vec<_> = issue
.assignees
.iter()
.map(|user| user.login.as_str())
.collect();
let cmnt = PingComment::new(&issue, &to_ping);
cmnt.post(&ctx.github).await?;
}
ShortcutCommand::Author => {
if assign_and_remove_label(&mut issue_labels, waiting_on_author, waiting_on_review)
.is_some()
{
return Ok(());
}
issue.set_labels(&ctx.github, issue_labels).await?;

let to_ping = vec![issue.user.login.as_str()];
let cmnt = PingComment::new(&issue, &to_ping);
cmnt.post(&ctx.github).await?;
}
}

Ok(())
}

fn assign_and_remove_label(
issue_labels: &mut Vec<Label>,
assign: &str,
remove: &str,
) -> Option<()> {
if issue_labels.iter().any(|label| label.name == assign) {
return Some(());
}

if let Some(index) = issue_labels.iter().position(|label| label.name == remove) {
issue_labels.swap_remove(index);
}

issue_labels.push(Label {
name: assign.into(),
});

None
}

#[cfg(test)]
mod tests {

use super::{assign_and_remove_label, Label};
fn create_labels(names: Vec<&str>) -> Vec<Label> {
names
.into_iter()
.map(|name| Label { name: name.into() })
.collect()
}

#[test]
fn test_adds_without_labels() {
let expected = create_labels(vec!["assign"]);
let mut labels = vec![];
assert!(assign_and_remove_label(&mut labels, "assign", "remove").is_none());
assert_eq!(labels, expected);
}

#[test]
fn test_do_nothing_with_label_already_set() {
let expected = create_labels(vec!["assign"]);
let mut labels = create_labels(vec!["assign"]);
assert!(assign_and_remove_label(&mut labels, "assign", "remove").is_some());
assert_eq!(labels, expected);
}

#[test]
fn test_other_labels_untouched() {
let expected = create_labels(vec!["bug", "documentation", "assign"]);
let mut labels = create_labels(vec!["bug", "documentation"]);
assert!(assign_and_remove_label(&mut labels, "assign", "remove").is_none());
assert_eq!(labels, expected);
}

#[test]
fn test_correctly_remove_label() {
let expected = create_labels(vec!["bug", "documentation", "assign"]);
let mut labels = create_labels(vec!["bug", "documentation", "remove"]);
assert!(assign_and_remove_label(&mut labels, "assign", "remove").is_none());
assert_eq!(labels, expected);
}
}
19 changes: 19 additions & 0 deletions src/interactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,25 @@ impl<'a> ErrorComment<'a> {
}
}

pub struct PingComment<'a> {
issue: &'a Issue,
users: &'a [&'a str],
}

impl<'a> PingComment<'a> {
pub fn new(issue: &'a Issue, users: &'a [&str]) -> PingComment<'a> {
PingComment { issue, users }
}

pub async fn post(&self, client: &GithubClient) -> anyhow::Result<()> {
let mut body = String::new();
for user in self.users {
write!(body, "@{} ", user)?;
}
self.issue.post_comment(client, &body).await
}
}

pub struct EditIssueBody<'a> {
issue: &'a Issue,
id: &'static str,
Expand Down