mirror of https://github.com/cncf/gitvote.git
Add check vote command (#259)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com> Signed-off-by: Cintia Sanchez Garcia <cynthiasg@icloud.com> Co-authored-by: Sergio Castaño Arteaga <tegioz@icloud.com> Co-authored-by: Cintia Sanchez Garcia <cynthiasg@icloud.com>
This commit is contained in:
parent
f30a10a781
commit
aaeec980a4
|
@ -60,8 +60,9 @@ checksum = "983cd8b9d4b02a6dc6ffa557262eb5858a27a0038ffffe21a0f133eaa819a164"
|
|||
|
||||
[[package]]
|
||||
name = "askama"
|
||||
version = "0.11.2"
|
||||
source = "git+https://github.com/djc/askama?rev=eeec6f0654f32270aec4e4a0d0f42e4ad39bc28e#eeec6f0654f32270aec4e4a0d0f42e4ad39bc28e"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47cbc3cf73fa8d9833727bbee4835ba5c421a0d65b72daf9a7b5d0e0f9cfb57e"
|
||||
dependencies = [
|
||||
"askama_derive",
|
||||
"askama_escape",
|
||||
|
@ -72,8 +73,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "askama_axum"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/djc/askama?rev=eeec6f0654f32270aec4e4a0d0f42e4ad39bc28e#eeec6f0654f32270aec4e4a0d0f42e4ad39bc28e"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07b336dea26a2eb67f04e1134385721f794b654a870ce5146d2fcb69ea39c3a4"
|
||||
dependencies = [
|
||||
"askama",
|
||||
"axum-core",
|
||||
|
@ -83,8 +85,10 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "askama_derive"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/djc/askama?rev=eeec6f0654f32270aec4e4a0d0f42e4ad39bc28e#eeec6f0654f32270aec4e4a0d0f42e4ad39bc28e"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e80b5ad1afe82872b7aa3e9de9b206ecb85584aa324f0f60fa4c903ce935936b"
|
||||
dependencies = [
|
||||
"basic-toml",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"nom",
|
||||
|
@ -92,13 +96,13 @@ dependencies = [
|
|||
"quote",
|
||||
"serde",
|
||||
"syn",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama_escape"
|
||||
version = "0.10.3"
|
||||
source = "git+https://github.com/djc/askama?rev=eeec6f0654f32270aec4e4a0d0f42e4ad39bc28e#eeec6f0654f32270aec4e4a0d0f42e4ad39bc28e"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341"
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
|
@ -223,6 +227,15 @@ version = "0.21.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a"
|
||||
|
||||
[[package]]
|
||||
name = "basic-toml"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c0de75129aa8d0cceaf750b89013f0e08804d6ec61416da787b35ad0d7cddf1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
|
|
|
@ -7,8 +7,8 @@ edition = "2021"
|
|||
|
||||
[dependencies]
|
||||
anyhow = "1.0.69"
|
||||
askama = { git = "https://github.com/djc/askama", rev = "eeec6f0654f32270aec4e4a0d0f42e4ad39bc28e" }
|
||||
askama_axum = { git = "https://github.com/djc/askama", rev = "eeec6f0654f32270aec4e4a0d0f42e4ad39bc28e" }
|
||||
askama = "0.12.0"
|
||||
askama_axum = "0.3.0"
|
||||
async-channel = "1.8.0"
|
||||
async-trait = "0.1.66"
|
||||
axum = { version = "0.6.11", features = ["macros"] }
|
||||
|
|
12
README.md
12
README.md
|
@ -1,5 +1,7 @@
|
|||
# GitVote
|
||||
[](https://github.com/cncf/gitvote/actions/workflows/ci.yml) [](https://artifacthub.io/packages/helm/gitvote/gitvote)
|
||||
|
||||
[](https://github.com/cncf/gitvote/actions/workflows/ci.yml)
|
||||
[](https://artifacthub.io/packages/helm/gitvote/gitvote)
|
||||
|
||||
**GitVote** is a GitHub application that allows holding a vote on *issues* and *pull requests*.
|
||||
|
||||
|
@ -57,6 +59,14 @@ Only votes from users with a binding vote as defined in the configuration file w
|
|||
|
||||
*Please note that voting multiple options is not allowed and those votes won't be counted.*
|
||||
|
||||
### Checking votes
|
||||
|
||||
It is possible to check the status of a vote in progress by calling the `/check-vote` command:
|
||||
|
||||

|
||||
|
||||
*Please note that this command can only be called once a day per vote (additional calls will be ignored).*
|
||||
|
||||
### Closing votes
|
||||
|
||||
Once the vote time is up, the vote will be automatically closed and the results will be published in a new comment.
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
alter table vote add column checked_at timestamptz;
|
||||
|
||||
---- create above / drop below ----
|
||||
|
||||
alter table vote drop column checked_at;
|
Binary file not shown.
After Width: | Height: | Size: 170 KiB |
33
src/cmd.rs
33
src/cmd.rs
|
@ -11,18 +11,21 @@ use tracing::error;
|
|||
/// Available commands.
|
||||
const CMD_CREATE_VOTE: &str = "vote";
|
||||
const CMD_CANCEL_VOTE: &str = "cancel-vote";
|
||||
const CMD_CHECK_VOTE: &str = "check-vote";
|
||||
|
||||
lazy_static! {
|
||||
/// Regex used to detect commands in issues/prs comments.
|
||||
static ref CMD: Regex = Regex::new(r#"(?m)^/(vote|cancel-vote)-?([a-zA-Z0-9]*)\s*$"#)
|
||||
static ref CMD: Regex = Regex::new(r#"(?m)^/(vote|cancel-vote|check-vote)-?([a-zA-Z0-9]*)\s*$"#)
|
||||
.expect("invalid CMD regexp");
|
||||
}
|
||||
|
||||
/// Represents a command to be executed, usually created from a GitHub event.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
pub(crate) enum Command {
|
||||
CreateVote(CreateVoteInput),
|
||||
CancelVote(CancelVoteInput),
|
||||
CheckVote(CheckVoteInput),
|
||||
}
|
||||
|
||||
impl Command {
|
||||
|
@ -81,6 +84,7 @@ impl Command {
|
|||
CMD_CANCEL_VOTE => {
|
||||
return Some(Command::CancelVote(CancelVoteInput::new(event)))
|
||||
}
|
||||
CMD_CHECK_VOTE => return Some(Command::CheckVote(CheckVoteInput::new(event))),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
|
@ -225,6 +229,33 @@ impl CancelVoteInput {
|
|||
}
|
||||
}
|
||||
|
||||
/// Information required to check the status of an open vote.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub(crate) struct CheckVoteInput {
|
||||
pub issue_number: i64,
|
||||
pub repository_full_name: String,
|
||||
}
|
||||
|
||||
impl CheckVoteInput {
|
||||
/// Create a new CheckVoteInput instance from the event provided.
|
||||
pub(crate) fn new(event: &Event) -> Self {
|
||||
match event {
|
||||
Event::Issue(event) => Self {
|
||||
issue_number: event.issue.number,
|
||||
repository_full_name: event.repository.full_name.clone(),
|
||||
},
|
||||
Event::IssueComment(event) => Self {
|
||||
issue_number: event.issue.number,
|
||||
repository_full_name: event.repository.full_name.clone(),
|
||||
},
|
||||
Event::PullRequest(event) => Self {
|
||||
issue_number: event.pull_request.number,
|
||||
repository_full_name: event.repository.full_name.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
88
src/db.rs
88
src/db.rs
|
@ -30,6 +30,13 @@ pub(crate) trait DB {
|
|||
/// Close any pending finished vote.
|
||||
async fn close_finished_vote(&self, gh: DynGH) -> Result<Option<(Vote, VoteResults)>>;
|
||||
|
||||
/// Get open vote (if available) in the issue/pr provided.
|
||||
async fn get_open_vote(
|
||||
&self,
|
||||
repository_full_name: &str,
|
||||
issue_number: i64,
|
||||
) -> Result<Option<Vote>>;
|
||||
|
||||
/// Check if the issue/pr provided has a vote.
|
||||
async fn has_vote(&self, repository_full_name: &str, issue_number: i64) -> Result<bool>;
|
||||
|
||||
|
@ -43,6 +50,9 @@ pub(crate) trait DB {
|
|||
input: &CreateVoteInput,
|
||||
cfg: &CfgProfile,
|
||||
) -> Result<Uuid>;
|
||||
|
||||
/// Update vote's last check ts.
|
||||
async fn update_vote_last_check(&self, vote_id: Uuid) -> Result<()>;
|
||||
}
|
||||
|
||||
/// DB implementation backed by PostgreSQL.
|
||||
|
@ -69,6 +79,7 @@ impl PgDB {
|
|||
ends_at,
|
||||
closed,
|
||||
closed_at,
|
||||
checked_at,
|
||||
cfg,
|
||||
installation_id,
|
||||
issue_id,
|
||||
|
@ -78,7 +89,8 @@ impl PgDB {
|
|||
organization,
|
||||
results
|
||||
from vote
|
||||
where current_timestamp > ends_at and closed = false
|
||||
where current_timestamp > ends_at
|
||||
and closed = false
|
||||
for update of vote skip locked
|
||||
limit 1
|
||||
",
|
||||
|
@ -96,6 +108,7 @@ impl PgDB {
|
|||
ends_at: row.get("ends_at"),
|
||||
closed: row.get("closed"),
|
||||
closed_at: row.get("closed_at"),
|
||||
checked_at: row.get("checked_at"),
|
||||
cfg,
|
||||
installation_id: row.get("installation_id"),
|
||||
issue_id: row.get("issue_id"),
|
||||
|
@ -174,6 +187,65 @@ impl DB for PgDB {
|
|||
Ok(Some((vote, results)))
|
||||
}
|
||||
|
||||
async fn get_open_vote(
|
||||
&self,
|
||||
repository_full_name: &str,
|
||||
issue_number: i64,
|
||||
) -> Result<Option<Vote>> {
|
||||
let db = self.pool.get().await?;
|
||||
let vote = db
|
||||
.query_opt(
|
||||
"
|
||||
select
|
||||
vote_id,
|
||||
vote_comment_id,
|
||||
created_at,
|
||||
created_by,
|
||||
ends_at,
|
||||
closed,
|
||||
closed_at,
|
||||
checked_at,
|
||||
cfg,
|
||||
installation_id,
|
||||
issue_id,
|
||||
issue_number,
|
||||
is_pull_request,
|
||||
repository_full_name,
|
||||
organization,
|
||||
results
|
||||
from vote
|
||||
where repository_full_name = $1::text
|
||||
and issue_number = $2::bigint
|
||||
and closed = false
|
||||
",
|
||||
&[&repository_full_name, &issue_number],
|
||||
)
|
||||
.await?
|
||||
.map(|row| {
|
||||
let Json(cfg): Json<CfgProfile> = row.get("cfg");
|
||||
let results: Option<Json<VoteResults>> = row.get("results");
|
||||
Vote {
|
||||
vote_id: row.get("vote_id"),
|
||||
vote_comment_id: row.get("vote_comment_id"),
|
||||
created_at: row.get("created_at"),
|
||||
created_by: row.get("created_by"),
|
||||
ends_at: row.get("ends_at"),
|
||||
closed: row.get("closed"),
|
||||
closed_at: row.get("closed_at"),
|
||||
checked_at: row.get("checked_at"),
|
||||
cfg,
|
||||
installation_id: row.get("installation_id"),
|
||||
issue_id: row.get("issue_id"),
|
||||
issue_number: row.get("issue_number"),
|
||||
is_pull_request: row.get("is_pull_request"),
|
||||
repository_full_name: row.get("repository_full_name"),
|
||||
organization: row.get("organization"),
|
||||
results: results.map(|Json(results)| results),
|
||||
}
|
||||
});
|
||||
Ok(vote)
|
||||
}
|
||||
|
||||
async fn has_vote(&self, repository_full_name: &str, issue_number: i64) -> Result<bool> {
|
||||
let db = self.pool.get().await?;
|
||||
let has_vote = db
|
||||
|
@ -264,4 +336,18 @@ impl DB for PgDB {
|
|||
.get("vote_id");
|
||||
Ok(vote_id)
|
||||
}
|
||||
|
||||
async fn update_vote_last_check(&self, vote_id: Uuid) -> Result<()> {
|
||||
let db = self.pool.get().await?;
|
||||
db.execute(
|
||||
"
|
||||
update vote set
|
||||
checked_at = current_timestamp
|
||||
where vote_id = $1::uuid;
|
||||
",
|
||||
&[&vote_id],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
255
src/processor.rs
255
src/processor.rs
|
@ -1,14 +1,15 @@
|
|||
use crate::{
|
||||
cfg::{CfgError, CfgProfile},
|
||||
cmd::{CancelVoteInput, Command, CreateVoteInput},
|
||||
cmd::{CancelVoteInput, CheckVoteInput, Command, CreateVoteInput},
|
||||
db::DynDB,
|
||||
github::{split_full_name, CheckDetails, DynGH},
|
||||
tmpl,
|
||||
results, tmpl,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use askama::Template;
|
||||
use futures::future::{self, JoinAll};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::{
|
||||
sync::broadcast::{self, error::TryRecvError},
|
||||
task::JoinHandle,
|
||||
|
@ -30,6 +31,9 @@ const VOTES_CLOSER_PAUSE_ON_NONE: Duration = Duration::from_secs(15);
|
|||
/// Amount of time the votes closer will sleep when something goes wrong.
|
||||
const VOTES_CLOSER_PAUSE_ON_ERROR: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How often a vote can be checked.
|
||||
const MAX_VOTE_CHECK_FREQUENCY: Duration = Duration::from_secs(60 * 60 * 24);
|
||||
|
||||
/// A votes processor is in charge of creating the requested votes, stopping
|
||||
/// them at the scheduled time and publishing results, etc.
|
||||
pub(crate) struct Processor {
|
||||
|
@ -89,7 +93,10 @@ impl Processor {
|
|||
_ = self.create_vote(&input).await;
|
||||
}
|
||||
Command::CancelVote(input) => {
|
||||
_ = self.cancel_vote(input).await;
|
||||
_ = self.cancel_vote(&input).await;
|
||||
}
|
||||
Command::CheckVote(input) => {
|
||||
_ = self.check_vote(&input).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -137,7 +144,7 @@ impl Processor {
|
|||
}
|
||||
|
||||
/// Create a new vote.
|
||||
#[instrument(fields(repo = i.repository_full_name, issue_number = i.issue_number), skip_all, err)]
|
||||
#[instrument(fields(repo = i.repository_full_name, issue_number = i.issue_number), skip_all, err(Debug))]
|
||||
async fn create_vote(&self, i: &CreateVoteInput) -> Result<()> {
|
||||
// Get vote configuration profile
|
||||
let inst_id = i.installation_id as u64;
|
||||
|
@ -221,8 +228,19 @@ impl Processor {
|
|||
}
|
||||
|
||||
/// Cancel an open vote.
|
||||
#[instrument(fields(repo = i.repository_full_name, issue_number = i.issue_number), skip_all, err)]
|
||||
async fn cancel_vote(&self, i: CancelVoteInput) -> Result<()> {
|
||||
#[instrument(fields(repo = i.repository_full_name, issue_number = i.issue_number), skip_all, err(Debug))]
|
||||
async fn cancel_vote(&self, i: &CancelVoteInput) -> Result<()> {
|
||||
// Only repository collaborators can cancel votes
|
||||
let inst_id = i.installation_id as u64;
|
||||
let (owner, repo) = split_full_name(&i.repository_full_name);
|
||||
if !self
|
||||
.gh
|
||||
.user_is_collaborator(inst_id, owner, repo, &i.cancelled_by)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Try cancelling the vote open in the issue/pr provided
|
||||
let cancelled_vote_id: Option<Uuid> = self
|
||||
.db
|
||||
|
@ -230,8 +248,6 @@ impl Processor {
|
|||
.await?;
|
||||
|
||||
// Post the corresponding comment on the issue/pr
|
||||
let inst_id = i.installation_id as u64;
|
||||
let (owner, repo) = split_full_name(&i.repository_full_name);
|
||||
let body: String;
|
||||
match cancelled_vote_id {
|
||||
Some(vote_id) => {
|
||||
|
@ -261,8 +277,55 @@ impl Processor {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Check the status of a vote. The vote must be open and not have been
|
||||
/// checked in MAX_VOTE_CHECK_FREQUENCY.
|
||||
#[instrument(fields(vote_id), skip_all, err(Debug))]
|
||||
async fn check_vote(&self, i: &CheckVoteInput) -> Result<()> {
|
||||
// Get open vote (if any) from database
|
||||
let vote = match self
|
||||
.db
|
||||
.get_open_vote(&i.repository_full_name, i.issue_number)
|
||||
.await?
|
||||
{
|
||||
Some(vote) => vote,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Record vote_id as part of the current span
|
||||
tracing::Span::current().record("vote_id", &vote.vote_id.to_string());
|
||||
|
||||
// Check if the vote has already been checked recently
|
||||
let inst_id = vote.installation_id as u64;
|
||||
let (owner, repo) = split_full_name(&vote.repository_full_name);
|
||||
if let Some(checked_at) = vote.checked_at {
|
||||
if OffsetDateTime::now_utc() - checked_at < MAX_VOTE_CHECK_FREQUENCY {
|
||||
// Post comment on the issue/pr and return
|
||||
let body = tmpl::VoteCheckedRecently {}.render()?;
|
||||
self.gh
|
||||
.post_comment(inst_id, owner, repo, vote.issue_number, &body)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate results
|
||||
let (owner, repo) = split_full_name(&vote.repository_full_name);
|
||||
let results = results::calculate(self.gh.clone(), owner, repo, &vote).await?;
|
||||
|
||||
// Post vote status comment on the issue/pr
|
||||
let body = tmpl::VoteStatus::new(&results).render()?;
|
||||
self.gh
|
||||
.post_comment(inst_id, owner, repo, vote.issue_number, &body)
|
||||
.await?;
|
||||
|
||||
// Update vote's last check ts
|
||||
self.db.update_vote_last_check(vote.vote_id).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close any pending finished vote.
|
||||
#[instrument(fields(vote_id), skip_all, err)]
|
||||
#[instrument(fields(vote_id), skip_all, err(Debug))]
|
||||
async fn close_finished_vote(&self) -> Result<Option<()>> {
|
||||
// Try to close any finished vote
|
||||
let (vote, results) = match self.db.close_finished_vote(self.gh.clone()).await? {
|
||||
|
@ -317,10 +380,12 @@ impl Processor {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::results::{Vote, REACTION_IN_FAVOR};
|
||||
use crate::testutil::*;
|
||||
use crate::{cfg::AllowedVoters, db::MockDB, github::*};
|
||||
use anyhow::format_err;
|
||||
use mockall::predicate::eq;
|
||||
use time::ext::NumericalDuration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn votes_processor_stops_when_requested() {
|
||||
|
@ -358,7 +423,10 @@ mod tests {
|
|||
db.expect_cancel_vote()
|
||||
.with(eq(REPOFN), eq(ISSUE_NUM))
|
||||
.returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR)))));
|
||||
let gh = MockGH::new();
|
||||
let mut gh = MockGH::new();
|
||||
gh.expect_user_is_collaborator()
|
||||
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER))
|
||||
.returning(|_, _, _, _| Box::pin(future::ready(Ok(true))));
|
||||
let votes_processor = Processor::new(Arc::new(db), Arc::new(gh));
|
||||
|
||||
let (cmds_tx, cmds_rx) = async_channel::unbounded();
|
||||
|
@ -611,18 +679,53 @@ mod tests {
|
|||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_vote_error_checking_if_user_is_collaborator() {
|
||||
let db = MockDB::new();
|
||||
let mut gh = MockGH::new();
|
||||
gh.expect_user_is_collaborator()
|
||||
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER))
|
||||
.returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR)))));
|
||||
let event = setup_test_issue_comment_event();
|
||||
|
||||
let votes_processor = Processor::new(Arc::new(db), Arc::new(gh));
|
||||
votes_processor
|
||||
.cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event)))
|
||||
.await
|
||||
.unwrap_err();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_vote_only_collaborators_can_close_votes() {
|
||||
let db = MockDB::new();
|
||||
let mut gh = MockGH::new();
|
||||
gh.expect_user_is_collaborator()
|
||||
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER))
|
||||
.returning(|_, _, _, _| Box::pin(future::ready(Ok(false))));
|
||||
let event = setup_test_issue_comment_event();
|
||||
|
||||
let votes_processor = Processor::new(Arc::new(db), Arc::new(gh));
|
||||
votes_processor
|
||||
.cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event)))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_vote_error_cancelling() {
|
||||
let mut db = MockDB::new();
|
||||
db.expect_cancel_vote()
|
||||
.with(eq(REPOFN), eq(ISSUE_NUM))
|
||||
.returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR)))));
|
||||
let gh = MockGH::new();
|
||||
let mut gh = MockGH::new();
|
||||
gh.expect_user_is_collaborator()
|
||||
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER))
|
||||
.returning(|_, _, _, _| Box::pin(future::ready(Ok(true))));
|
||||
let event = setup_test_issue_comment_event();
|
||||
|
||||
let votes_processor = Processor::new(Arc::new(db), Arc::new(gh));
|
||||
votes_processor
|
||||
.cancel_vote(CancelVoteInput::new(&Event::IssueComment(event)))
|
||||
.cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event)))
|
||||
.await
|
||||
.unwrap_err();
|
||||
}
|
||||
|
@ -634,6 +737,9 @@ mod tests {
|
|||
.with(eq(REPOFN), eq(ISSUE_NUM))
|
||||
.returning(|_, _| Box::pin(future::ready(Ok(None))));
|
||||
let mut gh = MockGH::new();
|
||||
gh.expect_user_is_collaborator()
|
||||
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER))
|
||||
.returning(|_, _, _, _| Box::pin(future::ready(Ok(true))));
|
||||
gh.expect_post_comment()
|
||||
.withf(|inst_id, owner, repo, issue_number, body| {
|
||||
let expected_body = tmpl::NoVoteInProgress::new(USER, false).render().unwrap();
|
||||
|
@ -648,7 +754,7 @@ mod tests {
|
|||
|
||||
let votes_processor = Processor::new(Arc::new(db), Arc::new(gh));
|
||||
votes_processor
|
||||
.cancel_vote(CancelVoteInput::new(&Event::IssueComment(event)))
|
||||
.cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event)))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
@ -660,6 +766,9 @@ mod tests {
|
|||
.with(eq(REPOFN), eq(ISSUE_NUM))
|
||||
.returning(|_, _| Box::pin(future::ready(Ok(Some(Uuid::parse_str(VOTE_ID).unwrap())))));
|
||||
let mut gh = MockGH::new();
|
||||
gh.expect_user_is_collaborator()
|
||||
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER))
|
||||
.returning(|_, _, _, _| Box::pin(future::ready(Ok(true))));
|
||||
gh.expect_post_comment()
|
||||
.withf(|inst_id, owner, repo, issue_number, body| {
|
||||
let expected_body = tmpl::VoteCancelled::new(USER, false).render().unwrap();
|
||||
|
@ -674,7 +783,7 @@ mod tests {
|
|||
|
||||
let votes_processor = Processor::new(Arc::new(db), Arc::new(gh));
|
||||
votes_processor
|
||||
.cancel_vote(CancelVoteInput::new(&Event::IssueComment(event)))
|
||||
.cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event)))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
@ -686,6 +795,9 @@ mod tests {
|
|||
.with(eq(REPOFN), eq(ISSUE_NUM))
|
||||
.returning(|_, _| Box::pin(future::ready(Ok(Some(Uuid::parse_str(VOTE_ID).unwrap())))));
|
||||
let mut gh = MockGH::new();
|
||||
gh.expect_user_is_collaborator()
|
||||
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER))
|
||||
.returning(|_, _, _, _| Box::pin(future::ready(Ok(true))));
|
||||
gh.expect_post_comment()
|
||||
.withf(|inst_id, owner, repo, issue_number, body| {
|
||||
let expected_body = tmpl::VoteCancelled::new(USER, true).render().unwrap();
|
||||
|
@ -713,7 +825,120 @@ mod tests {
|
|||
|
||||
let votes_processor = Processor::new(Arc::new(db), Arc::new(gh));
|
||||
votes_processor
|
||||
.cancel_vote(CancelVoteInput::new(&Event::PullRequest(event)))
|
||||
.cancel_vote(&CancelVoteInput::new(&Event::PullRequest(event)))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_vote_error_getting_vote() {
|
||||
let mut db = MockDB::new();
|
||||
db.expect_get_open_vote()
|
||||
.with(eq(REPOFN), eq(ISSUE_NUM))
|
||||
.returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR)))));
|
||||
let gh = MockGH::new();
|
||||
let event = setup_test_pr_event();
|
||||
|
||||
let votes_processor = Processor::new(Arc::new(db), Arc::new(gh));
|
||||
votes_processor
|
||||
.check_vote(&CheckVoteInput::new(&Event::PullRequest(event)))
|
||||
.await
|
||||
.unwrap_err();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_vote_not_found() {
|
||||
let mut db = MockDB::new();
|
||||
db.expect_get_open_vote()
|
||||
.with(eq(REPOFN), eq(ISSUE_NUM))
|
||||
.returning(|_, _| Box::pin(future::ready(Ok(None))));
|
||||
let gh = MockGH::new();
|
||||
let event = setup_test_pr_event();
|
||||
|
||||
let votes_processor = Processor::new(Arc::new(db), Arc::new(gh));
|
||||
votes_processor
|
||||
.check_vote(&CheckVoteInput::new(&Event::PullRequest(event)))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_vote_checked_recently() {
|
||||
let mut db = MockDB::new();
|
||||
db.expect_get_open_vote()
|
||||
.with(eq(REPOFN), eq(ISSUE_NUM))
|
||||
.returning(|_, _| {
|
||||
Box::pin(future::ready(Ok(Some(Vote {
|
||||
checked_at: OffsetDateTime::now_utc().checked_sub(1.hours()),
|
||||
..setup_test_vote()
|
||||
}))))
|
||||
});
|
||||
let mut gh = MockGH::new();
|
||||
gh.expect_post_comment()
|
||||
.withf(|inst_id, owner, repo, issue_number, body| {
|
||||
let expected_body = tmpl::VoteCheckedRecently {}.render().unwrap();
|
||||
*inst_id == INST_ID
|
||||
&& owner == ORG
|
||||
&& repo == REPO
|
||||
&& *issue_number == ISSUE_NUM
|
||||
&& body == expected_body.as_str()
|
||||
})
|
||||
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID))));
|
||||
let event = setup_test_pr_event();
|
||||
|
||||
let votes_processor = Processor::new(Arc::new(db), Arc::new(gh));
|
||||
votes_processor
|
||||
.check_vote(&CheckVoteInput::new(&Event::PullRequest(event)))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_vote_success() {
|
||||
let mut db = MockDB::new();
|
||||
db.expect_get_open_vote()
|
||||
.with(eq(REPOFN), eq(ISSUE_NUM))
|
||||
.returning(|_, _| Box::pin(future::ready(Ok(Some(setup_test_vote())))));
|
||||
db.expect_update_vote_last_check()
|
||||
.with(eq(Uuid::parse_str(VOTE_ID).unwrap()))
|
||||
.returning(|_| Box::pin(future::ready(Ok(()))));
|
||||
let mut gh = MockGH::new();
|
||||
gh.expect_get_comment_reactions()
|
||||
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID))
|
||||
.returning(|_, _, _, _| {
|
||||
Box::pin(future::ready(Ok(vec![Reaction {
|
||||
user: User {
|
||||
login: USER1.to_string(),
|
||||
},
|
||||
content: REACTION_IN_FAVOR.to_string(),
|
||||
created_at: TIMESTAMP.to_string(),
|
||||
}])))
|
||||
});
|
||||
gh.expect_get_allowed_voters()
|
||||
.with(
|
||||
eq(INST_ID),
|
||||
eq(setup_test_vote().cfg),
|
||||
eq(ORG),
|
||||
eq(REPO),
|
||||
eq(Some(ORG.to_string())),
|
||||
)
|
||||
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(vec![USER1.to_string()]))));
|
||||
gh.expect_post_comment()
|
||||
.withf(|inst_id, owner, repo, issue_number, body| {
|
||||
let results = setup_test_vote_results();
|
||||
let expected_body = tmpl::VoteStatus::new(&results).render().unwrap();
|
||||
*inst_id == INST_ID
|
||||
&& owner == ORG
|
||||
&& repo == REPO
|
||||
&& *issue_number == ISSUE_NUM
|
||||
&& body == expected_body.as_str()
|
||||
})
|
||||
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID))));
|
||||
let event = setup_test_pr_event();
|
||||
|
||||
let votes_processor = Processor::new(Arc::new(db), Arc::new(gh));
|
||||
votes_processor
|
||||
.check_vote(&CheckVoteInput::new(&Event::PullRequest(event)))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
|
|
@ -9,9 +9,9 @@ use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
|||
use uuid::Uuid;
|
||||
|
||||
/// Supported reactions.
|
||||
const REACTION_IN_FAVOR: &str = "+1";
|
||||
const REACTION_AGAINST: &str = "-1";
|
||||
const REACTION_ABSTAIN: &str = "eyes";
|
||||
pub(crate) const REACTION_IN_FAVOR: &str = "+1";
|
||||
pub(crate) const REACTION_AGAINST: &str = "-1";
|
||||
pub(crate) const REACTION_ABSTAIN: &str = "eyes";
|
||||
|
||||
/// Vote information.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
@ -23,6 +23,7 @@ pub(crate) struct Vote {
|
|||
pub ends_at: OffsetDateTime,
|
||||
pub closed: bool,
|
||||
pub closed_at: Option<OffsetDateTime>,
|
||||
pub checked_at: Option<OffsetDateTime>,
|
||||
pub cfg: CfgProfile,
|
||||
pub installation_id: i64,
|
||||
pub issue_id: i64,
|
||||
|
@ -233,6 +234,7 @@ mod tests {
|
|||
ends_at: OffsetDateTime::now_utc(),
|
||||
closed: false,
|
||||
closed_at: None,
|
||||
checked_at: None,
|
||||
cfg: $cfg.clone(),
|
||||
installation_id: INST_ID as i64,
|
||||
issue_id: ISSUE_ID,
|
||||
|
@ -246,10 +248,10 @@ mod tests {
|
|||
// Setup mocks and expectations
|
||||
let mut gh = MockGH::new();
|
||||
gh.expect_get_comment_reactions()
|
||||
.with(eq(INST_ID as u64), eq(OWNER), eq(REPO), eq(COMMENT_ID))
|
||||
.with(eq(INST_ID), eq(OWNER), eq(REPO), eq(COMMENT_ID))
|
||||
.returning(|_, _, _, _| Box::pin(future::ready(Ok($reactions))));
|
||||
gh.expect_get_allowed_voters()
|
||||
.with(eq(INST_ID as u64), eq($cfg), eq(OWNER), eq(REPO), eq(Some(ORG.to_string())))
|
||||
.with(eq(INST_ID), eq($cfg), eq(OWNER), eq(REPO), eq(Some(ORG.to_string())))
|
||||
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok($allowed_voters))));
|
||||
|
||||
// Calculate vote results and check we get what we expect
|
||||
|
|
|
@ -123,6 +123,7 @@ pub(crate) fn setup_test_vote() -> Vote {
|
|||
ends_at: OffsetDateTime::now_utc(),
|
||||
closed: false,
|
||||
closed_at: None,
|
||||
checked_at: None,
|
||||
cfg: CfgProfile {
|
||||
duration: Duration::from_secs(300),
|
||||
pass_threshold: 50.0,
|
||||
|
|
19
src/tmpl.rs
19
src/tmpl.rs
|
@ -71,6 +71,11 @@ impl<'a> VoteCancelled<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Template for the vote checked recently comment.
|
||||
#[derive(Debug, Clone, Template)]
|
||||
#[template(path = "vote-checked-recently.md")]
|
||||
pub(crate) struct VoteCheckedRecently {}
|
||||
|
||||
/// Template for the vote closed comment.
|
||||
#[derive(Debug, Clone, Template)]
|
||||
#[template(path = "vote-closed.md")]
|
||||
|
@ -164,6 +169,20 @@ impl<'a> VoteRestricted<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Template for the vote status comment.
|
||||
#[derive(Debug, Clone, Template)]
|
||||
#[template(path = "vote-status.md")]
|
||||
pub(crate) struct VoteStatus<'a> {
|
||||
results: &'a VoteResults,
|
||||
}
|
||||
|
||||
impl<'a> VoteStatus<'a> {
|
||||
/// Create a new VoteStatus template.
|
||||
pub(crate) fn new(results: &'a VoteResults) -> Self {
|
||||
Self { results }
|
||||
}
|
||||
}
|
||||
|
||||
mod filters {
|
||||
use crate::{github::UserName, results::UserVote};
|
||||
use std::collections::HashMap;
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Votes can only be checked once a day.
|
|
@ -0,0 +1,40 @@
|
|||
## Vote status
|
||||
|
||||
So far `{{ "{:.2}"|format(results.in_favor_percentage) }}%` of the users with binding vote are in favor (passing threshold: `{{ results.pass_threshold }}%`).
|
||||
|
||||
### Summary
|
||||
|
||||
| In favor | Against | Abstain | Not voted |
|
||||
| :--------------------: | :-------------------: | :------------------: | :---------------------: |
|
||||
| {{ results.in_favor }} | {{ results.against }} | {{ results.abstain}} | {{ results.not_voted }} |
|
||||
|
||||
{% if !results.votes.is_empty() %}
|
||||
|
||||
{% if results.binding > 0 -%}
|
||||
### Binding votes ({{ results.binding }})
|
||||
|
||||
| User | Vote | Timestamp |
|
||||
| ---- | :---: | :-------: |
|
||||
{% for (user, vote) in results.votes -%}
|
||||
{% if vote.binding -%}
|
||||
| {{ user }} | {{ vote.vote_option }} | {{ vote.timestamp }} |
|
||||
{% endif -%}
|
||||
{% endfor -%}
|
||||
{% endif -%}
|
||||
|
||||
{% if results.non_binding > 0 -%}
|
||||
<details>
|
||||
<summary><h3>Non-binding votes ({{ results.non_binding }})</h3></summary>
|
||||
{% let max_non_binding = 300 -%}
|
||||
{% if results.non_binding > max_non_binding %}
|
||||
<i>(displaying only the first {{ max_non_binding }} non-binding votes)</i>
|
||||
{% endif %}
|
||||
| User | Vote | Timestamp |
|
||||
| ---- | :---: | :-------: |
|
||||
{% for (user, vote) in results.votes|non_binding(max_non_binding) -%}
|
||||
| {{ user }} | {{ vote.vote_option }} | {{ vote.timestamp }} |
|
||||
{% endfor -%}
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
Loading…
Reference in New Issue