diff --git a/Cargo.lock b/Cargo.lock index 83079628..65eed7f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,7 +99,7 @@ checksum = "ed6aa3524a2dfcf9fe180c51eae2b58738348d819517ceadf95789c51fff7600" dependencies = [ "proc-macro2", "quote", - "syn 1.0.91", + "syn 1.0.99", ] [[package]] @@ -235,7 +235,7 @@ checksum = "a7ec4c6f261935ad534c0c22dbef2201b45918860eb1c574b972bd213a76af61" dependencies = [ "proc-macro2", "quote", - "syn 1.0.91", + "syn 1.0.99", ] [[package]] @@ -407,7 +407,7 @@ dependencies = [ "quote", "rkyv", "strsim", - "syn 1.0.91", + "syn 1.0.99", "thiserror", ] @@ -419,7 +419,7 @@ checksum = "4a933ea1f357cbd48f2068c59457631696ae58d554f89290e4da272b1f69ebf1" dependencies = [ "cynic-codegen", "quote", - "syn 1.0.91", + "syn 1.0.99", ] [[package]] @@ -443,7 +443,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 1.0.91", + "syn 1.0.99", ] [[package]] @@ -454,7 +454,7 @@ checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" dependencies = [ "darling_core", "quote", - "syn 1.0.91", + "syn 1.0.99", ] [[package]] @@ -487,6 +487,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2696e8a945f658fd14dc3b87242e6b80cd0f36ff04ea560fa39082368847946" +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.8.1" @@ -566,6 +572,26 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "factori" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ff6b50917609e530c145de1c6aa8df9c38c40562375e8aa5eeaaf6c737a0b31" +dependencies = [ + "factori-impl", +] + +[[package]] +name = "factori-impl" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d6344ded92b0a4a1d90a816632f7ff2a12e01401d325d6295810dacca1dbdd6" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.99", +] + [[package]] name = "fake-simd" version = "0.1.2" @@ -685,7 +711,7 @@ checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512" dependencies = [ "proc-macro2", "quote", - "syn 1.0.91", + "syn 1.0.99", ] [[package]] @@ -1409,7 +1435,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 1.0.91", + "syn 1.0.99", ] [[package]] @@ -1442,8 +1468,10 @@ name = "parser" version = "0.1.0" dependencies = [ "log", + "postgres-types", "pulldown-cmark", "regex", + "serde", ] [[package]] @@ -1499,7 +1527,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 1.0.91", + "syn 1.0.99", ] [[package]] @@ -1571,13 +1599,13 @@ checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" [[package]] name = "postgres-derive" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0c2c18e40b92144b05e6f3ae9d1ee931f0d1afa9410ac8b97486c6eaaf91201" +checksum = "9e76c801e97c9cf696097369e517785b98056e98b21149384c812febfc5912f2" dependencies = [ "proc-macro2", "quote", - "syn 1.0.91", + "syn 1.0.99", ] [[package]] @@ -1633,6 +1661,16 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" +[[package]] +name = "pretty_assertions" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -1642,7 +1680,7 @@ dependencies = [ "proc-macro-error-attr", "proc-macro2", "quote", - "syn 1.0.91", + "syn 1.0.99", "version_check", ] @@ -1683,7 +1721,7 @@ checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" dependencies = [ "proc-macro2", "quote", - "syn 1.0.91", + "syn 1.0.99", ] [[package]] @@ -1904,7 +1942,7 @@ checksum = "b2e06b915b5c230a17d7a736d1e2e63ee753c256a8614ef3f5147b13a4f5541d" dependencies = [ "proc-macro2", "quote", - "syn 1.0.91", + "syn 1.0.99", ] [[package]] @@ -2205,7 +2243,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 1.0.91", + "syn 1.0.99", ] [[package]] @@ -2264,13 +2302,13 @@ checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "syn" -version = "1.0.91" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b683b2b825c8eef438b77c36a06dc262294da3d5a5813fac20da149241dcd44d" +checksum = "58dbef6ec655055e20b86b15a8cc6d439cca19b667537ac6a1369572d151ab13" dependencies = [ "proc-macro2", "quote", - "unicode-xid", + "unicode-ident", ] [[package]] @@ -2337,7 +2375,7 @@ checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b" dependencies = [ "proc-macro2", "quote", - "syn 1.0.91", + "syn 1.0.99", ] [[package]] @@ -2448,7 +2486,7 @@ checksum = "b557f72f448c511a979e2564e55d74e6c4432fc96ff4f6241bc6bded342643b7" dependencies = [ "proc-macro2", "quote", - "syn 1.0.91", + "syn 1.0.99", ] [[package]] @@ -2679,6 +2717,7 @@ dependencies = [ "cron", "cynic", "dotenv", + "factori", "futures", "github-graphql", "glob", @@ -2694,6 +2733,7 @@ dependencies = [ "parser", "postgres-native-tls", "postgres-types", + "pretty_assertions", "rand", "regex", "reqwest", @@ -2840,12 +2880,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" -[[package]] -name = "unicode-xid" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" - [[package]] name = "unicode_categories" version = "0.1.1" @@ -2973,7 +3007,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 1.0.91", + "syn 1.0.99", "wasm-bindgen-shared", ] @@ -3007,7 +3041,7 @@ checksum = "99ec0dc7a4756fffc231aab1b9f2f578d23cd391390ab27f952ae0c9b3ece20b" dependencies = [ "proc-macro2", "quote", - "syn 1.0.91", + "syn 1.0.99", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3247,6 +3281,12 @@ dependencies = [ "dirs", ] +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + [[package]] name = "zeroize" version = "1.7.0" diff --git a/Cargo.toml b/Cargo.toml index 7803a91a..d7174a77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,5 +55,9 @@ features = ["derive"] version = "1.3.1" default-features = false +[dev-dependencies] +pretty_assertions = "1.2" +factori = "1.1.0" + [profile.release] debug = 2 diff --git a/parser/Cargo.toml b/parser/Cargo.toml index 22bf0aaf..d73d4aa9 100644 --- a/parser/Cargo.toml +++ b/parser/Cargo.toml @@ -8,3 +8,8 @@ edition = "2021" pulldown-cmark = "0.7.0" log = "0.4" regex = "1.6.0" +postgres-types = { version = "0.2.4", features = ["derive"] } + +[dependencies.serde] +version = "1" +features = ["derive"] diff --git a/parser/src/command.rs b/parser/src/command.rs index 2e645863..a48dcd7c 100644 --- a/parser/src/command.rs +++ b/parser/src/command.rs @@ -5,6 +5,7 @@ use regex::Regex; pub mod assign; pub mod close; +pub mod decision; pub mod glacier; pub mod nominate; pub mod note; @@ -28,6 +29,7 @@ pub enum Command<'a> { Close(Result>), Note(Result>), Transfer(Result>), + Decision(Result>), } #[derive(Debug)] @@ -139,6 +141,11 @@ impl<'a> Input<'a> { Command::Transfer, &original_tokenizer, )); + success.extend(parse_single_command( + decision::DecisionCommand::parse, + Command::Decision, + &original_tokenizer, + )); if success.len() > 1 { panic!( @@ -215,6 +222,7 @@ impl<'a> Command<'a> { Command::Close(r) => r.is_ok(), Command::Note(r) => r.is_ok(), Command::Transfer(r) => r.is_ok(), + Command::Decision(r) => r.is_ok(), } } diff --git a/parser/src/command/decision.rs b/parser/src/command/decision.rs new file mode 100644 index 00000000..2002c928 --- /dev/null +++ b/parser/src/command/decision.rs @@ -0,0 +1,220 @@ +//! The decision process command parser. +//! +//! This can parse arbitrary input, giving the command with which we would like +//! to vote that will potentially change the issue in its resolution, +//! reversibility and/or more. +//! +//! In the first one, we must also assign a valid team to the issue decision process. +//! +//! The grammar is as follows: +//! +//! ```text +//! Command: `@bot merge`, `@bot hold`, `@bot close` +//! +//! First comment: `@bot merge lang`, `@bot hold lang` +//! ``` + +use std::fmt; + +use crate::error::Error; +use crate::token::{Token, Tokenizer}; +use postgres_types::{FromSql, ToSql}; +use serde::{Deserialize, Serialize}; + +/// A command as parsed and received from calling the bot with some arguments, +/// like `@rustbot merge` +#[derive(Debug, Eq, PartialEq)] +pub struct DecisionCommand { + pub resolution: Resolution, + pub reversibility: Reversibility, + pub team: Option, +} + +impl DecisionCommand { + pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result, Error<'a>> { + let mut toks = input.clone(); + + match toks.peek_token()? { + Some(Token::Word("merge")) => { + toks.next_token()?; + + let team: Option = get_team(&mut toks)?; + + command_or_error( + input, + &mut toks, + Self { + resolution: Resolution::Merge, + reversibility: Reversibility::Reversible, + team, + }, + ) + } + Some(Token::Word("hold")) => { + toks.next_token()?; + + let team: Option = get_team(&mut toks)?; + + command_or_error( + input, + &mut toks, + Self { + resolution: Resolution::Hold, + reversibility: Reversibility::Reversible, + team, + }, + ) + } + _ => Ok(None), + } + } +} + +fn get_team<'a>(toks: &mut Tokenizer<'a>) -> Result, Error<'a>> { + match toks.peek_token()? { + Some(Token::Word(team)) => { + toks.next_token()?; + + Ok(Some(team.to_string())) + } + _ => Ok(None), + } +} + +fn command_or_error<'a>( + input: &mut Tokenizer<'a>, + toks: &mut Tokenizer<'a>, + command: DecisionCommand, +) -> Result, Error<'a>> { + if let Some(Token::Dot) | Some(Token::EndOfLine) = toks.peek_token()? { + *input = toks.clone(); + Ok(Some(command)) + } else { + Err(toks.error(ParseError::ExpectedEnd)) + } +} + +#[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"), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, ToSql, FromSql, Eq, PartialEq)] +#[postgres(name = "reversibility")] +pub enum Reversibility { + #[postgres(name = "reversible")] + Reversible, + #[postgres(name = "irreversible")] + Irreversible, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, ToSql, FromSql, Eq, PartialEq)] +#[postgres(name = "resolution")] +pub enum Resolution { + #[postgres(name = "merge")] + Merge, + #[postgres(name = "hold")] + Hold, +} + +impl fmt::Display for Resolution { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Resolution::Merge => write!(f, "merge"), + Resolution::Hold => write!(f, "hold"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse<'a>(input: &'a str) -> Result, Error<'a>> { + let mut toks = Tokenizer::new(input); + Ok(DecisionCommand::parse(&mut toks)?) + } + + #[test] + fn test_correct_merge() { + assert_eq!( + parse("merge"), + Ok(Some(DecisionCommand { + resolution: Resolution::Merge, + reversibility: Reversibility::Reversible, + team: None + })), + ); + } + + #[test] + fn test_correct_merge_final_dot() { + assert_eq!( + parse("merge."), + Ok(Some(DecisionCommand { + resolution: Resolution::Merge, + reversibility: Reversibility::Reversible, + team: None + })), + ); + } + + #[test] + fn test_correct_hold() { + assert_eq!( + parse("hold"), + Ok(Some(DecisionCommand { + resolution: Resolution::Hold, + reversibility: Reversibility::Reversible, + team: None + })), + ); + } + + #[test] + fn test_expected_end() { + use std::error::Error; + assert_eq!( + parse("hold lang beer") + .unwrap_err() + .source() + .unwrap() + .downcast_ref(), + Some(&ParseError::ExpectedEnd), + ); + } + + #[test] + fn test_correct_merge_with_team() { + assert_eq!( + parse("merge lang"), + Ok(Some(DecisionCommand { + resolution: Resolution::Merge, + reversibility: Reversibility::Reversible, + team: Some("lang".to_string()) + })), + ); + } + + #[test] + fn test_correct_hold_with_team() { + assert_eq!( + parse("hold lang"), + Ok(Some(DecisionCommand { + resolution: Resolution::Hold, + reversibility: Reversibility::Reversible, + team: Some("lang".to_string()) + })), + ); + } +} diff --git a/src/config.rs b/src/config.rs index 523836fb..5d2ce90f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -40,6 +40,7 @@ pub(crate) struct Config { pub(crate) note: Option, pub(crate) mentions: Option, pub(crate) no_merges: Option, + pub(crate) decision: Option, // We want this validation to run even without the entry in the config file #[serde(default = "ValidateConfig::default")] pub(crate) validate_config: Option, @@ -345,6 +346,12 @@ pub(crate) struct ReviewPrefsConfig { _empty: (), } +#[derive(PartialEq, Eq, Debug, serde::Deserialize)] +pub(crate) struct DecisionConfig { + #[serde(default)] + _empty: (), +} + #[derive(PartialEq, Eq, Debug, serde::Deserialize)] #[serde(rename_all = "kebab-case")] #[serde(deny_unknown_fields)] @@ -470,6 +477,8 @@ mod tests { infra = "T-infra" [shortcut] + + [decision] "#; let config = toml::from_str::(&config).unwrap(); let mut ping_teams = HashMap::new(); @@ -524,6 +533,7 @@ mod tests { review_requested: None, mentions: None, no_merges: None, + decision: Some(DecisionConfig { _empty: () }), validate_config: Some(ValidateConfig {}), pr_tracking: None, transfer: None, diff --git a/src/db.rs b/src/db.rs index 1da96372..c3eb7e6d 100644 --- a/src/db.rs +++ b/src/db.rs @@ -8,6 +8,7 @@ use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use tokio_postgres::Client as DbClient; pub mod issue_data; +pub mod issue_decision_state; pub mod jobs; pub mod notifications; pub mod rustc_commits; @@ -334,5 +335,21 @@ CREATE table review_prefs ( " CREATE EXTENSION intarray; CREATE UNIQUE INDEX review_prefs_user_id ON review_prefs(user_id); - ", +", + " +CREATE TYPE reversibility AS ENUM ('reversible', 'irreversible'); +", + " +CREATE TYPE resolution AS ENUM ('hold', 'merge'); +", + "CREATE TABLE issue_decision_state ( + issue_id BIGINT PRIMARY KEY, + initiator TEXT NOT NULL, + start_date TIMESTAMP WITH TIME ZONE NOT NULL, + end_date TIMESTAMP WITH TIME ZONE NOT NULL, + current JSONB NOT NULL, + history JSONB, + reversibility reversibility NOT NULL DEFAULT 'reversible', + resolution resolution NOT NULL DEFAULT 'merge' +);", ]; diff --git a/src/db/issue_decision_state.rs b/src/db/issue_decision_state.rs new file mode 100644 index 00000000..af673c3d --- /dev/null +++ b/src/db/issue_decision_state.rs @@ -0,0 +1,117 @@ +//! The issue decision state table provides a way to store +//! the decision process state of each issue + +use anyhow::{Context as _, Result}; +use chrono::{DateTime, Utc}; +use parser::command::decision::{Resolution, Reversibility}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use tokio_postgres::Client as DbClient; + +#[derive(Debug, Serialize, Deserialize)] +pub struct IssueDecisionState { + pub issue_id: i64, + pub initiator: String, + pub start_date: DateTime, + pub end_date: DateTime, + pub current: BTreeMap>, + pub history: BTreeMap>, + pub reversibility: Reversibility, + pub resolution: Resolution, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UserStatus { + pub comment_id: String, + pub text: String, + pub reversibility: Reversibility, + pub resolution: Resolution, +} + +pub async fn insert_issue_decision_state( + db: &DbClient, + issue_number: &u64, + initiator: &String, + start_date: &DateTime, + end_date: &DateTime, + current: &BTreeMap>, + history: &BTreeMap>, + reversibility: &Reversibility, + resolution: &Resolution, +) -> Result<()> { + tracing::trace!("insert_issue_decision_state(issue_id={})", issue_number); + let issue_id = *issue_number as i64; + + db.execute( + "INSERT INTO issue_decision_state (issue_id, initiator, start_date, end_date, current, history, reversibility, resolution) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT DO NOTHING", + &[&issue_id, &initiator, &start_date, &end_date, &serde_json::to_value(current).unwrap(), &serde_json::to_value(history).unwrap(), &reversibility, &resolution], + ) + .await + .context("Inserting decision state")?; + + Ok(()) +} + +pub async fn update_issue_decision_state( + db: &DbClient, + issue_number: &u64, + end_date: &DateTime, + current: &BTreeMap, + history: &BTreeMap>, + reversibility: &Reversibility, + resolution: &Resolution, +) -> Result<()> { + tracing::trace!("update_issue_decision_state(issue_id={})", issue_number); + let issue_id = *issue_number as i64; + + db.execute("UPDATE issue_decision_state SET end_date = $2, current = $3, history = $4, reversibility = $5, resolution = $6 WHERE issue_id = $1", + &[&issue_id, &end_date, &serde_json::to_value(current).unwrap(), &serde_json::to_value(history).unwrap(), &reversibility, &resolution] + ) + .await + .context("Updating decision state")?; + + Ok(()) +} + +pub async fn get_issue_decision_state( + db: &DbClient, + issue_number: &u64, +) -> Result { + tracing::trace!("get_issue_decision_state(issue_id={})", issue_number); + let issue_id = *issue_number as i64; + + let state = db + .query_one( + "SELECT * FROM issue_decision_state WHERE issue_id = $1", + &[&issue_id], + ) + .await + .context("Getting decision state data")?; + + deserialize_issue_decision_state(&state) +} + +fn deserialize_issue_decision_state(row: &tokio_postgres::row::Row) -> Result { + let issue_id: i64 = row.try_get(0)?; + let initiator: String = row.try_get(1)?; + let start_date: DateTime = row.try_get(2)?; + let end_date: DateTime = row.try_get(3)?; + let current: BTreeMap> = + serde_json::from_value(row.try_get(4).unwrap())?; + let history: BTreeMap> = + serde_json::from_value(row.try_get(5).unwrap())?; + let reversibility: Reversibility = row.try_get(6)?; + let resolution: Resolution = row.try_get(7)?; + + Ok(IssueDecisionState { + issue_id, + initiator, + start_date, + end_date, + current, + history, + reversibility, + resolution, + }) +} diff --git a/src/github.rs b/src/github.rs index 12b93d77..9fe6c343 100644 --- a/src/github.rs +++ b/src/github.rs @@ -2204,7 +2204,7 @@ impl GithubClient { response.text().await.context("raw gist from url") } - fn get(&self, url: &str) -> RequestBuilder { + pub 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 0747a555..d15510f3 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -26,6 +26,7 @@ impl fmt::Display for HandlerError { mod assign; mod autolabel; mod close; +mod decision; pub mod docs_update; mod github_releases; mod glacier; @@ -294,6 +295,7 @@ command_handlers! { close: Close, note: Note, transfer: Transfer, + decision: Decision, } pub struct Context { diff --git a/src/handlers/decision.rs b/src/handlers/decision.rs new file mode 100644 index 00000000..f6e30a1a --- /dev/null +++ b/src/handlers/decision.rs @@ -0,0 +1,342 @@ +use crate::github; +use crate::{ + config::DecisionConfig, db::issue_decision_state::*, github::Event, handlers::Context, + interactions::ErrorComment, +}; +use anyhow::bail; +use anyhow::Context as Ctx; +use chrono::{DateTime, Duration, Utc}; +use parser::command::decision::Resolution; +use parser::command::decision::*; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +pub const _DECISION_PROCESS_JOB_NAME: &str = "decision_process_action"; + +#[derive(Serialize, Deserialize)] +pub struct DecisionProcessActionMetadata { + pub message: String, + pub get_issue_url: String, + pub status: Resolution, +} + +pub(super) async fn handle_command( + ctx: &Context, + _config: &DecisionConfig, + event: &Event, + cmd: DecisionCommand, +) -> anyhow::Result<()> { + let db = ctx.db.get().await; + + let DecisionCommand { + resolution, + reversibility, + team: team_name, + } = cmd; + + let issue = event.issue().unwrap(); + let user = event.user(); + + match get_issue_decision_state(&db, &issue.number).await { + Ok(_state) => { + // TO DO + let cmnt = ErrorComment::new( + &issue, + "We don't support having more than one vote yet. Coming soon :)", + ); + cmnt.post(&ctx.github).await?; + + Ok(()) + } + _ => { + let is_team_member = user.is_team_member(&ctx.github).await.unwrap_or(false); + if !is_team_member { + let cmnt = ErrorComment::new( + &issue, + "Only team members can be part of the decision process.", + ); + cmnt.post(&ctx.github).await?; + + return Ok(()); + } + + match team_name { + None => { + let cmnt = ErrorComment::new( + &issue, + "In the first vote, is necessary to specify the team name that will be involved in the decision process.", + ); + cmnt.post(&ctx.github).await?; + + Ok(()) + } + Some(team_name) => { + match github::get_team(&ctx.github, &team_name).await { + Ok(Some(team)) => { + let start_date: DateTime = chrono::Utc::now().into(); + let end_date: DateTime = + start_date.checked_add_signed(Duration::days(10)).unwrap(); + + let mut current: BTreeMap> = BTreeMap::new(); + let mut history: BTreeMap> = BTreeMap::new(); + + // Add team members to current and history + for member in team.members { + current.insert(member.github.clone(), None); + history.insert(member.github.clone(), Vec::new()); + } + + // Add issue user to current and history + current.insert( + user.login.clone(), + Some(UserStatus { + comment_id: event.html_url().unwrap().to_string(), + text: event.comment_body().unwrap().to_string(), + reversibility: reversibility, + resolution: resolution, + }), + ); + history.insert(user.login.clone(), Vec::new()); + + // Initialize issue decision state + insert_issue_decision_state( + &db, + &issue.number, + &user.login, + &start_date, + &end_date, + ¤t, + &history, + &reversibility, + &resolution, + ) + .await?; + + // TO DO -- Do not insert this job until we support more votes + // let metadata = serde_json::value::to_value(DecisionProcessActionMetadata { + // message: "some message".to_string(), + // get_issue_url: format!("{}/issues/{}", issue.repository().url(), issue.number), + // status: resolution, + // }) + // .unwrap(); + // insert_job( + // &db, + // &DECISION_PROCESS_JOB_NAME.to_string(), + // &end_date, + // &metadata, + // ) + // .await?; + + let comment = build_status_comment(&history, ¤t)?; + issue + .post_comment(&ctx.github, &comment) + .await + .context("merge vote comment")?; + + Ok(()) + } + _ => { + let cmnt = + ErrorComment::new(&issue, "Failed to resolve to a known team."); + cmnt.post(&ctx.github).await?; + + Ok(()) + } + } + } + } + } + } +} + +fn build_status_comment( + history: &BTreeMap>, + current: &BTreeMap>, +) -> anyhow::Result { + let mut comment = "| Team member | State |\n|-------------|-------|".to_owned(); + for (user, status) in current { + let mut user_statuses = format!("\n| @{} |", user); + + // previous stasuses + match history.get(user) { + Some(statuses) => { + for status in statuses { + let status_item = + format!(" [~~{}~~]({}) ", status.resolution, status.comment_id); + user_statuses.push_str(&status_item); + } + } + None => bail!("user {} not present in history statuses list", user), + } + + // current status + let user_resolution = match status { + Some(status) => format!("[**{}**]({})", status.resolution, status.comment_id), + _ => "".to_string(), + }; + + let status_item = format!(" {} |", user_resolution); + user_statuses.push_str(&status_item); + + comment.push_str(&user_statuses); + } + + Ok(comment) +} + +#[cfg(test)] +mod tests { + use super::*; + use factori::{create, factori}; + + factori!(UserStatus, { + default { + comment_id = "https://some-comment-id-for-merge.com".to_string(), + text = "this is my argument for making this decision".to_string(), + reversibility = Reversibility::Reversible, + resolution = Resolution::Merge + } + + mixin hold { + comment_id = "https://some-comment-id-for-hold.com".to_string(), + resolution = Resolution::Hold + } + }); + + #[test] + fn test_successfuly_build_comment() { + let mut history: BTreeMap> = BTreeMap::new(); + let mut current_statuses: BTreeMap> = BTreeMap::new(); + + // user 1 + let mut user_1_statuses: Vec = Vec::new(); + user_1_statuses.push(create!(UserStatus)); + user_1_statuses.push(create!(UserStatus, :hold)); + + history.insert("Niklaus".to_string(), user_1_statuses); + + current_statuses.insert("Niklaus".to_string(), Some(create!(UserStatus))); + + // user 2 + let mut user_2_statuses: Vec = Vec::new(); + user_2_statuses.push(create!(UserStatus, :hold)); + user_2_statuses.push(create!(UserStatus)); + + history.insert("Barbara".to_string(), user_2_statuses); + + current_statuses.insert("Barbara".to_string(), Some(create!(UserStatus))); + + let build_result = build_status_comment(&history, ¤t_statuses) + .expect("it shouldn't fail building the message"); + let expected_comment = "| Team member | State |\n\ + |-------------|-------|\n\ + | @Barbara | [~~hold~~](https://some-comment-id-for-hold.com) [~~merge~~](https://some-comment-id-for-merge.com) [**merge**](https://some-comment-id-for-merge.com) |\n\ + | @Niklaus | [~~merge~~](https://some-comment-id-for-merge.com) [~~hold~~](https://some-comment-id-for-hold.com) [**merge**](https://some-comment-id-for-merge.com) |" + .to_string(); + + assert_eq!(build_result, expected_comment); + } + + #[test] + fn test_successfuly_build_comment_user_no_votes() { + let mut history: BTreeMap> = BTreeMap::new(); + let mut current_statuses: BTreeMap> = BTreeMap::new(); + + // user 1 + let mut user_1_statuses: Vec = Vec::new(); + user_1_statuses.push(create!(UserStatus)); + user_1_statuses.push(create!(UserStatus, :hold)); + + history.insert("Niklaus".to_string(), user_1_statuses); + + current_statuses.insert("Niklaus".to_string(), Some(create!(UserStatus))); + + // user 2 + let mut user_2_statuses: Vec = Vec::new(); + user_2_statuses.push(create!(UserStatus, :hold)); + user_2_statuses.push(create!(UserStatus)); + + history.insert("Barbara".to_string(), user_2_statuses); + + current_statuses.insert("Barbara".to_string(), Some(create!(UserStatus))); + + // user 3 + history.insert("Tom".to_string(), Vec::new()); + + current_statuses.insert("Tom".to_string(), None); + + let build_result = build_status_comment(&history, ¤t_statuses) + .expect("it shouldn't fail building the message"); + let expected_comment = "| Team member | State |\n\ + |-------------|-------|\n\ + | @Barbara | [~~hold~~](https://some-comment-id-for-hold.com) [~~merge~~](https://some-comment-id-for-merge.com) [**merge**](https://some-comment-id-for-merge.com) |\n\ + | @Niklaus | [~~merge~~](https://some-comment-id-for-merge.com) [~~hold~~](https://some-comment-id-for-hold.com) [**merge**](https://some-comment-id-for-merge.com) |\n\ + | @Tom | |" + .to_string(); + + assert_eq!(build_result, expected_comment); + } + + #[test] + fn test_build_comment_inconsistent_users() { + let mut history: BTreeMap> = BTreeMap::new(); + let mut current_statuses: BTreeMap> = BTreeMap::new(); + + // user 1 + let mut user_1_statuses: Vec = Vec::new(); + user_1_statuses.push(create!(UserStatus)); + user_1_statuses.push(create!(UserStatus, :hold)); + + history.insert("Niklaus".to_string(), user_1_statuses); + + current_statuses.insert("Niklaus".to_string(), Some(create!(UserStatus))); + + // user 2 + let mut user_2_statuses: Vec = Vec::new(); + user_2_statuses.push(create!(UserStatus, :hold)); + user_2_statuses.push(create!(UserStatus)); + + history.insert("Barbara".to_string(), user_2_statuses); + + current_statuses.insert("Martin".to_string(), Some(create!(UserStatus))); + + let build_result = build_status_comment(&history, ¤t_statuses); + assert_eq!( + format!("{}", build_result.unwrap_err()), + "user Martin not present in history statuses list" + ); + } + + #[test] + fn test_successfuly_build_comment_no_history() { + let mut history: BTreeMap> = BTreeMap::new(); + let mut current_statuses: BTreeMap> = BTreeMap::new(); + + // user 1 + let mut user_1_statuses: Vec = Vec::new(); + user_1_statuses.push(create!(UserStatus)); + user_1_statuses.push(create!(UserStatus, :hold)); + + current_statuses.insert("Niklaus".to_string(), Some(create!(UserStatus))); + history.insert("Niklaus".to_string(), Vec::new()); + + // user 2 + let mut user_2_statuses: Vec = Vec::new(); + user_2_statuses.push(create!(UserStatus, :hold)); + user_2_statuses.push(create!(UserStatus)); + + current_statuses.insert("Barbara".to_string(), Some(create!(UserStatus))); + history.insert("Barbara".to_string(), Vec::new()); + + let build_result = build_status_comment(&history, ¤t_statuses) + .expect("it shouldn't fail building the message"); + let expected_comment = "| Team member | State |\n\ + |-------------|-------|\n\ + | @Barbara | [**merge**](https://some-comment-id-for-merge.com) |\n\ + | @Niklaus | [**merge**](https://some-comment-id-for-merge.com) |\ + " + .to_string(); + + assert_eq!(build_result, expected_comment); + } +} diff --git a/src/handlers/jobs.rs b/src/handlers/jobs.rs index e667fa42..ee1b1c7d 100644 --- a/src/handlers/jobs.rs +++ b/src/handlers/jobs.rs @@ -3,8 +3,15 @@ // the job name and the corresponding function. // Further info could be find in src/jobs.rs - use super::Context; +use crate::db::issue_decision_state::get_issue_decision_state; +use crate::github::*; +use crate::handlers::decision::{DecisionProcessActionMetadata, DECISION_PROCESS_JOB_NAME}; +use crate::interactions::PingComment; +use parser::command::decision::Resolution::{Hold, Merge}; +use reqwest::Client; +use tokio_postgres::Client as DbClient; +use tracing as log; pub async fn handle_job( ctx: &Context, @@ -17,7 +24,11 @@ pub async fn handle_job( super::rustc_commits::synchronize_commits_inner(ctx, None).await; Ok(()) } - _ => default(name, &metadata), + matched_name if *matched_name == DECISION_PROCESS_JOB_NAME.to_string() => { + let db = ctx.db.get().await; + decision_process_handler(&db, &metadata).await + } + _ => default(&name, &metadata), } } @@ -30,3 +41,46 @@ fn default(name: &str, metadata: &serde_json::Value) -> anyhow::Result<()> { Ok(()) } + +async fn decision_process_handler( + db: &DbClient, + metadata: &serde_json::Value, +) -> anyhow::Result<()> { + tracing::trace!( + "handle_job fell into decision process case: (metadata={:?})", + metadata + ); + + let metadata: DecisionProcessActionMetadata = serde_json::from_value(metadata.clone())?; + let gh_client = GithubClient::new_with_default_token(Client::new().clone()); + let request = gh_client.get(&metadata.get_issue_url); + + match gh_client.json::(request).await { + Ok(issue) => match metadata.status { + Merge => { + let users: Vec = get_issue_decision_state(&db, &issue.number) + .await + .unwrap() + .current + .into_keys() + .collect(); + let users_ref: Vec<&str> = users.iter().map(|x| x.as_ref()).collect(); + + let cmnt = PingComment::new( + &issue, + &users_ref, + "The final comment period has resolved, with a decision to **merge**. Ping involved once again.", + ); + cmnt.post(&gh_client).await?; + } + Hold => issue.close(&gh_client).await?, + }, + Err(e) => log::error!( + "Failed to get issue {}, error: {}", + metadata.get_issue_url, + e + ), + } + + Ok(()) +} diff --git a/src/interactions.rs b/src/interactions.rs index f3d6a115..e78050d7 100644 --- a/src/interactions.rs +++ b/src/interactions.rs @@ -33,15 +33,25 @@ impl<'a> ErrorComment<'a> { pub struct PingComment<'a> { issue: &'a Issue, users: &'a [&'a str], + message: String, } impl<'a> PingComment<'a> { - pub fn new(issue: &'a Issue, users: &'a [&str]) -> PingComment<'a> { - PingComment { issue, users } + pub fn new(issue: &'a Issue, users: &'a [&str], message: T) -> PingComment<'a> + where + T: Into, + { + PingComment { + issue, + users, + message: message.into(), + } } pub async fn post(&self, client: &GithubClient) -> anyhow::Result<()> { let mut body = String::new(); + writeln!(body, "{}", self.message)?; + writeln!(body)?; for user in self.users { write!(body, "@{} ", user)?; }