config.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. use crate::changelogs::ChangelogFormat;
  2. use crate::github::{GithubClient, Repository};
  3. use std::collections::{HashMap, HashSet};
  4. use std::fmt;
  5. use std::sync::{Arc, RwLock};
  6. use std::time::{Duration, Instant};
  7. use tracing as log;
  8. pub(crate) static CONFIG_FILE_NAME: &str = "triagebot.toml";
  9. const REFRESH_EVERY: Duration = Duration::from_secs(2 * 60); // Every two minutes
  10. lazy_static::lazy_static! {
  11. static ref CONFIG_CACHE:
  12. RwLock<HashMap<String, (Result<Arc<Config>, ConfigurationError>, Instant)>> =
  13. RwLock::new(HashMap::new());
  14. }
  15. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  16. #[serde(rename_all = "kebab-case")]
  17. #[serde(deny_unknown_fields)]
  18. pub(crate) struct Config {
  19. pub(crate) relabel: Option<RelabelConfig>,
  20. pub(crate) assign: Option<AssignConfig>,
  21. pub(crate) ping: Option<PingConfig>,
  22. pub(crate) nominate: Option<NominateConfig>,
  23. pub(crate) prioritize: Option<PrioritizeConfig>,
  24. pub(crate) major_change: Option<MajorChangeConfig>,
  25. pub(crate) glacier: Option<GlacierConfig>,
  26. pub(crate) close: Option<CloseConfig>,
  27. pub(crate) autolabel: Option<AutolabelConfig>,
  28. pub(crate) notify_zulip: Option<NotifyZulipConfig>,
  29. pub(crate) github_releases: Option<GitHubReleasesConfig>,
  30. pub(crate) review_submitted: Option<ReviewSubmittedConfig>,
  31. pub(crate) review_requested: Option<ReviewRequestedConfig>,
  32. pub(crate) shortcut: Option<ShortcutConfig>,
  33. pub(crate) note: Option<NoteConfig>,
  34. pub(crate) mentions: Option<MentionsConfig>,
  35. pub(crate) no_merges: Option<NoMergesConfig>,
  36. // We want this validation to run even without the entry in the config file
  37. #[serde(default = "ValidateConfig::default")]
  38. pub(crate) validate_config: Option<ValidateConfig>,
  39. }
  40. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  41. #[serde(deny_unknown_fields)]
  42. pub(crate) struct NominateConfig {
  43. // team name -> label
  44. pub(crate) teams: HashMap<String, String>,
  45. }
  46. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  47. pub(crate) struct PingConfig {
  48. // team name -> message
  49. // message will have the cc string appended
  50. #[serde(flatten)]
  51. teams: HashMap<String, PingTeamConfig>,
  52. }
  53. impl PingConfig {
  54. pub(crate) fn get_by_name(&self, team: &str) -> Option<(&str, &PingTeamConfig)> {
  55. if let Some((team, cfg)) = self.teams.get_key_value(team) {
  56. return Some((team, cfg));
  57. }
  58. for (name, cfg) in self.teams.iter() {
  59. if cfg.alias.contains(team) {
  60. return Some((name, cfg));
  61. }
  62. }
  63. None
  64. }
  65. }
  66. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  67. #[serde(deny_unknown_fields)]
  68. pub(crate) struct PingTeamConfig {
  69. pub(crate) message: String,
  70. #[serde(default)]
  71. pub(crate) alias: HashSet<String>,
  72. pub(crate) label: Option<String>,
  73. }
  74. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  75. #[serde(deny_unknown_fields)]
  76. pub(crate) struct AssignConfig {
  77. /// If `true`, then posts a warning comment if the PR is opened against a
  78. /// different branch than the default (usually master or main).
  79. #[serde(default)]
  80. pub(crate) warn_non_default_branch: bool,
  81. /// A URL to include in the welcome message.
  82. pub(crate) contributing_url: Option<String>,
  83. /// Ad-hoc groups that can be referred to in `owners`.
  84. #[serde(default)]
  85. pub(crate) adhoc_groups: HashMap<String, Vec<String>>,
  86. /// Users to assign when a new PR is opened.
  87. /// The key is a gitignore-style path, and the value is a list of
  88. /// usernames, team names, or ad-hoc groups.
  89. #[serde(default)]
  90. pub(crate) owners: HashMap<String, Vec<String>>,
  91. #[serde(default)]
  92. pub(crate) users_on_vacation: HashSet<String>,
  93. }
  94. impl AssignConfig {
  95. pub(crate) fn is_on_vacation(&self, user: &str) -> bool {
  96. let name_lower = user.to_lowercase();
  97. self.users_on_vacation
  98. .iter()
  99. .any(|vacationer| name_lower == vacationer.to_lowercase())
  100. }
  101. }
  102. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  103. #[serde(deny_unknown_fields)]
  104. pub(crate) struct NoMergesConfig {
  105. /// No action will be taken on PRs with these substrings in the title.
  106. #[serde(default)]
  107. pub(crate) exclude_titles: Vec<String>,
  108. /// Set these labels on the PR when merge commits are detected.
  109. #[serde(default)]
  110. pub(crate) labels: Vec<String>,
  111. /// Override the default message to post when merge commits are detected.
  112. ///
  113. /// This message will always be followed up with
  114. /// "The following commits are merge commits:" and then
  115. /// a list of the merge commits.
  116. pub(crate) message: Option<String>,
  117. }
  118. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  119. #[serde(deny_unknown_fields)]
  120. pub(crate) struct NoteConfig {
  121. #[serde(default)]
  122. _empty: (),
  123. }
  124. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  125. pub(crate) struct MentionsConfig {
  126. #[serde(flatten)]
  127. pub(crate) paths: HashMap<String, MentionsPathConfig>,
  128. }
  129. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  130. #[serde(deny_unknown_fields)]
  131. pub(crate) struct MentionsPathConfig {
  132. pub(crate) message: Option<String>,
  133. #[serde(default)]
  134. pub(crate) cc: Vec<String>,
  135. }
  136. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  137. #[serde(rename_all = "kebab-case")]
  138. #[serde(deny_unknown_fields)]
  139. pub(crate) struct RelabelConfig {
  140. #[serde(default)]
  141. pub(crate) allow_unauthenticated: Vec<String>,
  142. }
  143. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  144. #[serde(deny_unknown_fields)]
  145. pub(crate) struct ShortcutConfig {
  146. #[serde(default)]
  147. _empty: (),
  148. }
  149. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  150. #[serde(deny_unknown_fields)]
  151. pub(crate) struct PrioritizeConfig {
  152. pub(crate) label: String,
  153. }
  154. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  155. pub(crate) struct ValidateConfig {}
  156. impl ValidateConfig {
  157. fn default() -> Option<Self> {
  158. Some(ValidateConfig {})
  159. }
  160. }
  161. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  162. pub(crate) struct AutolabelConfig {
  163. #[serde(flatten)]
  164. pub(crate) labels: HashMap<String, AutolabelLabelConfig>,
  165. }
  166. impl AutolabelConfig {
  167. pub(crate) fn get_by_trigger(&self, trigger: &str) -> Vec<(&str, &AutolabelLabelConfig)> {
  168. let mut results = Vec::new();
  169. for (label, cfg) in self.labels.iter() {
  170. if cfg.trigger_labels.iter().any(|l| l == trigger) {
  171. results.push((label.as_str(), cfg));
  172. }
  173. }
  174. results
  175. }
  176. }
  177. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  178. #[serde(deny_unknown_fields)]
  179. pub(crate) struct AutolabelLabelConfig {
  180. #[serde(default)]
  181. pub(crate) trigger_labels: Vec<String>,
  182. #[serde(default)]
  183. pub(crate) exclude_labels: Vec<String>,
  184. #[serde(default)]
  185. pub(crate) trigger_files: Vec<String>,
  186. #[serde(default)]
  187. pub(crate) new_pr: bool,
  188. #[serde(default)]
  189. pub(crate) new_issue: bool,
  190. }
  191. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  192. pub(crate) struct NotifyZulipConfig {
  193. #[serde(flatten)]
  194. pub(crate) labels: HashMap<String, NotifyZulipLabelConfig>,
  195. }
  196. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  197. #[serde(deny_unknown_fields)]
  198. pub(crate) struct NotifyZulipLabelConfig {
  199. pub(crate) zulip_stream: u64,
  200. pub(crate) topic: String,
  201. pub(crate) message_on_add: Option<String>,
  202. pub(crate) message_on_remove: Option<String>,
  203. pub(crate) message_on_close: Option<String>,
  204. pub(crate) message_on_reopen: Option<String>,
  205. #[serde(default)]
  206. pub(crate) required_labels: Vec<String>,
  207. }
  208. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  209. #[serde(deny_unknown_fields)]
  210. pub(crate) struct MajorChangeConfig {
  211. /// A username (typically a group, e.g. T-lang) to ping on Zulip for newly
  212. /// opened proposals.
  213. pub(crate) zulip_ping: String,
  214. /// This label allows an issue to participate in the major change process
  215. /// (i.e., creates a Zulip thread, tracks seconding, etc.)
  216. // This has a default primarily for backwards compatibility.
  217. #[serde(default = "MajorChangeConfig::enabling_label_default")]
  218. pub(crate) enabling_label: String,
  219. /// This is the label applied when issuing a `@rustbot second` command, it
  220. /// indicates that the proposal has moved into the 10 day waiting period.
  221. pub(crate) second_label: String,
  222. /// This is the label applied after the waiting period has successfully
  223. /// elapsed (currently not automatically applied; this must be done
  224. /// manually).
  225. // This has a default primarily for backwards compatibility.
  226. #[serde(default = "MajorChangeConfig::accept_label_default")]
  227. pub(crate) accept_label: String,
  228. /// This is the label to be added to newly opened proposals, so they can be
  229. /// discussed in a meeting.
  230. pub(crate) meeting_label: String,
  231. pub(crate) zulip_stream: u64,
  232. pub(crate) open_extra_text: Option<String>,
  233. }
  234. impl MajorChangeConfig {
  235. fn enabling_label_default() -> String {
  236. String::from("major-change")
  237. }
  238. fn accept_label_default() -> String {
  239. String::from("major-change-accepted")
  240. }
  241. }
  242. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  243. #[serde(deny_unknown_fields)]
  244. pub(crate) struct GlacierConfig {}
  245. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  246. #[serde(deny_unknown_fields)]
  247. pub(crate) struct CloseConfig {}
  248. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  249. #[serde(deny_unknown_fields)]
  250. pub(crate) struct ReviewSubmittedConfig {
  251. pub(crate) review_labels: Vec<String>,
  252. pub(crate) reviewed_label: String,
  253. }
  254. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  255. #[serde(deny_unknown_fields)]
  256. pub(crate) struct ReviewRequestedConfig {
  257. pub(crate) remove_labels: Vec<String>,
  258. pub(crate) add_labels: Vec<String>,
  259. }
  260. pub(crate) async fn get(
  261. gh: &GithubClient,
  262. repo: &Repository,
  263. ) -> Result<Arc<Config>, ConfigurationError> {
  264. if let Some(config) = get_cached_config(&repo.full_name) {
  265. log::trace!("returning config for {} from cache", repo.full_name);
  266. config
  267. } else {
  268. log::trace!("fetching fresh config for {}", repo.full_name);
  269. let res = get_fresh_config(gh, repo).await;
  270. CONFIG_CACHE
  271. .write()
  272. .unwrap()
  273. .insert(repo.full_name.to_string(), (res.clone(), Instant::now()));
  274. res
  275. }
  276. }
  277. #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
  278. #[serde(rename_all = "kebab-case")]
  279. #[serde(deny_unknown_fields)]
  280. pub(crate) struct GitHubReleasesConfig {
  281. pub(crate) format: ChangelogFormat,
  282. pub(crate) project_name: String,
  283. pub(crate) changelog_path: String,
  284. pub(crate) changelog_branch: String,
  285. }
  286. fn get_cached_config(repo: &str) -> Option<Result<Arc<Config>, ConfigurationError>> {
  287. let cache = CONFIG_CACHE.read().unwrap();
  288. cache.get(repo).and_then(|(config, fetch_time)| {
  289. if fetch_time.elapsed() < REFRESH_EVERY {
  290. Some(config.clone())
  291. } else {
  292. None
  293. }
  294. })
  295. }
  296. async fn get_fresh_config(
  297. gh: &GithubClient,
  298. repo: &Repository,
  299. ) -> Result<Arc<Config>, ConfigurationError> {
  300. let contents = gh
  301. .raw_file(&repo.full_name, &repo.default_branch, CONFIG_FILE_NAME)
  302. .await
  303. .map_err(|e| ConfigurationError::Http(Arc::new(e)))?
  304. .ok_or(ConfigurationError::Missing)?;
  305. let contents = String::from_utf8_lossy(&*contents);
  306. let config = Arc::new(toml::from_str::<Config>(&contents).map_err(ConfigurationError::Toml)?);
  307. log::debug!("fresh configuration for {}: {:?}", repo.full_name, config);
  308. Ok(config)
  309. }
  310. #[derive(Clone, Debug)]
  311. pub enum ConfigurationError {
  312. Missing,
  313. Toml(toml::de::Error),
  314. Http(Arc<anyhow::Error>),
  315. }
  316. impl std::error::Error for ConfigurationError {}
  317. impl fmt::Display for ConfigurationError {
  318. fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
  319. match self {
  320. ConfigurationError::Missing => write!(
  321. f,
  322. "This repository is not enabled to use triagebot.\n\
  323. Add a `triagebot.toml` in the root of the default branch to enable it."
  324. ),
  325. ConfigurationError::Toml(e) => {
  326. write!(f, "Malformed `triagebot.toml` in default branch.\n{e}")
  327. }
  328. ConfigurationError::Http(e) => {
  329. write!(
  330. f,
  331. "Failed to query configuration for this repository.\n{e:?}"
  332. )
  333. }
  334. }
  335. }
  336. }
  337. #[cfg(test)]
  338. mod tests {
  339. use super::*;
  340. #[test]
  341. fn sample() {
  342. let config = r#"
  343. [relabel]
  344. allow-unauthenticated = [
  345. "C-*"
  346. ]
  347. [assign]
  348. users_on_vacation = ["jyn514"]
  349. [note]
  350. [ping.compiler]
  351. message = """\
  352. So many people!\
  353. """
  354. label = "T-compiler"
  355. [ping.wg-meta]
  356. message = """\
  357. Testing\
  358. """
  359. [nominate.teams]
  360. compiler = "T-compiler"
  361. release = "T-release"
  362. core = "T-core"
  363. infra = "T-infra"
  364. [shortcut]
  365. "#;
  366. let config = toml::from_str::<Config>(&config).unwrap();
  367. let mut ping_teams = HashMap::new();
  368. ping_teams.insert(
  369. "compiler".to_owned(),
  370. PingTeamConfig {
  371. message: "So many people!".to_owned(),
  372. label: Some("T-compiler".to_owned()),
  373. alias: HashSet::new(),
  374. },
  375. );
  376. ping_teams.insert(
  377. "wg-meta".to_owned(),
  378. PingTeamConfig {
  379. message: "Testing".to_owned(),
  380. label: None,
  381. alias: HashSet::new(),
  382. },
  383. );
  384. let mut nominate_teams = HashMap::new();
  385. nominate_teams.insert("compiler".to_owned(), "T-compiler".to_owned());
  386. nominate_teams.insert("release".to_owned(), "T-release".to_owned());
  387. nominate_teams.insert("core".to_owned(), "T-core".to_owned());
  388. nominate_teams.insert("infra".to_owned(), "T-infra".to_owned());
  389. assert_eq!(
  390. config,
  391. Config {
  392. relabel: Some(RelabelConfig {
  393. allow_unauthenticated: vec!["C-*".into()],
  394. }),
  395. assign: Some(AssignConfig {
  396. warn_non_default_branch: false,
  397. contributing_url: None,
  398. adhoc_groups: HashMap::new(),
  399. owners: HashMap::new(),
  400. users_on_vacation: HashSet::from(["jyn514".into()]),
  401. }),
  402. note: Some(NoteConfig { _empty: () }),
  403. ping: Some(PingConfig { teams: ping_teams }),
  404. nominate: Some(NominateConfig {
  405. teams: nominate_teams
  406. }),
  407. shortcut: Some(ShortcutConfig { _empty: () }),
  408. prioritize: None,
  409. major_change: None,
  410. glacier: None,
  411. close: None,
  412. autolabel: None,
  413. notify_zulip: None,
  414. github_releases: None,
  415. review_submitted: None,
  416. review_requested: None,
  417. mentions: None,
  418. no_merges: None,
  419. validate_config: Some(ValidateConfig {}),
  420. }
  421. );
  422. }
  423. }