mirror of https://github.com/cncf/gitvote.git
Compare commits
No commits in common. "main" and "gitvote-chart-1.4.0" have entirely different histories.
main
...
gitvote-ch
|
@ -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 }}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
18
ADOPTERS.md
18
ADOPTERS.md
|
@ -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/)
|
||||
|
|
File diff suppressed because it is too large
Load Diff
56
Cargo.toml
56
Cargo.toml
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
11
src/cmd.rs
11
src/cmd.rs
|
@ -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)]
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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())?;
|
||||
|
|
126
src/processor.rs
126
src/processor.rs
|
@ -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();
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
Configuration file not found. Please see <https://github.com/cncf/gitvote#configuration>.
|
|
@ -1 +0,0 @@
|
|||
The requested configuration profile was not found in the configuration file.
|
|
@ -1,5 +0,0 @@
|
|||
Something went wrong while processing the configuration file:
|
||||
|
||||
```text
|
||||
Missing required field: pass_threshold
|
||||
```
|
|
@ -1 +0,0 @@
|
|||
There is no vote in progress to cancel in this issue @testuser.
|
|
@ -1 +0,0 @@
|
|||
There is no vote in progress to cancel in this pull request @testuser.
|
|
@ -1,3 +0,0 @@
|
|||
## Vote cancelled
|
||||
|
||||
@testuser has cancelled the vote in progress in this issue.
|
|
@ -1,3 +0,0 @@
|
|||
## Vote cancelled
|
||||
|
||||
@testuser has cancelled the vote in progress in this pull request.
|
|
@ -1 +0,0 @@
|
|||
Votes can only be checked once a day.
|
|
@ -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 |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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>
|
|
@ -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,
|
||||
|
|
457
src/tmpl.rs
457
src/tmpl.rs
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
Loading…
Reference in New Issue