use crate::changelogs::ChangelogFormat; use crate::github::GithubClient; use std::collections::{HashMap, HashSet}; use std::fmt; 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, ConfigurationError>, Instant)>> = RwLock::new(HashMap::new()); } #[derive(PartialEq, Eq, Debug, serde::Deserialize)] #[serde(rename_all = "kebab-case")] pub(crate) struct Config { pub(crate) relabel: Option, pub(crate) assign: Option, pub(crate) ping: Option, pub(crate) nominate: Option, pub(crate) prioritize: Option, pub(crate) major_change: Option, pub(crate) glacier: Option, pub(crate) close: Option, pub(crate) autolabel: Option, pub(crate) notify_zulip: Option, pub(crate) github_releases: Option, pub(crate) review_submitted: Option, pub(crate) shortcut: Option, } #[derive(PartialEq, Eq, Debug, serde::Deserialize)] pub(crate) struct NominateConfig { // team name -> label pub(crate) teams: HashMap, } #[derive(PartialEq, Eq, Debug, serde::Deserialize)] pub(crate) struct PingConfig { // team name -> message // message will have the cc string appended #[serde(flatten)] teams: HashMap, } impl PingConfig { pub(crate) fn get_by_name(&self, team: &str) -> Option<(&str, &PingTeamConfig)> { if let Some((team, cfg)) = self.teams.get_key_value(team) { return Some((team, cfg)); } for (name, cfg) in self.teams.iter() { if cfg.alias.contains(team) { return Some((name, cfg)); } } None } } #[derive(PartialEq, Eq, Debug, serde::Deserialize)] pub(crate) struct PingTeamConfig { pub(crate) message: String, #[serde(default)] pub(crate) alias: HashSet, pub(crate) label: Option, } #[derive(PartialEq, Eq, Debug, serde::Deserialize)] pub(crate) struct AssignConfig { #[serde(default)] _empty: (), } #[derive(PartialEq, Eq, Debug, serde::Deserialize)] #[serde(rename_all = "kebab-case")] pub(crate) struct RelabelConfig { #[serde(default)] pub(crate) allow_unauthenticated: Vec, } #[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, } #[derive(PartialEq, Eq, Debug, serde::Deserialize)] pub(crate) struct AutolabelConfig { #[serde(flatten)] pub(crate) labels: HashMap, } impl AutolabelConfig { pub(crate) fn get_by_trigger(&self, trigger: &str) -> Vec<(&str, &AutolabelLabelConfig)> { let mut results = Vec::new(); for (label, cfg) in self.labels.iter() { if cfg.trigger_labels.iter().any(|l| l == trigger) { results.push((label.as_str(), cfg)); } } results } } #[derive(PartialEq, Eq, Debug, serde::Deserialize)] pub(crate) struct AutolabelLabelConfig { #[serde(default)] pub(crate) trigger_labels: Vec, #[serde(default)] pub(crate) exclude_labels: Vec, #[serde(default)] pub(crate) trigger_files: Vec, } #[derive(PartialEq, Eq, Debug, serde::Deserialize)] pub(crate) struct NotifyZulipConfig { #[serde(flatten)] pub(crate) labels: HashMap, } #[derive(PartialEq, Eq, Debug, serde::Deserialize)] pub(crate) struct NotifyZulipLabelConfig { pub(crate) zulip_stream: u64, pub(crate) topic: String, pub(crate) message_on_add: Option, pub(crate) message_on_remove: Option, pub(crate) message_on_close: Option, pub(crate) message_on_reopen: Option, #[serde(default)] pub(crate) required_labels: Vec, } #[derive(PartialEq, Eq, Debug, serde::Deserialize)] pub(crate) struct MajorChangeConfig { pub(crate) zulip_ping: String, pub(crate) second_label: String, pub(crate) meeting_label: String, pub(crate) zulip_stream: u64, pub(crate) open_extra_text: Option, } #[derive(PartialEq, Eq, Debug, serde::Deserialize)] pub(crate) struct GlacierConfig {} #[derive(PartialEq, Eq, Debug, serde::Deserialize)] pub(crate) struct CloseConfig {} #[derive(PartialEq, Eq, Debug, serde::Deserialize)] pub(crate) struct ReviewSubmittedConfig { pub(crate) review_labels: Vec, pub(crate) reviewed_label: String, } pub(crate) async fn get(gh: &GithubClient, repo: &str) -> Result, ConfigurationError> { if let Some(config) = get_cached_config(repo) { log::trace!("returning config for {} from cache", repo); config } else { log::trace!("fetching fresh config for {}", repo); let res = get_fresh_config(gh, repo).await; CONFIG_CACHE .write() .unwrap() .insert(repo.to_string(), (res.clone(), Instant::now())); res } } #[derive(PartialEq, Eq, Debug, serde::Deserialize)] #[serde(rename_all = "kebab-case")] pub(crate) struct GitHubReleasesConfig { pub(crate) format: ChangelogFormat, pub(crate) project_name: String, pub(crate) changelog_path: String, pub(crate) changelog_branch: String, } fn get_cached_config(repo: &str) -> Option, ConfigurationError>> { 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 } }) } async fn get_fresh_config( gh: &GithubClient, repo: &str, ) -> Result, ConfigurationError> { let contents = gh .raw_file(repo, "master", CONFIG_FILE_NAME) .await .map_err(|e| ConfigurationError::Http(Arc::new(e)))? .ok_or(ConfigurationError::Missing)?; let config = Arc::new(toml::from_slice::(&contents).map_err(ConfigurationError::Toml)?); log::debug!("fresh configuration for {}: {:?}", repo, config); Ok(config) } #[derive(Clone, Debug)] pub enum ConfigurationError { Missing, Toml(toml::de::Error), Http(Arc), } impl std::error::Error for ConfigurationError {} impl fmt::Display for ConfigurationError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { ConfigurationError::Missing => write!( f, "This repository is not enabled to use triagebot.\n\ Add a `triagebot.toml` in the root of the master branch to enable it." ), ConfigurationError::Toml(e) => { write!(f, "Malformed `triagebot.toml` in master branch.\n{}", e) } ConfigurationError::Http(_) => { write!(f, "Failed to query configuration for this repository.") } } } } #[cfg(test)] mod tests { use super::*; #[test] fn sample() { let config = r#" [relabel] allow-unauthenticated = [ "C-*" ] [assign] [ping.compiler] message = """\ So many people!\ """ label = "T-compiler" [ping.wg-meta] message = """\ Testing\ """ [nominate.teams] compiler = "T-compiler" release = "T-release" core = "T-core" infra = "T-infra" [shortcut] "#; let config = toml::from_str::(&config).unwrap(); let mut ping_teams = HashMap::new(); ping_teams.insert( "compiler".to_owned(), PingTeamConfig { message: "So many people!".to_owned(), label: Some("T-compiler".to_owned()), alias: HashSet::new(), }, ); ping_teams.insert( "wg-meta".to_owned(), PingTeamConfig { message: "Testing".to_owned(), label: None, alias: HashSet::new(), }, ); let mut nominate_teams = HashMap::new(); nominate_teams.insert("compiler".to_owned(), "T-compiler".to_owned()); nominate_teams.insert("release".to_owned(), "T-release".to_owned()); nominate_teams.insert("core".to_owned(), "T-core".to_owned()); nominate_teams.insert("infra".to_owned(), "T-infra".to_owned()); assert_eq!( config, Config { relabel: Some(RelabelConfig { allow_unauthenticated: vec!["C-*".into()], }), assign: Some(AssignConfig { _empty: () }), ping: Some(PingConfig { teams: ping_teams }), nominate: Some(NominateConfig { teams: nominate_teams }), shortcut: Some(ShortcutConfig { _empty: () }), prioritize: None, major_change: None, glacier: None, close: None, autolabel: None, notify_zulip: None, github_releases: None, review_submitted: None, } ); } }