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:
Sergio Castaño Arteaga 2023-03-17 16:55:26 +01:00 committed by GitHub
parent f30a10a781
commit aaeec980a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 465 additions and 32 deletions

27
Cargo.lock generated
View File

@ -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"

View File

@ -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"] }

View File

@ -1,5 +1,7 @@
# GitVote
[![CI](https://github.com/cncf/gitvote/actions/workflows/ci.yml/badge.svg)](https://github.com/cncf/gitvote/actions/workflows/ci.yml) [![Artifact Hub](https://camo.githubusercontent.com/71c62a4d3e15d916774c527ae92a8ad2f8c026ad48dc6c711acf8ae217611817/68747470733a2f2f696d672e736869656c64732e696f2f656e64706f696e743f75726c3d68747470733a2f2f61727469666163746875622e696f2f62616467652f7265706f7369746f72792f676974766f7465)](https://artifacthub.io/packages/helm/gitvote/gitvote)
[![CI](https://github.com/cncf/gitvote/actions/workflows/ci.yml/badge.svg)](https://github.com/cncf/gitvote/actions/workflows/ci.yml)
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/gitvote)](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:
![vote-status](docs/screenshots/vote-status.png)
*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.

View File

@ -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

View File

@ -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::*;

View File

@ -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(())
}
}

View File

@ -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();
}

View File

@ -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

View File

@ -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,

View File

@ -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;

View File

@ -0,0 +1 @@
Votes can only be checked once a day.

40
templates/vote-status.md Normal file
View File

@ -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 %}