diff --git a/Cargo.lock b/Cargo.lock index 0914b9c37..ec3f64acb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -387,6 +387,11 @@ dependencies = [ "unicode-width 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "glob" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "h2" version = "0.1.16" @@ -1504,6 +1509,14 @@ dependencies = [ "serde 1.0.87 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "toml" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "serde 1.0.87 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "traitobject" version = "0.1.0" @@ -1516,6 +1529,7 @@ dependencies = [ "dotenv 0.13.0 (registry+https://github.com/rust-lang/crates.io-index)", "env_logger 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", "failure 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)", + "glob 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", "hex 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)", "lazy_static 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1528,6 +1542,7 @@ dependencies = [ "rust_team_data 1.0.0 (git+https://github.com/rust-lang/team)", "serde 1.0.87 (registry+https://github.com/rust-lang/crates.io-index)", "serde_json 1.0.38 (registry+https://github.com/rust-lang/crates.io-index)", + "toml 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1771,6 +1786,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" "checksum futures 0.1.25 (registry+https://github.com/rust-lang/crates.io-index)" = "49e7653e374fe0d0c12de4250f0bdb60680b8c80eed558c5c7538eec9c89e21b" "checksum futures-cpupool 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)" = "ab90cde24b3319636588d0c35fe03b1333857621051837ed769faefb4c2162e4" "checksum getopts 0.2.18 (registry+https://github.com/rust-lang/crates.io-index)" = "0a7292d30132fb5424b354f5dc02512a86e4c516fe544bb7a25e7f266951b797" +"checksum glob 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" "checksum h2 0.1.16 (registry+https://github.com/rust-lang/crates.io-index)" = "ddb2b25a33e231484694267af28fec74ac63b5ccf51ee2065a5e313b834d836e" "checksum hex 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)" = "805026a5d0141ffc30abb3be3173848ad46a1b1664fe632428479619a3644d77" "checksum http 0.1.15 (registry+https://github.com/rust-lang/crates.io-index)" = "1a10e5b573b9a0146545010f50772b9e8b1dd0a256564cc4307694c68832a2f5" @@ -1887,6 +1903,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" "checksum tokio-threadpool 0.1.11 (registry+https://github.com/rust-lang/crates.io-index)" = "c3fd86cb15547d02daa2b21aadaf4e37dee3368df38a526178a5afa3c034d2fb" "checksum tokio-timer 0.2.10 (registry+https://github.com/rust-lang/crates.io-index)" = "2910970404ba6fa78c5539126a9ae2045d62e3713041e447f695f41405a120c6" "checksum toml 0.4.10 (registry+https://github.com/rust-lang/crates.io-index)" = "758664fc71a3a69038656bee8b6be6477d2a6c315a6b81f7081f591bffa4111f" +"checksum toml 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "87c5890a989fa47ecdc7bcb4c63a77a82c18f306714104b1decfd722db17b39e" "checksum traitobject 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "efd1f82c56340fdf16f2a953d7bda4f8fdffba13d93b00844c25572110b26079" "checksum try-lock 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "e604eb7b43c06650e854be16a2a03155743d3752dd1c943f6829e26b7a36e382" "checksum typeable 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "1410f6f91f21d1612654e7cc69193b0334f909dcf2c790c4826254fbb86f8887" diff --git a/Cargo.toml b/Cargo.toml index 167bcff05..8d2dbcfad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ hex = "0.3.2" env_logger = "0.6" parser = { path = "parser" } rust_team_data = { git = "https://github.com/rust-lang/team" } +glob = "0.3.0" +toml = "0.5.0" [dependencies.serde] version = "1" diff --git a/parser/src/command.rs b/parser/src/command.rs index 2f02777c8..06378de1c 100644 --- a/parser/src/command.rs +++ b/parser/src/command.rs @@ -2,7 +2,7 @@ use crate::code_block::ColorCodeBlocks; use crate::error::Error; use crate::token::{Token, Tokenizer}; -pub mod label; +pub mod relabel; pub fn find_commmand_start(input: &str, bot: &str) -> Option { input.find(&format!("@{}", bot)) @@ -10,7 +10,7 @@ pub fn find_commmand_start(input: &str, bot: &str) -> Option { #[derive(Debug)] pub enum Command<'a> { - Label(Result>), + Relabel(Result>), None, } @@ -50,14 +50,14 @@ impl<'a> Input<'a> { { let mut tok = original_tokenizer.clone(); - let res = label::LabelCommand::parse(&mut tok); + let res = relabel::RelabelCommand::parse(&mut tok); match res { Ok(None) => {} Ok(Some(cmd)) => { - success.push((tok, Command::Label(Ok(cmd)))); + success.push((tok, Command::Relabel(Ok(cmd)))); } Err(err) => { - success.push((tok, Command::Label(Err(err)))); + success.push((tok, Command::Relabel(Err(err)))); } } } @@ -94,7 +94,7 @@ impl<'a> Input<'a> { impl<'a> Command<'a> { pub fn is_ok(&self) -> bool { match self { - Command::Label(r) => r.is_ok(), + Command::Relabel(r) => r.is_ok(), Command::None => true, } } diff --git a/parser/src/command/label.rs b/parser/src/command/relabel.rs similarity index 97% rename from parser/src/command/label.rs rename to parser/src/command/relabel.rs index 2a15f0311..a73ede6dd 100644 --- a/parser/src/command/label.rs +++ b/parser/src/command/relabel.rs @@ -30,7 +30,7 @@ use std::error::Error as _; use std::fmt; #[derive(Debug)] -pub struct LabelCommand(pub Vec); +pub struct RelabelCommand(pub Vec); #[derive(Debug, PartialEq, Eq)] pub enum LabelDelta { @@ -124,7 +124,7 @@ fn delta_empty() { assert_eq!(err.position(), 1); } -impl LabelCommand { +impl RelabelCommand { pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result, Error<'a>> { let mut toks = input.clone(); if let Some(Token::Word("modify")) = toks.next_token()? { @@ -163,7 +163,7 @@ impl LabelCommand { if let Some(Token::Dot) | Some(Token::EndOfLine) = toks.peek_token()? { toks.next_token()?; *input = toks; - return Ok(Some(LabelCommand(deltas))); + return Ok(Some(RelabelCommand(deltas))); } } } @@ -172,7 +172,7 @@ impl LabelCommand { #[cfg(test)] fn parse<'a>(input: &'a str) -> Result>, Error<'a>> { let mut toks = Tokenizer::new(input); - Ok(LabelCommand::parse(&mut toks)?.map(|c| c.0)) + Ok(RelabelCommand::parse(&mut toks)?.map(|c| c.0)) } #[test] diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 000000000..d932556bf --- /dev/null +++ b/src/config.rs @@ -0,0 +1,61 @@ +use crate::github::GithubClient; +use failure::Error; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +static CONFIG_FILE_NAME: &str = "triagebot.toml"; +const REFRESH_EVERY: Duration = Duration::from_secs(2 * 60); // Every two minutes + +lazy_static::lazy_static! { + static ref CONFIG_CACHE: RwLock, Instant)>> = + RwLock::new(HashMap::new()); +} + +#[derive(serde::Deserialize)] +pub(crate) struct Config { + pub(crate) relabel: Option, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct RelabelConfig { + #[serde(default)] + pub(crate) allow_unauthenticated: Vec, +} + +pub(crate) fn get(gh: &GithubClient, repo: &str) -> Result, Error> { + if let Some(config) = get_cached_config(repo) { + Ok(config) + } else { + get_fresh_config(gh, repo) + } +} + +fn get_cached_config(repo: &str) -> Option> { + let cache = CONFIG_CACHE.read().unwrap(); + cache.get(repo).and_then(|(config, fetch_time)| { + if fetch_time.elapsed() < REFRESH_EVERY { + Some(config.clone()) + } else { + None + } + }) +} + +fn get_fresh_config(gh: &GithubClient, repo: &str) -> Result, Error> { + let contents = gh + .raw_file(repo, "master", CONFIG_FILE_NAME)? + .ok_or_else(|| { + failure::err_msg( + "This repository is not enabled to use triagebot.\n\ + Add a `triagebot.toml` in the root of the master branch to enable it.", + ) + })?; + let config = Arc::new(toml::from_slice::(&contents)?); + CONFIG_CACHE + .write() + .unwrap() + .insert(repo.to_string(), (config.clone(), Instant::now())); + Ok(config) +} diff --git a/src/github.rs b/src/github.rs index bcb1419d5..9b8dc0930 100644 --- a/src/github.rs +++ b/src/github.rs @@ -1,6 +1,7 @@ use failure::{Error, ResultExt}; use reqwest::header::{AUTHORIZATION, USER_AGENT}; -use reqwest::{Client, Error as HttpError, RequestBuilder, Response}; +use reqwest::{Client, Error as HttpError, RequestBuilder, Response, StatusCode}; +use std::io::Read; #[derive(Debug, serde::Deserialize)] pub struct User { @@ -149,6 +150,46 @@ impl Issue { } } +#[derive(PartialEq, Eq, Debug, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum IssueCommentAction { + Created, + Edited, + Deleted, +} + +#[derive(Debug, serde::Deserialize)] +pub struct IssueCommentEvent { + pub action: IssueCommentAction, + pub issue: Issue, + pub comment: Comment, + pub repository: Repository, +} + +#[derive(Debug, serde::Deserialize)] +pub struct Repository { + pub full_name: String, +} + +#[derive(Debug)] +pub enum Event { + IssueComment(IssueCommentEvent), +} + +impl Event { + pub fn repo_name(&self) -> &str { + match self { + Event::IssueComment(event) => &event.repository.full_name, + } + } + + pub fn issue(&self) -> Option<&Issue> { + match self { + Event::IssueComment(event) => Some(&event.issue), + } + } +} + trait RequestSend: Sized { fn configure(self, g: &GithubClient) -> Self; fn send_req(self) -> Result; @@ -183,6 +224,23 @@ impl GithubClient { &self.client } + pub fn raw_file(&self, repo: &str, branch: &str, path: &str) -> Result>, Error> { + let url = format!( + "https://raw.githubusercontent.com/{}/{}/{}", + repo, branch, path + ); + let mut resp = self.get(&url).send()?; + match resp.status() { + StatusCode::OK => { + let mut buf = Vec::with_capacity(resp.content_length().unwrap_or(4) as usize); + resp.read_to_end(&mut buf)?; + Ok(Some(buf)) + } + StatusCode::NOT_FOUND => Ok(None), + status => failure::bail!("failed to GET {}: {}", url, status), + } + } + fn get(&self, url: &str) -> RequestBuilder { log::trace!("get {:?}", url); self.client.get(url).configure(self) diff --git a/src/handlers.rs b/src/handlers.rs index b76a82013..75ebf7a26 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -1,20 +1,51 @@ -use crate::github::GithubClient; -use crate::registry::HandleRegistry; -use std::sync::Arc; +use crate::github::{Event, GithubClient}; +use failure::Error; -//mod assign; -mod label; -//mod tracking_issue; +macro_rules! handlers { + ($($name:ident = $handler:expr,)*) => { + $(mod $name;)* -pub fn register_all(registry: &mut HandleRegistry, client: GithubClient, username: Arc) { - registry.register(label::LabelHandler { - client: client.clone(), - username: username.clone(), - }); - //registry.register(assign::AssignmentHandler { - // client: client.clone(), - //}); - //registry.register(tracking_issue::TrackingIssueHandler { - // client: client.clone(), - //}); + pub fn handle(ctx: &Context, event: &Event) -> Result<(), Error> { + $(if let Some(input) = Handler::parse_input(&$handler, ctx, event)? { + let config = crate::config::get(&ctx.github, event.repo_name())?; + if let Some(config) = &config.$name { + Handler::handle_input(&$handler, ctx, config, event, input)?; + } else { + failure::bail!( + "The feature `{}` is not enabled in this repository.\n\ + To enable it add its section in the `triagebot.toml` \ + in the root of the repository.", + stringify!($name) + ); + } + })* + Ok(()) + } + } +} + +handlers! { + //assign = assign::AssignmentHandler, + relabel = relabel::RelabelHandler, + //tracking_issue = tracking_issue::TrackingIssueHandler, +} + +pub struct Context { + pub github: GithubClient, + pub username: String, +} + +pub trait Handler: Sync + Send { + type Input; + type Config; + + fn parse_input(&self, ctx: &Context, event: &Event) -> Result, Error>; + + fn handle_input( + &self, + ctx: &Context, + config: &Self::Config, + event: &Event, + input: Self::Input, + ) -> Result<(), Error>; } diff --git a/src/handlers/label.rs b/src/handlers/relabel.rs similarity index 52% rename from src/handlers/label.rs rename to src/handlers/relabel.rs index 82c5717f1..e060ce1ac 100644 --- a/src/handlers/label.rs +++ b/src/handlers/relabel.rs @@ -3,64 +3,70 @@ //! Labels are checked against the labels in the project; the bot does not support creating new //! labels. //! -//! Parsing is done in the `parser::command::label` module. +//! Parsing is done in the `parser::command::relabel` module. //! //! If the command was successful, there will be no feedback beyond the label change to reduce //! notification noise. use crate::{ - github::{self, GithubClient}, + config::RelabelConfig, + github::{self, Event, GithubClient}, + handlers::{Context, Handler}, interactions::ErrorComment, - registry::{Event, Handler}, }; use failure::Error; -use parser::command::label::{LabelCommand, LabelDelta}; +use parser::command::relabel::{RelabelCommand, LabelDelta}; use parser::command::{Command, Input}; -use std::sync::Arc; -pub struct LabelHandler { - pub client: GithubClient, - pub username: Arc, -} +pub(super) struct RelabelHandler; + +impl Handler for RelabelHandler { + type Input = RelabelCommand; + type Config = RelabelConfig; -impl Handler for LabelHandler { - fn handle_event(&self, event: &Event) -> Result<(), Error> { + fn parse_input(&self, ctx: &Context, event: &Event) -> Result, Error> { #[allow(irrefutable_let_patterns)] let event = if let Event::IssueComment(e) = event { e } else { // not interested in other events - return Ok(()); + return Ok(None); }; - let mut issue_labels = event.issue.labels().to_owned(); - - let mut input = Input::new(&event.comment.body, &self.username); - let deltas = match input.parse_command() { - Command::Label(Ok(LabelCommand(deltas))) => deltas, - Command::Label(Err(err)) => { - ErrorComment::new( - &event.issue, - format!( - "Parsing label command in [comment]({}) failed: {}", - event.comment.html_url, err - ), - ) - .post(&self.client)?; + let mut input = Input::new(&event.comment.body, &ctx.username); + match input.parse_command() { + Command::Relabel(Ok(command)) => Ok(Some(command)), + Command::Relabel(Err(err)) => { failure::bail!( - "label parsing failed for issue #{}, error: {:?}", - event.issue.number, - err + "Parsing label command in [comment]({}) failed: {}", + event.comment.html_url, err ); } - _ => return Ok(()), + _ => Ok(None), + } + } + + fn handle_input( + &self, + ctx: &Context, + config: &RelabelConfig, + event: &Event, + input: RelabelCommand, + ) -> Result<(), Error> { + #[allow(irrefutable_let_patterns)] + let event = if let Event::IssueComment(e) = event { + e + } else { + // not interested in other events + return Ok(()); }; + let mut issue_labels = event.issue.labels().to_owned(); let mut changed = false; - for delta in &deltas { + for delta in &input.0 { let name = delta.label().as_str(); - if let Err(msg) = check_filter(name, &event.comment.user, &self.client) { - ErrorComment::new(&event.issue, msg).post(&self.client)?; + if let Err(msg) = check_filter(name, config, &event.comment.user, &ctx.github) { + ErrorComment::new(&event.issue, msg.to_string()).post(&ctx.github)?; return Ok(()); } match delta { @@ -82,14 +88,19 @@ impl Handler for LabelHandler { } if changed { - event.issue.set_labels(&self.client, issue_labels)?; + event.issue.set_labels(&ctx.github, issue_labels)?; } Ok(()) } } -fn check_filter(label: &str, user: &github::User, client: &GithubClient) -> Result<(), String> { +fn check_filter( + label: &str, + config: &RelabelConfig, + user: &github::User, + client: &GithubClient, +) -> Result<(), Error> { let is_team_member; match user.is_team_member(client) { Ok(true) => return Ok(()), @@ -102,34 +113,19 @@ fn check_filter(label: &str, user: &github::User, client: &GithubClient) -> Resu // continue on; if we failed to check their membership assume that they are not members. } } - if label.starts_with("C-") // categories - || label.starts_with("A-") // areas - || label.starts_with("E-") // easy, mentor, etc. - || label.starts_with("NLL-") - || label.starts_with("O-") // operating systems - || label.starts_with("S-") // status labels - || label.starts_with("T-") - || label.starts_with("WG-") - { - return Ok(()); - } - match label { - "I-compilemem" | "I-compiletime" | "I-crash" | "I-hang" | "I-ICE" | "I-slow" => { + for pattern in &config.allow_unauthenticated { + let pattern = glob::Pattern::new(pattern)?; + if pattern.matches(label) { return Ok(()); } - _ => {} } - if is_team_member.is_ok() { - Err(format!( - "Label {} can only be set by Rust team members", - label - )) + failure::bail!("Label {} can only be set by Rust team members", label); } else { - Err(format!( + failure::bail!( "Label {} can only be set by Rust team members;\ we were unable to check if you are a team member.", label - )) + ); } } diff --git a/src/main.rs b/src/main.rs index d69dcfd0c..a7015cf67 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,41 +10,23 @@ use rocket::request; use rocket::State; use rocket::{http::Status, Outcome, Request}; use std::env; -use std::sync::Arc; - -mod handlers; -mod registry; +mod config; mod github; +mod handlers; mod interactions; mod payload; mod team; -use github::{Comment, GithubClient, Issue, User}; +use interactions::ErrorComment; use payload::SignedPayload; -use registry::HandleRegistry; - -#[derive(PartialEq, Eq, Debug, serde::Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum IssueCommentAction { - Created, - Edited, - Deleted, -} - -#[derive(Debug, serde::Deserialize)] -pub struct IssueCommentEvent { - action: IssueCommentAction, - issue: Issue, - comment: Comment, -} -enum Event { +enum EventName { IssueComment, Other, } -impl<'a, 'r> request::FromRequest<'a, 'r> for Event { +impl<'a, 'r> request::FromRequest<'a, 'r> for EventName { type Error = String; fn from_request(req: &'a Request<'r>) -> request::Outcome { let ev = if let Some(ev) = req.headers().get_one("X-GitHub-Event") { @@ -53,8 +35,8 @@ impl<'a, 'r> request::FromRequest<'a, 'r> for Event { return Outcome::Failure((Status::BadRequest, "Needs a X-GitHub-Event".into())); }; let ev = match ev { - "issue_comment" => Event::IssueComment, - _ => Event::Other, + "issue_comment" => EventName::IssueComment, + _ => EventName::Other, }; Outcome::Success(ev) } @@ -82,22 +64,27 @@ impl From for WebhookError { #[post("/github-hook", data = "")] fn webhook( - event: Event, + event: EventName, payload: SignedPayload, - reg: State, + ctx: State, ) -> Result<(), WebhookError> { match event { - Event::IssueComment => { + EventName::IssueComment => { let payload = payload - .deserialize::() + .deserialize::() .context("IssueCommentEvent failed to deserialize") .map_err(Error::from)?; - let event = registry::Event::IssueComment(payload); - reg.handle(&event).map_err(Error::from)?; + let event = github::Event::IssueComment(payload); + if let Err(err) = handlers::handle(&ctx, &event) { + if let Some(issue) = event.issue() { + ErrorComment::new(issue, err.to_string()).post(&ctx.github)?; + } + return Err(err.into()); + } } // Other events need not be handled - Event::Other => {} + EventName::Other => {} } Ok(()) } @@ -110,13 +97,14 @@ fn not_found(_: &Request) -> &'static str { fn main() { dotenv::dotenv().ok(); let client = Client::new(); - let gh = GithubClient::new( + let gh = github::GithubClient::new( client.clone(), env::var("GITHUB_API_TOKEN").expect("Missing GITHUB_API_TOKEN"), ); - let username = Arc::new(User::current(&gh).unwrap().login); - let mut registry = HandleRegistry::new(); - handlers::register_all(&mut registry, gh.clone(), username); + let ctx = handlers::Context { + github: gh.clone(), + username: github::User::current(&gh).unwrap().login, + }; let mut config = rocket::Config::active().unwrap(); config.set_port( @@ -126,7 +114,7 @@ fn main() { ); rocket::custom(config) .manage(gh) - .manage(registry) + .manage(ctx) .mount("/", routes![webhook]) .register(catchers![not_found]) .launch(); diff --git a/src/registry.rs b/src/registry.rs deleted file mode 100644 index 5785ef0e9..000000000 --- a/src/registry.rs +++ /dev/null @@ -1,45 +0,0 @@ -use crate::IssueCommentEvent; -use failure::Error; - -pub struct HandleRegistry { - handlers: Vec>, -} - -impl HandleRegistry { - pub fn new() -> HandleRegistry { - HandleRegistry { - handlers: Vec::new(), - } - } - - pub fn register(&mut self, h: H) { - self.handlers.push(Box::new(h)); - } - - pub fn handle(&self, event: &Event) -> Result<(), Error> { - let mut last_error = None; - for h in &self.handlers { - match h.handle_event(event) { - Ok(()) => {} - Err(e) => { - eprintln!("event handling failed: {:?}", e); - last_error = Some(e); - } - } - } - if let Some(err) = last_error { - Err(err) - } else { - Ok(()) - } - } -} - -#[derive(Debug)] -pub enum Event { - IssueComment(IssueCommentEvent), -} - -pub trait Handler: Sync + Send { - fn handle_event(&self, event: &Event) -> Result<(), Error>; -}