Compare commits

..

No commits in common. "main" and "gitvote-chart-1.4.0" have entirely different histories.

39 changed files with 528 additions and 1230 deletions

View File

@ -9,7 +9,7 @@ permissions: read-all
jobs:
lint-and-test:
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v4
@ -22,9 +22,9 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.8
python-version: 3.7
- name: Set up chart-testing
uses: helm/chart-testing-action@v2.7.0
uses: helm/chart-testing-action@v2.6.1
- name: Run chart-testing (list-changed)
id: list-changed
run: |
@ -35,7 +35,7 @@ jobs:
- name: Run chart-testing (lint)
run: ct lint --config .ct.yaml --target-branch ${{ github.event.repository.default_branch }}
- name: Create kind cluster
uses: helm/kind-action@v1.12.0
uses: helm/kind-action@v1.10.0
if: steps.list-changed.outputs.changed == 'true'
- name: Run chart-testing (install)
run: ct install --config .ct.yaml --target-branch ${{ github.event.repository.default_branch }}

View File

@ -15,7 +15,7 @@ jobs:
- name: Setup Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.88.0
toolchain: 1.83.0
components: clippy, rustfmt
- name: Run clippy
run: cargo clippy --all-targets --all-features -- --deny warnings
@ -30,6 +30,6 @@ jobs:
- name: Setup Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.88.0
toolchain: 1.83.0
- name: Run backend tests
run: cargo test

View File

@ -9,18 +9,16 @@ permissions: read-all
jobs:
build-and-publish-images:
runs-on: ubuntu-latest
permissions:
packages: write
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Login to GitHub Container Registry
- name: Login to AWS Public ECR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
registry: public.ecr.aws
username: ${{ secrets.AWS_ACCESS_KEY_ID }}
password: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Extract tag name
id: extract_tag_name
run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
@ -28,17 +26,17 @@ jobs:
run: |
docker build \
-f database/migrations/Dockerfile \
-t ghcr.io/${{ github.repository }}/dbmigrator:${{steps.extract_tag_name.outputs.tag}} \
-t ghcr.io/${{ github.repository }}/dbmigrator:latest \
-t public.ecr.aws/g6m3a0y9/gitvote-dbmigrator:${{steps.extract_tag_name.outputs.tag}} \
-t public.ecr.aws/g6m3a0y9/gitvote-dbmigrator:latest \
.
docker push --all-tags ghcr.io/${{ github.repository }}/dbmigrator
docker push --all-tags public.ecr.aws/g6m3a0y9/gitvote-dbmigrator
- name: Build and push gitvote image
run: |
docker build \
-t ghcr.io/${{ github.repository }}/server:${{steps.extract_tag_name.outputs.tag}} \
-t ghcr.io/${{ github.repository }}/server:latest \
-t public.ecr.aws/g6m3a0y9/gitvote:${{steps.extract_tag_name.outputs.tag}} \
-t public.ecr.aws/g6m3a0y9/gitvote:latest \
.
docker push --all-tags ghcr.io/${{ github.repository }}/server
docker push --all-tags public.ecr.aws/g6m3a0y9/gitvote
package-and-publish-helm-chart:
needs:

View File

@ -2,26 +2,12 @@
If your organization is using GitVote, please consider adding it to this list by submitting a pull request.
- [AsyncAPI](https://github.com/asyncapi/community/blob/master/voting.md)
- [CloudNativePG](https://cloudnative-pg.io)
- [AWS Labs](https://github.com/awslabs)
- [CNCF](https://cncf.io)
- [DevRel Foundation](https://dev-rel.org/)
- [Fintech Open Source Foundation](www.finos.org)
- [JSON Schema](https://json-schema.org)
- [K8sGateway](https://k8sgateway.io)
- [KServe](https://kserve.github.io/website/latest/)
- [Kuadrant](https://kuadrant.io)
- [Kuma](https://kuma.io)
- [Kyverno](https://kyverno.io)
- [Microcks](https://microcks.io/)
- [NFDI4Health](https://github.com/nfdi4health)
- [Open Component Model](https://ocm.software)
- [OpenGemini](https://opengemini.org)
- [OpenSSF](https://openssf.org)
- [ORAS](https://oras.land)
- [OSCAL Compass](https://github.com/oscal-compass)
- [Ratify Project](https://ratify.dev)
- [ResBaz Arizona](https://researchbazaar.arizona.edu)
- [TODO Group](https://todogroup.org)
- [Universal Blue](https://universal-blue.org)
- [WasmEdge](https://wasmedge.org/)

733
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,56 +4,58 @@ description = "GitVote server"
version = "1.4.0"
license = "Apache-2.0"
edition = "2021"
rust-version = "1.88"
rust-version = "1.83"
[dependencies]
anyhow = "1.0.98"
askama = { version = "0.14.0", features = ["serde_json"] }
async-channel = "2.5.0"
async-trait = "0.1.88"
axum = { version = "0.8.4", features = ["macros"] }
clap = { version = "4.5.41", features = ["derive"] }
deadpool-postgres = { version = "0.14.1", features = ["serde"] }
anyhow = "1.0.94"
askama = "0.12.1"
askama_axum = "0.4.0"
async-channel = "2.3.1"
async-trait = "0.1.83"
axum = { version = "0.7.9", features = ["macros"] }
clap = { version = "4.5.23", features = ["derive"] }
deadpool-postgres = { version = "0.14.0", features = ["serde"] }
figment = { version = "0.10.19", features = ["yaml", "env"] }
futures = "0.3.31"
graphql_client = { version = "0.14.0", features = ["reqwest"] }
hex = "0.4.3"
hmac = "0.12.1"
http = "1.3.1"
humantime = "2.2.0"
http = "0.2.12"
humantime = "2.1.0"
humantime-serde = "1.1.1"
ignore = "0.4.23"
jsonwebtoken = "9.3.1"
octocrab = "0.44.1"
openssl = { version = "0.10.73", features = ["vendored"] }
postgres-openssl = "0.5.1"
jsonwebtoken = "9.3.0"
lazy_static = "1.5.0"
octocrab = "=0.33.3"
openssl = { version = "0.10.68", features = ["vendored"] }
postgres-openssl = "0.5.0"
regex = "1.11.1"
reqwest = "0.12.22"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
reqwest = "0.12.9"
serde = { version = "1.0.215", features = ["derive"] }
serde_json = "1.0.133"
serde_yaml = "0.9.34"
sha2 = "0.10.9"
thiserror = "2.0.12"
time = { version = "0.3.41", features = ["serde"] }
tokio = { version = "1.46.1", features = [
sha2 = "0.10.8"
thiserror = "2.0.6"
time = { version = "0.3.37", features = ["serde"] }
tokio = { version = "1.42.0", features = [
"macros",
"rt-multi-thread",
"signal",
"time",
] }
tokio-postgres = { version = "0.7.13", features = [
tokio-postgres = { version = "0.7.12", features = [
"with-uuid-1",
"with-serde_json-1",
"with-time-0_3",
] }
tokio-util = { version = "0.7.15", features = ["rt"] }
tower = { version = "0.5.2", features = ["util"] }
tower-http = { version = "0.6.6", features = ["trace"] }
tokio-util = { version = "0.7.13", features = ["rt"] }
tower = { version = "0.5.1", features = ["util"] }
tower-http = { version = "0.6.2", features = ["trace"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] }
uuid = { version = "1.17.0", features = ["serde", "v4"] }
uuid = { version = "1.11.0", features = ["serde", "v4"] }
[dev-dependencies]
http-body = "1.0.1"
hyper = "1.6.0"
hyper = "1.5.1"
mockall = "0.13.1"

View File

@ -1,5 +1,5 @@
# Build gitvote
FROM rust:1-alpine3.22 as builder
FROM rust:1-alpine3.21 as builder
RUN apk --no-cache add musl-dev perl make
WORKDIR /gitvote
COPY src src
@ -10,7 +10,7 @@ WORKDIR /gitvote/src
RUN cargo build --release
# Final stage
FROM alpine:3.22.0
FROM alpine:3.21.0
RUN apk --no-cache add ca-certificates && addgroup -S gitvote && adduser -S gitvote -G gitvote
USER gitvote
WORKDIR /home/gitvote

View File

@ -2,7 +2,7 @@ apiVersion: v2
name: gitvote
description: GitVote is a GitHub application that allows holding a vote on issues and pull requests
type: application
version: 1.4.1
version: 1.4.0
appVersion: 1.4.0
kubeVersion: ">= 1.19.0-0"
home: https://gitvote.dev
@ -41,9 +41,9 @@ annotations:
artifacthub.io/containsSecurityUpdates: "true"
artifacthub.io/images: |
- name: dbmigrator
image: ghcr.io/cncf/gitvote/dbmigrator:v1.4.1
image: public.ecr.aws/g6m3a0y9/gitvote-dbmigrator:v1.4.0
- name: gitvote
image: ghcr.io/cncf/gitvote/server:v1.4.1
image: public.ecr.aws/g6m3a0y9/gitvote:v1.4.0
artifacthub.io/links: |
- name: source
url: https://github.com/cncf/gitvote

View File

@ -36,7 +36,7 @@ dbmigrator:
job:
image:
# Database migrator image repository (without the tag)
repository: ghcr.io/cncf/gitvote/dbmigrator
repository: public.ecr.aws/g6m3a0y9/gitvote-dbmigrator
# GitVote service configuration
gitvote:
@ -74,7 +74,7 @@ gitvote:
deploy:
replicaCount: 1
image:
repository: ghcr.io/cncf/gitvote/server
repository: public.ecr.aws/g6m3a0y9/gitvote
resources: {}
# PostgreSQL configuration

View File

@ -1,10 +1,10 @@
# Build tern
FROM golang:1.24.5-alpine3.22 AS tern
FROM golang:1.23.4-alpine3.21 AS tern
RUN apk --no-cache add git
RUN go install github.com/jackc/tern@latest
# Build final image
FROM alpine:3.22.0
FROM alpine:3.21.0
RUN addgroup -S gitvote && adduser -S gitvote -G gitvote
USER gitvote
WORKDIR /home/gitvote

View File

@ -2,9 +2,9 @@
//! GitHub events.
use anyhow::Result;
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
use tracing::error;
use crate::{
@ -19,10 +19,11 @@ const CMD_CREATE_VOTE: &str = "vote";
const CMD_CANCEL_VOTE: &str = "cancel-vote";
const CMD_CHECK_VOTE: &str = "check-vote";
/// Regex used to detect commands in issues/prs comments.
static CMD: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?m)^/(vote|cancel-vote|check-vote)-?([a-zA-Z0-9]*)\s*$").expect("invalid CMD regexp")
});
lazy_static! {
/// Regex used to detect commands in issues/prs comments.
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)]

View File

@ -191,7 +191,7 @@ impl GH for GHApi {
issue_number: i64,
labels: &[&str],
) -> Result<()> {
let client = self.app_client.installation(InstallationId(inst_id))?;
let client = self.app_client.installation(InstallationId(inst_id));
let labels = labels.iter().map(ToString::to_string).collect::<Vec<String>>();
client.issues(owner, repo).add_labels(issue_number as u64, &labels).await?;
Ok(())
@ -206,7 +206,7 @@ impl GH for GHApi {
issue_number: i64,
check_details: &CheckDetails,
) -> Result<()> {
let client = self.app_client.installation(InstallationId(inst_id))?;
let client = self.app_client.installation(InstallationId(inst_id));
let pr = client.pulls(owner, repo).get(issue_number as u64).await?;
let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/check-runs");
let mut body = json!({
@ -220,7 +220,7 @@ impl GH for GHApi {
});
if let Some(conclusion) = &check_details.conclusion {
body["conclusion"] = json!(conclusion);
}
};
let _: Value = client.post(url, Some(&body)).await?;
Ok(())
}
@ -235,7 +235,7 @@ impl GH for GHApi {
title: &str,
body: &str,
) -> Result<()> {
let client = self.app_client.installation(InstallationId(inst_id))?;
let client = self.app_client.installation(InstallationId(inst_id));
// Fetch some repository details needed to create a discussion
let response: graphql_client::Response<announcement_repo_query::ResponseData> = client
@ -326,7 +326,7 @@ impl GH for GHApi {
/// [GH::get_collaborators]
async fn get_collaborators(&self, inst_id: u64, owner: &str, repo: &str) -> Result<Vec<UserName>> {
let client = self.app_client.installation(InstallationId(inst_id))?;
let client = self.app_client.installation(InstallationId(inst_id));
let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/collaborators");
let first_page: Page<User> = client.get(url, None::<&()>).await?;
let collaborators = client.all_pages(first_page).await?.into_iter().map(|u| u.login).collect();
@ -341,7 +341,7 @@ impl GH for GHApi {
repo: &str,
comment_id: i64,
) -> Result<Vec<Reaction>> {
let client = self.app_client.installation(InstallationId(inst_id))?;
let client = self.app_client.installation(InstallationId(inst_id));
let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/issues/comments/{comment_id}/reactions",);
let first_page: Page<Reaction> = client.get(url, None::<&()>).await?;
let reactions = client.all_pages(first_page).await?;
@ -350,19 +350,20 @@ impl GH for GHApi {
/// [GH::get_config_file]
async fn get_config_file(&self, inst_id: u64, owner: &str, repo: &str) -> Option<String> {
let Ok(client) = self.app_client.installation(InstallationId(inst_id)) else {
return None;
};
let client = self.app_client.installation(InstallationId(inst_id));
// Try to get the config file from the repository. Otherwise try
// getting the organization wide config file in the .github repo.
let mut content: Option<String> = None;
for repo in &[repo, ORG_CONFIG_REPO] {
if let Ok(resp) = client.repos(owner, *repo).get_content().path(CONFIG_FILE).send().await {
if resp.items.len() == 1 {
content = resp.items[0].decoded_content();
break;
match client.repos(owner, *repo).get_content().path(CONFIG_FILE).send().await {
Ok(resp) => {
if resp.items.len() == 1 {
content = resp.items[0].decoded_content();
break;
}
}
Err(_) => continue,
}
}
@ -371,7 +372,7 @@ impl GH for GHApi {
/// [GH::get_pr_files]
async fn get_pr_files(&self, inst_id: u64, owner: &str, repo: &str, pr_number: i64) -> Result<Vec<File>> {
let client = self.app_client.installation(InstallationId(inst_id))?;
let client = self.app_client.installation(InstallationId(inst_id));
let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/pulls/{pr_number}/files");
let first_page: Page<File> = client.get(url, None::<&()>).await?;
let files: Vec<File> = client.all_pages(first_page).await?;
@ -386,7 +387,7 @@ impl GH for GHApi {
team: &str,
exclude_maintainers: bool,
) -> Result<Vec<UserName>> {
let client = self.app_client.installation(InstallationId(inst_id))?;
let client = self.app_client.installation(InstallationId(inst_id));
let url = format!("{GITHUB_API_URL}/orgs/{org}/teams/{team}/members");
let first_page: Page<User> = client
.get(
@ -403,7 +404,7 @@ impl GH for GHApi {
/// [GH::is_check_required]
async fn is_check_required(&self, inst_id: u64, owner: &str, repo: &str, branch: &str) -> Result<bool> {
let client = self.app_client.installation(InstallationId(inst_id))?;
let client = self.app_client.installation(InstallationId(inst_id));
let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/branches/{branch}");
let branch: Branch = client.get(url, None::<&()>).await?;
let is_check_required = if let Some(required_checks) =
@ -425,7 +426,7 @@ impl GH for GHApi {
issue_number: i64,
body: &str,
) -> Result<i64> {
let client = self.app_client.installation(InstallationId(inst_id))?;
let client = self.app_client.installation(InstallationId(inst_id));
let comment = client.issues(owner, repo).create_comment(issue_number as u64, body).await?;
Ok(comment.id.0 as i64)
}
@ -439,21 +440,14 @@ impl GH for GHApi {
issue_number: i64,
label: &str,
) -> Result<()> {
let client = self.app_client.installation(InstallationId(inst_id))?;
match client.issues(owner, repo).remove_label(issue_number as u64, label).await {
Ok(_) => Ok(()),
Err(octocrab::Error::GitHub { source, backtrace: _ })
if source.message == "Label does not exist" =>
{
Ok(())
}
Err(err) => Err(err.into()),
}
let client = self.app_client.installation(InstallationId(inst_id));
client.issues(owner, repo).remove_label(issue_number as u64, label).await?;
Ok(())
}
/// [GH::user_is_collaborator]
async fn user_is_collaborator(&self, inst_id: u64, owner: &str, repo: &str, user: &str) -> Result<bool> {
let client = self.app_client.installation(InstallationId(inst_id))?;
let client = self.app_client.installation(InstallationId(inst_id));
let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/collaborators/{user}",);
let resp = client._get(url).await?;
if resp.status() == StatusCode::NO_CONTENT {

View File

@ -2,12 +2,11 @@
//! supported endpoints.
use anyhow::{format_err, Error, Result};
use askama::Template;
use axum::{
body::Bytes,
extract::{FromRef, State},
http::{HeaderMap, HeaderValue, StatusCode},
response::{Html, IntoResponse},
response::IntoResponse,
routing::{get, post},
Router,
};
@ -66,11 +65,7 @@ pub(crate) fn setup_router(
/// Handler that returns the index document.
#[allow(clippy::unused_async)]
async fn index() -> impl IntoResponse {
let template = tmpl::Index {};
match template.render() {
Ok(html) => Ok(Html(html)),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
tmpl::Index {}
}
/// Handler that processes webhook events from GitHub.
@ -97,7 +92,7 @@ async fn event(
.is_err()
{
return Err((StatusCode::BAD_REQUEST, "no valid signature found".to_string()));
}
};
// Parse event
let event = match Event::try_from((headers.get(GITHUB_EVENT_HEADER), &body[..])) {
@ -127,7 +122,7 @@ async fn event(
})?;
}
}
}
};
Ok("no command detected")
}
@ -196,7 +191,7 @@ async fn set_check_status(db: DynDB, gh: DynGH, event: &PullRequestEvent) -> Res
gh.create_check_run(inst_id, owner, repo, pr, &check_details).await?;
}
PullRequestEventAction::Other => {}
}
};
Ok(())
}

View File

@ -55,7 +55,7 @@ async fn main() -> Result<()> {
match cfg.log.format {
LogFormat::Json => ts.json().init(),
LogFormat::Pretty => ts.init(),
}
};
// Setup database
let mut builder = SslConnector::builder(SslMethod::tls())?;

View File

@ -45,16 +45,7 @@ const AUTO_CLOSE_FREQUENCY: Duration = Duration::from_secs(60 * 60 * 24);
const GITVOTE_LABEL: &str = "gitvote";
/// Label used to tag issues/prs where a vote is open.
const VOTE_OPEN_LABEL: &str = "gitvote/open";
/// Label used to tag issues/prs where a vote is closed.
const VOTE_CLOSED_LABEL: &str = "gitvote/closed";
/// Label used to tag issues/prs where a vote passed.
const VOTE_PASSED_LABEL: &str = "gitvote/passed";
/// Label used to tag issues/prs where a vote failed.
const VOTE_FAILED_LABEL: &str = "gitvote/failed";
const VOTE_OPEN_LABEL: &str = "vote open";
/// A votes processor is in charge of creating the requested votes, stopping
/// them at the scheduled time and publishing results, etc. It relies on some
@ -219,13 +210,7 @@ impl CommandsHandler {
self.gh.create_check_run(inst_id, owner, repo, i.issue_number, &check_details).await?;
}
// Update issue/pr labels
//
// We try to remove the vote closed and result labels just in case they
// were left from a previous vote that was closed
_ = self.gh.remove_label(inst_id, owner, repo, i.issue_number, VOTE_CLOSED_LABEL).await;
_ = self.gh.remove_label(inst_id, owner, repo, i.issue_number, VOTE_PASSED_LABEL).await;
_ = self.gh.remove_label(inst_id, owner, repo, i.issue_number, VOTE_FAILED_LABEL).await;
// Add "vote open" label to issue/pr
self.gh
.add_labels(
inst_id,
@ -287,8 +272,8 @@ impl CommandsHandler {
self.gh.create_check_run(inst_id, owner, repo, i.issue_number, &check_details).await?;
}
// Update issue/pr labels
_ = self.gh.remove_label(inst_id, owner, repo, i.issue_number, VOTE_OPEN_LABEL).await;
// Remove "vote open" label from issue/pr
self.gh.remove_label(inst_id, owner, repo, i.issue_number, VOTE_OPEN_LABEL).await?;
}
Ok(())
@ -462,17 +447,8 @@ impl VotesCloser {
self.gh.create_check_run(inst_id, owner, repo, vote.issue_number, &check_details).await?;
}
// Update issue/pr labels
let mut labels_to_add = vec![VOTE_CLOSED_LABEL];
if let Some(results) = &results {
if results.passed {
labels_to_add.push(VOTE_PASSED_LABEL);
} else {
labels_to_add.push(VOTE_FAILED_LABEL);
}
}
_ = self.gh.remove_label(inst_id, owner, repo, vote.issue_number, VOTE_OPEN_LABEL).await;
self.gh.add_labels(inst_id, owner, repo, vote.issue_number, &labels_to_add).await?;
// Remove "vote open" label from issue/pr
self.gh.remove_label(inst_id, owner, repo, vote.issue_number, VOTE_OPEN_LABEL).await?;
debug!("closed");
Ok(Some(()))
@ -952,36 +928,6 @@ mod tests {
})
.times(1)
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID))));
gh.expect_remove_label()
.with(
eq(INST_ID),
eq(ORG),
eq(REPO),
eq(ISSUE_NUM),
eq(VOTE_CLOSED_LABEL),
)
.times(1)
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(()))));
gh.expect_remove_label()
.with(
eq(INST_ID),
eq(ORG),
eq(REPO),
eq(ISSUE_NUM),
eq(VOTE_PASSED_LABEL),
)
.times(1)
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(()))));
gh.expect_remove_label()
.with(
eq(INST_ID),
eq(ORG),
eq(REPO),
eq(ISSUE_NUM),
eq(VOTE_FAILED_LABEL),
)
.times(1)
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(()))));
gh.expect_add_labels()
.withf(|inst_id, owner, repo, issue_number, labels| {
*inst_id == INST_ID
@ -1057,36 +1003,6 @@ mod tests {
)
.times(1)
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(()))));
gh.expect_remove_label()
.with(
eq(INST_ID),
eq(ORG),
eq(REPO),
eq(ISSUE_NUM),
eq(VOTE_CLOSED_LABEL),
)
.times(1)
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(()))));
gh.expect_remove_label()
.with(
eq(INST_ID),
eq(ORG),
eq(REPO),
eq(ISSUE_NUM),
eq(VOTE_PASSED_LABEL),
)
.times(1)
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(()))));
gh.expect_remove_label()
.with(
eq(INST_ID),
eq(ORG),
eq(REPO),
eq(ISSUE_NUM),
eq(VOTE_FAILED_LABEL),
)
.times(1)
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(()))));
gh.expect_add_labels()
.withf(|inst_id, owner, repo, issue_number, labels| {
*inst_id == INST_ID
@ -1458,16 +1374,6 @@ mod tests {
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL))
.times(1)
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(()))));
gh.expect_add_labels()
.withf(|inst_id, owner, repo, issue_number, labels| {
*inst_id == INST_ID
&& owner == ORG
&& repo == REPO
&& *issue_number == ISSUE_NUM
&& labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL]
})
.times(1)
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(()))));
let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh));
votes_closer.close_finished_vote().await.unwrap();
@ -1528,16 +1434,6 @@ mod tests {
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL))
.times(1)
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(()))));
gh.expect_add_labels()
.withf(|inst_id, owner, repo, issue_number, labels| {
*inst_id == INST_ID
&& owner == ORG
&& repo == REPO
&& *issue_number == ISSUE_NUM
&& labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL]
})
.times(1)
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(()))));
let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh));
votes_closer.close_finished_vote().await.unwrap();
@ -1602,16 +1498,6 @@ mod tests {
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL))
.times(1)
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(()))));
gh.expect_add_labels()
.withf(|inst_id, owner, repo, issue_number, labels| {
*inst_id == INST_ID
&& owner == ORG
&& repo == REPO
&& *issue_number == ISSUE_NUM
&& labels == vec![VOTE_CLOSED_LABEL, VOTE_FAILED_LABEL]
})
.times(1)
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(()))));
let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh));
votes_closer.close_finished_vote().await.unwrap();

View File

@ -1,6 +1,6 @@
//! This module defines the logic to calculate vote results.
use std::{collections::BTreeMap, fmt};
use std::{collections::HashMap, fmt};
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
@ -113,7 +113,7 @@ pub(crate) struct VoteResults {
pub binding: i64,
pub non_binding: i64,
pub allowed_voters: i64,
pub votes: BTreeMap<UserName, UserVote>,
pub votes: HashMap<UserName, UserVote>,
pub pending_voters: Vec<UserName>,
}
@ -141,7 +141,7 @@ pub(crate) async fn calculate<'a>(
gh.get_allowed_voters(inst_id, &vote.cfg, owner, repo, vote.organization.as_ref()).await?;
// Track users votes
let mut votes: BTreeMap<UserName, UserVote> = BTreeMap::new();
let mut votes: HashMap<UserName, UserVote> = HashMap::new();
let mut multiple_options_voters: Vec<UserName> = Vec::new();
for reaction in reactions {
// Get vote option from reaction
@ -345,7 +345,7 @@ mod tests {
not_voted: 0,
binding: 1,
non_binding: 0,
votes: BTreeMap::from([
votes: HashMap::from([
(
USER1.to_string(),
UserVote {
@ -393,7 +393,7 @@ mod tests {
not_voted: 1,
binding: 0,
non_binding: 0,
votes: BTreeMap::new(),
votes: HashMap::new(),
allowed_voters: 1,
pending_voters: vec![USER1.to_string()],
}
@ -445,7 +445,7 @@ mod tests {
not_voted: 1,
binding: 3,
non_binding: 1,
votes: BTreeMap::from([
votes: HashMap::from([
(
USER1.to_string(),
UserVote {
@ -525,7 +525,7 @@ mod tests {
not_voted: 1,
binding: 3,
non_binding: 0,
votes: BTreeMap::from([
votes: HashMap::from([
(
USER1.to_string(),
UserVote {

View File

@ -1 +0,0 @@
Configuration file not found. Please see <https://github.com/cncf/gitvote#configuration>.

View File

@ -1 +0,0 @@
The requested configuration profile was not found in the configuration file.

View File

@ -1,5 +0,0 @@
Something went wrong while processing the configuration file:
```text
Missing required field: pass_threshold
```

View File

@ -1 +0,0 @@
There is no vote in progress to cancel in this issue @testuser.

View File

@ -1 +0,0 @@
There is no vote in progress to cancel in this pull request @testuser.

View File

@ -1,3 +0,0 @@
## Vote cancelled
@testuser has cancelled the vote in progress in this issue.

View File

@ -1,3 +0,0 @@
## Vote cancelled
@testuser has cancelled the vote in progress in this pull request.

View File

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

View File

@ -1,22 +0,0 @@
The vote for "**Implement RFC-42** (**#123**)" is now closed.
## Vote results
The vote **passed**! 🎉
`66.67%` of the users with binding vote were in favor and `0.00%` were against (passing threshold: `50%`).
### Summary
| In favor | Against | Abstain | Not voted |
| :--------------------: | :-------------------: | :------------------: | :---------------------: |
| 2 | 0 | 1 | 0 |
### Binding votes (3)
| User | Vote | Timestamp |
| ---- | :---: | :-------: |
| @alice | In favor | 2023-01-04 10:00:00.0 +00:00:00 |
| @bob | In favor | 2023-01-04 11:00:00.0 +00:00:00 |
| @charlie | Abstain | 2023-01-04 12:00:00.0 +00:00:00 |

View File

@ -1,22 +0,0 @@
## Vote closed
The vote **did not pass**.
`40.00%` of the users with binding vote were in favor and `60.00%` were against (passing threshold: `50%`).
### Summary
| In favor | Against | Abstain | Not voted |
| :--------------------: | :-------------------: | :------------------: | :---------------------: |
| 2 | 3 | 0 | 0 |
### Binding votes (5)
| User | Vote | Timestamp |
| ---- | :---: | :-------: |
| @alice | Against | 2023-01-02 10:00:00.0 +00:00:00 |
| @bob | In favor | 2023-01-02 11:00:00.0 +00:00:00 |
| @charlie | Against | 2023-01-02 12:00:00.0 +00:00:00 |
| @dave | In favor | 2023-01-02 13:00:00.0 +00:00:00 |
| @eve | Against | 2023-01-02 14:00:00.0 +00:00:00 |

View File

@ -1,31 +0,0 @@
## Vote closed
The vote **passed**! 🎉
`80.00%` of the users with binding vote were in favor and `20.00%` were against (passing threshold: `50%`).
### Summary
| In favor | Against | Abstain | Not voted |
| :--------------------: | :-------------------: | :------------------: | :---------------------: |
| 4 | 1 | 0 | 0 |
### Binding votes (5)
| User | Vote | Timestamp |
| ---- | :---: | :-------: |
| @alice | In favor | 2023-01-01 10:00:00.0 +00:00:00 |
| @bob | In favor | 2023-01-01 11:00:00.0 +00:00:00 |
| @charlie | Against | 2023-01-01 12:00:00.0 +00:00:00 |
| @dave | In favor | 2023-01-01 13:00:00.0 +00:00:00 |
| @eve | In favor | 2023-01-01 14:00:00.0 +00:00:00 |
<details>
<summary><h3>Non-binding votes (2)</h3></summary>
| User | Vote | Timestamp |
| ---- | :---: | :-------: |
| @supporter1 | In favor | 2023-01-01 15:00:00.0 +00:00:00 |
| @supporter2 | In favor | 2023-01-01 16:00:00.0 +00:00:00 |
</details>

View File

@ -1,19 +0,0 @@
## Vote created
**@user** has called for a vote on `Test title` (#1).
All repository collaborators have binding votes.
Non-binding votes are also appreciated as a sign of support!
## How to vote
You can cast your vote by reacting to `this` comment. The following reactions are supported:
| In favor | Against | Abstain |
| :------: | :-----: | :-----: |
| 👍 | 👎 | 👀 |
*Please note that voting for multiple options is not allowed and those votes won't be counted.*
The vote will be open for `1day`. It will pass if at least `75%` of the users with binding votes vote `In favor 👍`. Once it's closed, results will be published here as a new comment.

View File

@ -1,29 +0,0 @@
## Vote created
**@user** has called for a vote on `Add new feature X` (#42).
The members of the following teams have binding votes:
| Team |
| ---- |
| @org/core-team |
| @org/maintainers |
The following users have binding votes:
| User |
| ---- |
| @alice |
| @bob |
Non-binding votes are also appreciated as a sign of support!
## How to vote
You can cast your vote by reacting to `this` comment. The following reactions are supported:
| In favor | Against | Abstain |
| :------: | :-----: | :-----: |
| 👍 | 👎 | 👀 |
*Please note that voting for multiple options is not allowed and those votes won't be counted.*
The vote will be open for `3days`. It will pass if at least `51%` of the users with binding votes vote `In favor 👍`. Once it's closed, results will be published here as a new comment.

View File

@ -1,3 +0,0 @@
There is already a vote in progress in this issue @testuser.
Please wait until it is closed before creating a new one.

View File

@ -1,3 +0,0 @@
There is already a vote in progress in this pull request @testuser.
Please wait until it is closed before creating a new one.

View File

@ -1,3 +0,0 @@
Only repository collaborators can create a vote @testuser.
For organization-owned repositories, the list of collaborators includes outside collaborators, organization members that are direct collaborators, organization members with access through team memberships, organization members with access through default organization permissions, and organization owners.

View File

@ -1,25 +0,0 @@
## Vote status
So far `33.33%` of the users with binding vote are in favor and `0.00%` are against (passing threshold: `50%`).
### Summary
| In favor | Against | Abstain | Not voted |
| :--------------------: | :-------------------: | :------------------: | :---------------------: |
| 1 | 0 | 1 | 1 |
### Binding votes (2)
| User | Vote | Timestamp |
| ---- | :---: | :-------: |
| alice | In favor | 2023-01-03 10:00:00.0 +00:00:00 |
| bob | Abstain | 2023-01-03 11:00:00.0 +00:00:00 |
| @charlie | *Pending* | |
<details>
<summary><h3>Non-binding votes (1)</h3></summary>
| User | Vote | Timestamp |
| ---- | :---: | :-------: |
| supporter | In favor | 2023-01-03 12:00:00.0 +00:00:00 |
</details>

View File

@ -1,6 +1,6 @@
//! This modules defines some test utilities.
use std::{collections::BTreeMap, fs, path::Path, time::Duration};
use std::{collections::HashMap, fs, path::Path, time::Duration};
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
use uuid::Uuid;
@ -166,7 +166,7 @@ pub(crate) fn setup_test_vote_results() -> VoteResults {
not_voted: 0,
binding: 1,
non_binding: 0,
votes: BTreeMap::from([(
votes: HashMap::from([(
USER1.to_string(),
UserVote {
vote_option: VoteOption::InFavor,

View File

@ -207,17 +207,15 @@ impl<'a> VoteStatus<'a> {
}
mod filters {
use std::collections::BTreeMap;
use crate::{github::UserName, results::UserVote};
use std::collections::HashMap;
/// Template filter that returns up to the requested number of non-binding
/// votes from the votes collection provided sorted by timestamp (oldest
/// first).
#[allow(clippy::trivially_copy_pass_by_ref, clippy::unnecessary_wraps)]
pub(crate) fn non_binding(
votes: &BTreeMap<UserName, UserVote>,
_: &dyn askama::Values,
votes: &HashMap<UserName, UserVote>,
max: &i64,
) -> askama::Result<Vec<(UserName, UserVote)>> {
let mut non_binding_votes: Vec<(UserName, UserVote)> =
@ -227,454 +225,3 @@ mod filters {
Ok(non_binding_votes.into_iter().take(*max as usize).collect())
}
}
#[cfg(test)]
mod tests {
use std::{collections::BTreeMap, env, fs};
use askama::Template;
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
use crate::{
cmd::CreateVoteInput,
github::Event,
results::{UserVote, VoteOption, VoteResults},
testutil::*,
};
use super::*;
fn golden_file_path(name: &str) -> String {
format!("{TESTDATA_PATH}/templates/{name}.golden")
}
fn read_golden_file(name: &str) -> String {
let path = golden_file_path(name);
fs::read_to_string(&path).unwrap_or_else(|_| panic!("error reading golden file: {path}"))
}
fn write_golden_file(name: &str, content: &str) {
let path = golden_file_path(name);
fs::write(&path, content).expect("write golden file should succeed");
}
fn check_golden_file(name: &str, actual: &str) {
if env::var("REGENERATE_GOLDEN_FILES").is_ok() {
write_golden_file(name, actual);
} else {
let expected = read_golden_file(name);
assert_eq!(actual, expected, "output does not match golden file ({name})");
}
}
#[test]
fn test_config_not_found() {
let tmpl = ConfigNotFound {};
let output = tmpl.render().unwrap();
check_golden_file("config-not-found", &output);
}
#[test]
fn test_config_profile_not_found() {
let tmpl = ConfigProfileNotFound {};
let output = tmpl.render().unwrap();
check_golden_file("config-profile-not-found", &output);
}
#[test]
fn test_vote_checked_recently() {
let tmpl = VoteCheckedRecently {};
let output = tmpl.render().unwrap();
check_golden_file("vote-checked-recently", &output);
}
#[test]
fn test_invalid_config() {
let tmpl = InvalidConfig::new("Missing required field: pass_threshold");
let output = tmpl.render().unwrap();
check_golden_file("invalid-config", &output);
}
#[test]
fn test_no_vote_in_progress_issue() {
let tmpl = NoVoteInProgress::new("testuser", false);
let output = tmpl.render().unwrap();
check_golden_file("no-vote-in-progress-issue", &output);
}
#[test]
fn test_no_vote_in_progress_pr() {
let tmpl = NoVoteInProgress::new("testuser", true);
let output = tmpl.render().unwrap();
check_golden_file("no-vote-in-progress-pr", &output);
}
#[test]
fn test_vote_cancelled_issue() {
let tmpl = VoteCancelled::new("testuser", false);
let output = tmpl.render().unwrap();
check_golden_file("vote-cancelled-issue", &output);
}
#[test]
fn test_vote_cancelled_pr() {
let tmpl = VoteCancelled::new("testuser", true);
let output = tmpl.render().unwrap();
check_golden_file("vote-cancelled-pr", &output);
}
#[test]
fn test_vote_in_progress_issue() {
let tmpl = VoteInProgress::new("testuser", false);
let output = tmpl.render().unwrap();
check_golden_file("vote-in-progress-issue", &output);
}
#[test]
fn test_vote_in_progress_pr() {
let tmpl = VoteInProgress::new("testuser", true);
let output = tmpl.render().unwrap();
check_golden_file("vote-in-progress-pr", &output);
}
#[test]
fn test_vote_restricted() {
let tmpl = VoteRestricted::new("testuser");
let output = tmpl.render().unwrap();
check_golden_file("vote-restricted", &output);
}
#[test]
fn test_vote_created_all_collaborators() {
let event = Event::Issue(setup_test_issue_event());
let input = CreateVoteInput::new(None, &event);
let cfg = CfgProfile {
duration: std::time::Duration::from_secs(86_400), // 1 day
pass_threshold: 75.0,
..Default::default()
};
let tmpl = VoteCreated::new(&input, &cfg);
let output = tmpl.render().unwrap();
check_golden_file("vote-created-all-collaborators", &output);
}
#[test]
fn test_vote_created_with_teams_and_users() {
let mut event = setup_test_issue_event();
event.issue.title = "Add new feature X".to_string();
event.issue.number = 42;
let event = Event::Issue(event);
let input = CreateVoteInput::new(None, &event);
let cfg = CfgProfile {
duration: std::time::Duration::from_secs(259_200), // 3 days
pass_threshold: 51.0,
allowed_voters: Some(crate::cfg_repo::AllowedVoters {
teams: Some(vec!["core-team".into(), "maintainers".into()]),
users: Some(vec!["alice".into(), "bob".into()]),
exclude_team_maintainers: None,
}),
..Default::default()
};
let tmpl = VoteCreated::new(&input, &cfg);
let output = tmpl.render().unwrap();
check_golden_file("vote-created-with-teams-and-users", &output);
}
#[test]
fn test_vote_closed_passed() {
let mut votes = BTreeMap::new();
votes.insert(
"alice".to_string(),
UserVote {
vote_option: VoteOption::InFavor,
timestamp: OffsetDateTime::parse("2023-01-01T10:00:00Z", &Rfc3339).unwrap(),
binding: true,
},
);
votes.insert(
"bob".to_string(),
UserVote {
vote_option: VoteOption::InFavor,
timestamp: OffsetDateTime::parse("2023-01-01T11:00:00Z", &Rfc3339).unwrap(),
binding: true,
},
);
votes.insert(
"charlie".to_string(),
UserVote {
vote_option: VoteOption::Against,
timestamp: OffsetDateTime::parse("2023-01-01T12:00:00Z", &Rfc3339).unwrap(),
binding: true,
},
);
votes.insert(
"dave".to_string(),
UserVote {
vote_option: VoteOption::InFavor,
timestamp: OffsetDateTime::parse("2023-01-01T13:00:00Z", &Rfc3339).unwrap(),
binding: true,
},
);
votes.insert(
"eve".to_string(),
UserVote {
vote_option: VoteOption::InFavor,
timestamp: OffsetDateTime::parse("2023-01-01T14:00:00Z", &Rfc3339).unwrap(),
binding: true,
},
);
votes.insert(
"supporter1".to_string(),
UserVote {
vote_option: VoteOption::InFavor,
timestamp: OffsetDateTime::parse("2023-01-01T15:00:00Z", &Rfc3339).unwrap(),
binding: false,
},
);
votes.insert(
"supporter2".to_string(),
UserVote {
vote_option: VoteOption::InFavor,
timestamp: OffsetDateTime::parse("2023-01-01T16:00:00Z", &Rfc3339).unwrap(),
binding: false,
},
);
let results = VoteResults {
passed: true,
in_favor_percentage: 80.0,
pass_threshold: 50.0,
in_favor: 4,
against: 1,
against_percentage: 20.0,
abstain: 0,
not_voted: 0,
binding: 5,
non_binding: 2,
allowed_voters: 5,
votes: votes.into_iter().collect(),
pending_voters: vec![],
};
let tmpl = VoteClosed::new(&results);
let output = tmpl.render().unwrap();
check_golden_file("vote-closed-passed", &output);
}
#[test]
fn test_vote_closed_failed() {
let mut votes = BTreeMap::new();
votes.insert(
"alice".to_string(),
UserVote {
vote_option: VoteOption::Against,
timestamp: OffsetDateTime::parse("2023-01-02T10:00:00Z", &Rfc3339).unwrap(),
binding: true,
},
);
votes.insert(
"bob".to_string(),
UserVote {
vote_option: VoteOption::InFavor,
timestamp: OffsetDateTime::parse("2023-01-02T11:00:00Z", &Rfc3339).unwrap(),
binding: true,
},
);
votes.insert(
"charlie".to_string(),
UserVote {
vote_option: VoteOption::Against,
timestamp: OffsetDateTime::parse("2023-01-02T12:00:00Z", &Rfc3339).unwrap(),
binding: true,
},
);
votes.insert(
"dave".to_string(),
UserVote {
vote_option: VoteOption::InFavor,
timestamp: OffsetDateTime::parse("2023-01-02T13:00:00Z", &Rfc3339).unwrap(),
binding: true,
},
);
votes.insert(
"eve".to_string(),
UserVote {
vote_option: VoteOption::Against,
timestamp: OffsetDateTime::parse("2023-01-02T14:00:00Z", &Rfc3339).unwrap(),
binding: true,
},
);
let results = VoteResults {
passed: false,
in_favor_percentage: 40.0,
pass_threshold: 50.0,
in_favor: 2,
against: 3,
against_percentage: 60.0,
abstain: 0,
not_voted: 0,
binding: 5,
non_binding: 0,
allowed_voters: 5,
votes: votes.into_iter().collect(),
pending_voters: vec![],
};
let tmpl = VoteClosed::new(&results);
let output = tmpl.render().unwrap();
check_golden_file("vote-closed-failed", &output);
}
#[test]
fn test_vote_status_in_progress() {
let mut votes = BTreeMap::new();
votes.insert(
"alice".to_string(),
UserVote {
vote_option: VoteOption::InFavor,
timestamp: OffsetDateTime::parse("2023-01-03T10:00:00Z", &Rfc3339).unwrap(),
binding: true,
},
);
votes.insert(
"bob".to_string(),
UserVote {
vote_option: VoteOption::Abstain,
timestamp: OffsetDateTime::parse("2023-01-03T11:00:00Z", &Rfc3339).unwrap(),
binding: true,
},
);
votes.insert(
"supporter".to_string(),
UserVote {
vote_option: VoteOption::InFavor,
timestamp: OffsetDateTime::parse("2023-01-03T12:00:00Z", &Rfc3339).unwrap(),
binding: false,
},
);
let results = VoteResults {
passed: false,
in_favor_percentage: 33.33,
pass_threshold: 50.0,
in_favor: 1,
against: 0,
against_percentage: 0.0,
abstain: 1,
not_voted: 1,
binding: 2,
non_binding: 1,
allowed_voters: 3,
votes: votes.into_iter().collect(),
pending_voters: vec!["charlie".to_string()],
};
let tmpl = VoteStatus::new(&results);
let output = tmpl.render().unwrap();
check_golden_file("vote-status-in-progress", &output);
}
#[test]
fn test_vote_closed_announcement() {
let mut votes = BTreeMap::new();
votes.insert(
"alice".to_string(),
UserVote {
vote_option: VoteOption::InFavor,
timestamp: OffsetDateTime::parse("2023-01-04T10:00:00Z", &Rfc3339).unwrap(),
binding: true,
},
);
votes.insert(
"bob".to_string(),
UserVote {
vote_option: VoteOption::InFavor,
timestamp: OffsetDateTime::parse("2023-01-04T11:00:00Z", &Rfc3339).unwrap(),
binding: true,
},
);
votes.insert(
"charlie".to_string(),
UserVote {
vote_option: VoteOption::Abstain,
timestamp: OffsetDateTime::parse("2023-01-04T12:00:00Z", &Rfc3339).unwrap(),
binding: true,
},
);
let results = VoteResults {
passed: true,
in_favor_percentage: 66.67,
pass_threshold: 50.0,
in_favor: 2,
against: 0,
against_percentage: 0.0,
abstain: 1,
not_voted: 0,
binding: 3,
non_binding: 0,
allowed_voters: 3,
votes: votes.into_iter().collect(),
pending_voters: vec![],
};
let tmpl = VoteClosedAnnouncement::new(123, "Implement RFC-42", &results);
let output = tmpl.render().unwrap();
check_golden_file("vote-closed-announcement", &output);
}
#[test]
fn test_non_binding_filter() {
// Create a dummy struct that implements askama::Values
struct DummyValues;
impl askama::Values for DummyValues {
fn get_value(&self, _: &str) -> Option<&(dyn std::any::Any + 'static)> {
None
}
}
let mut votes = BTreeMap::new();
// Add some binding votes
votes.insert(
"alice".to_string(),
UserVote {
vote_option: VoteOption::InFavor,
timestamp: OffsetDateTime::parse("2023-01-05T10:00:00Z", &Rfc3339).unwrap(),
binding: true,
},
);
// Add non-binding votes with different timestamps
for i in 0..5 {
votes.insert(
format!("supporter{i}"),
UserVote {
vote_option: VoteOption::InFavor,
timestamp: OffsetDateTime::parse(&format!("2023-01-05T{:02}:00:00Z", 11 + i), &Rfc3339)
.unwrap(),
binding: false,
},
);
}
// Test with limit of 3
let dummy_values = DummyValues;
let filtered = filters::non_binding(&votes, &dummy_values, &3).unwrap();
assert_eq!(filtered.len(), 3);
// Verify they are sorted by timestamp
assert_eq!(filtered[0].0, "supporter0");
assert_eq!(filtered[1].0, "supporter1");
assert_eq!(filtered[2].0, "supporter2");
// Test with limit larger than available non-binding votes
let filtered = filters::non_binding(&votes, &dummy_values, &10).unwrap();
assert_eq!(filtered.len(), 5);
}
}

View File

@ -1,7 +1,7 @@
{%- extends "vote-closed.md" -%}
{% block introduction -%}
The vote for "**{{ issue_title }}** (**#{{ issue_number }}**)" is now closed.
{{ "" +}}
{% endblock +%}
{% extends "vote-closed.md" %}
{%- block title %}Vote results{% endblock -%}
{% block introduction %}
The vote for "**{{ issue_title }}** (**#{{ issue_number }}**)" is now closed.
{% endblock %}
{% block title %}Vote results{% endblock %}

View File

@ -1,37 +1,38 @@
{%- block introduction %}{%+ endblock -%}
{% block introduction %}{% endblock %}
## {% block title %}Vote closed{% endblock %}
The vote {% if results.passed %}**passed**! 🎉{% else %}**did not pass**.{% endif %}
`{{ "{:.2}"|format(results.in_favor_percentage) }}%` of the users with binding vote were in favor and `{{ "{:.2}"|format(results.against_percentage) }}%` were against (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.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 %}
{% 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 %}
{% 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 %}
{% endif %}
{{~ "| User | Vote | Timestamp |" }}
{{~ "| ---- | :---: | :-------: |" }}
@ -39,5 +40,6 @@ The vote {% if results.passed %}**passed**! 🎉{% else %}**did not pass**.{% en
| @{{ user }} | {{ vote.vote_option }} | {{ vote.timestamp }} {{ "|" -}}
{% endfor ~%}
</details>
{% endif -%}
{% endif -%}
{% endif %}
{% endif %}

View File

@ -2,7 +2,7 @@
**@{{ creator }}** has called for a vote on `{{ issue_title }}` (#{{ issue_number }}).
{%- if !teams.is_empty() || !users.is_empty() %}
{% if !teams.is_empty() || !users.is_empty() %}
{% if !teams.is_empty() ~%}
The members of the following teams have binding votes:
@ -24,9 +24,9 @@
{% endif -%}
{% else ~%}
{{ " " ~}}
All repository collaborators have binding votes.
{% endif %}
Non-binding votes are also appreciated as a sign of support!
## How to vote

View File

@ -19,13 +19,14 @@ So far `{{ "{:.2}"|format(results.in_favor_percentage) }}%` of the users with bi
{% endfor -%}
{%- for user in results.pending_voters ~%}
| @{{ user }} | *Pending* | {{ "|" -}}
{%- endfor %}
{% endfor -%}
{% 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 %}
{% 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 %}