Skip to content

Commit ef61cb1

Browse files
authored
Merge pull request #1381 from Llandy3d/ready_alias
implement the shortcut handler
2 parents 5549067 + 2b077d6 commit ef61cb1

File tree

6 files changed

+261
-0
lines changed

6 files changed

+261
-0
lines changed

parser/src/command.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub mod ping;
1010
pub mod prioritize;
1111
pub mod relabel;
1212
pub mod second;
13+
pub mod shortcut;
1314

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

@@ -119,6 +121,11 @@ impl<'a> Input<'a> {
119121
Command::Glacier,
120122
&original_tokenizer,
121123
));
124+
success.extend(parse_single_command(
125+
shortcut::ShortcutCommand::parse,
126+
Command::Shortcut,
127+
&original_tokenizer,
128+
));
122129
success.extend(parse_single_command(
123130
close::CloseCommand::parse,
124131
Command::Close,
@@ -182,6 +189,7 @@ impl<'a> Command<'a> {
182189
Command::Prioritize(r) => r.is_ok(),
183190
Command::Second(r) => r.is_ok(),
184191
Command::Glacier(r) => r.is_ok(),
192+
Command::Shortcut(r) => r.is_ok(),
185193
Command::Close(r) => r.is_ok(),
186194
}
187195
}

parser/src/command/shortcut.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
//! The shortcut command parser.
2+
//!
3+
//! This can parse predefined shortcut input, single word commands.
4+
//!
5+
//! The grammar is as follows:
6+
//!
7+
//! ```text
8+
//! Command: `@bot ready`, or `@bot author`.
9+
//! ```
10+
11+
use crate::error::Error;
12+
use crate::token::{Token, Tokenizer};
13+
use std::collections::HashMap;
14+
use std::fmt;
15+
16+
#[derive(PartialEq, Eq, Debug, Copy, Clone)]
17+
pub enum ShortcutCommand {
18+
Ready,
19+
Author,
20+
}
21+
22+
#[derive(PartialEq, Eq, Debug)]
23+
pub enum ParseError {
24+
ExpectedEnd,
25+
}
26+
27+
impl std::error::Error for ParseError {}
28+
29+
impl fmt::Display for ParseError {
30+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
31+
match self {
32+
ParseError::ExpectedEnd => write!(f, "expected end of command"),
33+
}
34+
}
35+
}
36+
37+
impl ShortcutCommand {
38+
pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result<Option<Self>, Error<'a>> {
39+
let mut shortcuts = HashMap::new();
40+
shortcuts.insert("ready", ShortcutCommand::Ready);
41+
shortcuts.insert("author", ShortcutCommand::Author);
42+
43+
let mut toks = input.clone();
44+
if let Some(Token::Word(word)) = toks.peek_token()? {
45+
if !shortcuts.contains_key(word) {
46+
return Ok(None);
47+
}
48+
toks.next_token()?;
49+
if let Some(Token::Dot) | Some(Token::EndOfLine) = toks.peek_token()? {
50+
toks.next_token()?;
51+
*input = toks;
52+
let command = shortcuts.get(word).unwrap();
53+
return Ok(Some(*command));
54+
} else {
55+
return Err(toks.error(ParseError::ExpectedEnd));
56+
}
57+
}
58+
Ok(None)
59+
}
60+
}
61+
62+
#[cfg(test)]
63+
fn parse(input: &str) -> Result<Option<ShortcutCommand>, Error<'_>> {
64+
let mut toks = Tokenizer::new(input);
65+
Ok(ShortcutCommand::parse(&mut toks)?)
66+
}
67+
68+
#[test]
69+
fn test_1() {
70+
assert_eq!(parse("ready."), Ok(Some(ShortcutCommand::Ready)),);
71+
}
72+
73+
#[test]
74+
fn test_2() {
75+
assert_eq!(parse("ready"), Ok(Some(ShortcutCommand::Ready)),);
76+
}
77+
78+
#[test]
79+
fn test_3() {
80+
assert_eq!(parse("author"), Ok(Some(ShortcutCommand::Author)),);
81+
}
82+
83+
#[test]
84+
fn test_4() {
85+
use std::error::Error;
86+
assert_eq!(
87+
parse("ready word")
88+
.unwrap_err()
89+
.source()
90+
.unwrap()
91+
.downcast_ref(),
92+
Some(&ParseError::ExpectedEnd),
93+
);
94+
}

src/config.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub(crate) struct Config {
2929
pub(crate) notify_zulip: Option<NotifyZulipConfig>,
3030
pub(crate) github_releases: Option<GitHubReleasesConfig>,
3131
pub(crate) review_submitted: Option<ReviewSubmittedConfig>,
32+
pub(crate) shortcut: Option<ShortcutConfig>,
3233
}
3334

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

86+
#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
87+
pub(crate) struct ShortcutConfig {
88+
#[serde(default)]
89+
_empty: (),
90+
}
91+
8592
#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
8693
pub(crate) struct PrioritizeConfig {
8794
pub(crate) label: String,
@@ -255,6 +262,8 @@ mod tests {
255262
release = "T-release"
256263
core = "T-core"
257264
infra = "T-infra"
265+
266+
[shortcut]
258267
"#;
259268
let config = toml::from_str::<Config>(&config).unwrap();
260269
let mut ping_teams = HashMap::new();
@@ -290,6 +299,7 @@ mod tests {
290299
nominate: Some(NominateConfig {
291300
teams: nominate_teams
292301
}),
302+
shortcut: Some(ShortcutConfig { _empty: () }),
293303
prioritize: None,
294304
major_change: None,
295305
glacier: None,

src/handlers.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ mod prioritize;
3838
mod relabel;
3939
mod review_submitted;
4040
mod rustc_commits;
41+
mod shortcut;
4142

4243
pub async fn handle(ctx: &Context, event: &Event) -> Vec<HandlerError> {
4344
let config = config::get(&ctx.github, event.repo_name()).await;
@@ -240,6 +241,7 @@ command_handlers! {
240241
prioritize: Prioritize,
241242
relabel: Relabel,
242243
major_change: Second,
244+
shortcut: Shortcut,
243245
close: Close,
244246
}
245247

src/handlers/shortcut.rs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//! Purpose: Allow the use of single words shortcut to do specific actions on GitHub via comments.
2+
//!
3+
//! Parsing is done in the `parser::command::shortcut` module.
4+
5+
use crate::{
6+
config::ShortcutConfig,
7+
github::{Event, Label},
8+
handlers::Context,
9+
interactions::{ErrorComment, PingComment},
10+
};
11+
use parser::command::shortcut::ShortcutCommand;
12+
13+
pub(super) async fn handle_command(
14+
ctx: &Context,
15+
_config: &ShortcutConfig,
16+
event: &Event,
17+
input: ShortcutCommand,
18+
) -> anyhow::Result<()> {
19+
let issue = event.issue().unwrap();
20+
// NOTE: if shortcuts available to issues are created, they need to be allowed here
21+
if !issue.is_pr() {
22+
let msg = format!("The \"{:?}\" shortcut only works on pull requests.", input);
23+
let cmnt = ErrorComment::new(&issue, msg);
24+
cmnt.post(&ctx.github).await?;
25+
return Ok(());
26+
}
27+
28+
let mut issue_labels = issue.labels().to_owned();
29+
let waiting_on_review = "S-waiting-on-review";
30+
let waiting_on_author = "S-waiting-on-author";
31+
32+
match input {
33+
ShortcutCommand::Ready => {
34+
if assign_and_remove_label(&mut issue_labels, waiting_on_review, waiting_on_author)
35+
.is_some()
36+
{
37+
return Ok(());
38+
}
39+
issue.set_labels(&ctx.github, issue_labels).await?;
40+
41+
let to_ping: Vec<_> = issue
42+
.assignees
43+
.iter()
44+
.map(|user| user.login.as_str())
45+
.collect();
46+
let cmnt = PingComment::new(&issue, &to_ping);
47+
cmnt.post(&ctx.github).await?;
48+
}
49+
ShortcutCommand::Author => {
50+
if assign_and_remove_label(&mut issue_labels, waiting_on_author, waiting_on_review)
51+
.is_some()
52+
{
53+
return Ok(());
54+
}
55+
issue.set_labels(&ctx.github, issue_labels).await?;
56+
57+
let to_ping = vec![issue.user.login.as_str()];
58+
let cmnt = PingComment::new(&issue, &to_ping);
59+
cmnt.post(&ctx.github).await?;
60+
}
61+
}
62+
63+
Ok(())
64+
}
65+
66+
fn assign_and_remove_label(
67+
issue_labels: &mut Vec<Label>,
68+
assign: &str,
69+
remove: &str,
70+
) -> Option<()> {
71+
if issue_labels.iter().any(|label| label.name == assign) {
72+
return Some(());
73+
}
74+
75+
if let Some(index) = issue_labels.iter().position(|label| label.name == remove) {
76+
issue_labels.swap_remove(index);
77+
}
78+
79+
issue_labels.push(Label {
80+
name: assign.into(),
81+
});
82+
83+
None
84+
}
85+
86+
#[cfg(test)]
87+
mod tests {
88+
89+
use super::{assign_and_remove_label, Label};
90+
fn create_labels(names: Vec<&str>) -> Vec<Label> {
91+
names
92+
.into_iter()
93+
.map(|name| Label { name: name.into() })
94+
.collect()
95+
}
96+
97+
#[test]
98+
fn test_adds_without_labels() {
99+
let expected = create_labels(vec!["assign"]);
100+
let mut labels = vec![];
101+
assert!(assign_and_remove_label(&mut labels, "assign", "remove").is_none());
102+
assert_eq!(labels, expected);
103+
}
104+
105+
#[test]
106+
fn test_do_nothing_with_label_already_set() {
107+
let expected = create_labels(vec!["assign"]);
108+
let mut labels = create_labels(vec!["assign"]);
109+
assert!(assign_and_remove_label(&mut labels, "assign", "remove").is_some());
110+
assert_eq!(labels, expected);
111+
}
112+
113+
#[test]
114+
fn test_other_labels_untouched() {
115+
let expected = create_labels(vec!["bug", "documentation", "assign"]);
116+
let mut labels = create_labels(vec!["bug", "documentation"]);
117+
assert!(assign_and_remove_label(&mut labels, "assign", "remove").is_none());
118+
assert_eq!(labels, expected);
119+
}
120+
121+
#[test]
122+
fn test_correctly_remove_label() {
123+
let expected = create_labels(vec!["bug", "documentation", "assign"]);
124+
let mut labels = create_labels(vec!["bug", "documentation", "remove"]);
125+
assert!(assign_and_remove_label(&mut labels, "assign", "remove").is_none());
126+
assert_eq!(labels, expected);
127+
}
128+
}

src/interactions.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,25 @@ impl<'a> ErrorComment<'a> {
2929
}
3030
}
3131

32+
pub struct PingComment<'a> {
33+
issue: &'a Issue,
34+
users: &'a [&'a str],
35+
}
36+
37+
impl<'a> PingComment<'a> {
38+
pub fn new(issue: &'a Issue, users: &'a [&str]) -> PingComment<'a> {
39+
PingComment { issue, users }
40+
}
41+
42+
pub async fn post(&self, client: &GithubClient) -> anyhow::Result<()> {
43+
let mut body = String::new();
44+
for user in self.users {
45+
write!(body, "@{} ", user)?;
46+
}
47+
self.issue.post_comment(client, &body).await
48+
}
49+
}
50+
3251
pub struct EditIssueBody<'a> {
3352
issue: &'a Issue,
3453
id: &'static str,

0 commit comments

Comments
 (0)