Compare commits

...

26 Commits

Author SHA1 Message Date
Sergio Castaño Arteaga 8e7eafba8d
Upgrade dependencies and base images (#628)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
2025-06-13 13:50:40 +02:00
Sergio Castaño Arteaga 9b4063c6f8
More labeling improvements (#627)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
2025-06-12 13:46:28 +02:00
Sergio Castaño Arteaga 426286cf74
Improve labeling (#626)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
2025-06-12 11:14:00 +02:00
Sergio Castaño Arteaga 9183e10d6a
Upgrade dependencies and base images (#617)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
2025-05-12 08:10:27 +02:00
Sergio Castaño Arteaga 3915b82736
Update ADOPTERS.md (#613)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
2025-05-01 09:54:50 +02:00
Sergio Castaño Arteaga 38a1ef34ce
Update runner image (#611)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
2025-04-23 09:46:14 +02:00
Yacine Kheddache c4d5cbeb8e
Update ADOPTERS.md (#610)
Add Microcks 
Fix #609

Signed-off-by: Yacine Kheddache <yacine@microcks.io>
2025-04-22 18:51:47 +02:00
Sergio Castaño Arteaga 216216c007
Upgrade dependencies and base images (#598)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
2025-03-17 11:35:32 +01:00
Sergio Castaño Arteaga fbdb69ced9
Update list of adopters (#572)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
2024-12-16 08:44:00 +01:00
Sergio Castaño Arteaga ff0ccdc9a7
Bump version to 1.4.0 (#571)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
2024-12-13 09:18:36 +01:00
Sergio Castaño Arteaga 278c1134a0
Add some temporary extra debug information (#570)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
2024-12-11 13:48:04 +01:00
Sergio Castaño Arteaga bfe7209197
Bump Rust to 1.83 (#569)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
2024-12-10 10:24:00 +01:00
Sergio Castaño Arteaga 8c36a74f79
Upgrade dependencies and base images (#567)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
2024-12-10 09:48:11 +01:00
Sergio Castaño Arteaga 1778be36dc
Upgrade dependencies (#559) 2024-11-29 13:41:34 +01:00
Sergio Castaño Arteaga 2c2f0627d5
Migrate service config to figment (#558)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
2024-11-29 13:14:50 +01:00
Sergio Castaño Arteaga 0dad63922d
Some refactoring in votes processor (#557)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
2024-11-29 12:56:34 +01:00
Daniel Castaño Sánchez 577c3d50a7
Add min wait support to close on passing (#555)
Signed-off-by: Daniel Castaño Sánchez <danielcastanosanchez@icloud.com>
2024-11-21 08:56:13 +01:00
dependabot[bot] 3543eec09d
Bump golang from 1.23.2-alpine3.20 to 1.23.3-alpine3.20 in /database/migrations (#551)
Bump golang in /database/migrations

Bumps golang from 1.23.2-alpine3.20 to 1.23.3-alpine3.20.

---
updated-dependencies:
- dependency-name: golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-12 07:51:08 +01:00
Sergio Castaño Arteaga e9b07333d1
Upgrade dependencies (#548)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
2024-10-28 10:40:26 +01:00
Davanum Srinivas ea0863fc9b
Display how many folks are against the vote as well (#546)
Signed-off-by: Davanum Srinivas <davanum@gmail.com>
2024-10-15 14:37:35 +02:00
dependabot[bot] 4636393256
Bump golang from 1.23.1-alpine3.20 to 1.23.2-alpine3.20 in /database/migrations (#544)
Bump golang in /database/migrations

Bumps golang from 1.23.1-alpine3.20 to 1.23.2-alpine3.20.

---
updated-dependencies:
- dependency-name: golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-08 08:59:54 +02:00
Sergio Castaño Arteaga b6dc2083be
Upgrade dependencies (#540)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
2024-09-30 10:32:00 +02:00
dependabot[bot] 9ac717ac30
Bump golang from 1.23.0-alpine3.20 to 1.23.1-alpine3.20 in /database/migrations (#536)
Bump golang in /database/migrations

Bumps golang from 1.23.0-alpine3.20 to 1.23.1-alpine3.20.

---
updated-dependencies:
- dependency-name: golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-17 08:07:08 +02:00
Sergio Castaño Arteaga dc419b2471
Upgrade dependencies and base images (#535)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
2024-09-13 13:44:18 +02:00
Chris Aniszczyk f9ea1811c7
Update ADOPTERS.md (#527)
Add AsyncAPI as an Adopter

https://github.com/asyncapi/community/blob/master/voting.md

Signed-off-by: Chris Aniszczyk <caniszczyk@gmail.com>
2024-07-30 11:49:28 -05:00
Sergio Castaño Arteaga 9e0ebf3649
Upgrade dependencies and base images (#525)
Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
2024-07-26 21:17:24 +02:00
24 changed files with 1476 additions and 1190 deletions

View File

@ -9,7 +9,7 @@ permissions: read-all
jobs:
lint-and-test:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
@ -24,7 +24,7 @@ jobs:
with:
python-version: 3.7
- name: Set up chart-testing
uses: helm/chart-testing-action@v2.6.1
uses: helm/chart-testing-action@v2.7.0
- 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.10.0
uses: helm/kind-action@v1.12.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.76.0
toolchain: 1.87.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.75.0
toolchain: 1.87.0
- name: Run backend tests
run: cargo test

View File

@ -9,7 +9,7 @@ permissions: read-all
jobs:
build-and-publish-images:
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

View File

@ -2,10 +2,26 @@
If your organization is using GitVote, please consider adding it to this list by submitting a pull request.
- [AWS Labs](https://github.com/awslabs)
- [AsyncAPI](https://github.com/asyncapi/community/blob/master/voting.md)
- [CloudNativePG](https://cloudnative-pg.io)
- [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/)

1219
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,59 +1,59 @@
[package]
name = "gitvote"
description = "GitVote server"
version = "1.3.0"
version = "1.4.0"
license = "Apache-2.0"
edition = "2021"
rust-version = "1.87"
[dependencies]
anyhow = "1.0.86"
askama = "0.12.1"
askama_axum = "0.4.0"
anyhow = "1.0.98"
askama = { version = "0.14.0", features = ["serde_json"] }
async-channel = "2.3.1"
async-trait = "0.1.80"
axum = { version = "0.7.5", features = ["macros"] }
clap = { version = "4.5.7", features = ["derive"] }
config = "0.13.4"
deadpool-postgres = { version = "0.14.0", features = ["serde"] }
futures = "0.3.30"
async-trait = "0.1.88"
axum = { version = "0.8.4", features = ["macros"] }
clap = { version = "4.5.40", features = ["derive"] }
deadpool-postgres = { version = "0.14.1", 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 = "0.2.12"
humantime = "2.1.0"
http = "1.3.1"
humantime = "2.2.0"
humantime-serde = "1.1.1"
ignore = "0.4.22"
jsonwebtoken = "9.3.0"
lazy_static = "1.5.0"
octocrab = "=0.33.3"
openssl = { version = "0.10.64", features = ["vendored"] }
postgres-openssl = "0.5.0"
regex = "1.10.5"
reqwest = "0.12.5"
serde = { version = "1.0.203", features = ["derive"] }
serde_json = "1.0.118"
ignore = "0.4.23"
jsonwebtoken = "9.3.1"
octocrab = "0.44.1"
openssl = { version = "0.10.73", features = ["vendored"] }
postgres-openssl = "0.5.1"
regex = "1.11.1"
reqwest = "0.12.20"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
serde_yaml = "0.9.34"
sha2 = "0.10.8"
thiserror = "1.0.61"
time = { version = "0.3.36", features = ["serde"] }
tokio = { version = "1.38.0", features = [
sha2 = "0.10.9"
thiserror = "2.0.12"
time = { version = "0.3.41", features = ["serde"] }
tokio = { version = "1.45.1", features = [
"macros",
"rt-multi-thread",
"signal",
"time",
] }
tokio-postgres = { version = "0.7.10", features = [
tokio-postgres = { version = "0.7.13", features = [
"with-uuid-1",
"with-serde_json-1",
"with-time-0_3",
] }
tower = "0.4.13"
tower-http = { version = "0.5.2", features = ["trace"] }
tokio-util = { version = "0.7.15", features = ["rt"] }
tower = { version = "0.5.2", features = ["util"] }
tower-http = { version = "0.6.6", features = ["trace"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json"] }
uuid = { version = "1.9.1", features = ["serde", "v4"] }
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] }
uuid = { version = "1.17.0", features = ["serde", "v4"] }
[dev-dependencies]
http-body = "1.0.0"
hyper = "1.3.1"
mockall = "0.12.1"
http-body = "1.0.1"
hyper = "1.6.0"
mockall = "0.13.1"

View File

@ -1,5 +1,5 @@
# Build gitvote
FROM rust:1-alpine3.20 as builder
FROM rust:1-alpine3.22 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.20.1
FROM alpine:3.22.0
RUN apk --no-cache add ca-certificates && addgroup -S gitvote && adduser -S gitvote -G gitvote
USER gitvote
WORKDIR /home/gitvote

View File

@ -2,8 +2,8 @@ apiVersion: v2
name: gitvote
description: GitVote is a GitHub application that allows holding a vote on issues and pull requests
type: application
version: 1.3.0
appVersion: 1.3.0
version: 1.4.0
appVersion: 1.4.0
kubeVersion: ">= 1.19.0-0"
home: https://gitvote.dev
icon: https://raw.githubusercontent.com/cncf/gitvote/main/docs/logo/logo.png
@ -25,19 +25,25 @@ annotations:
artifacthub.io/category: skip-prediction
artifacthub.io/changes: |
- kind: added
description: Support for GitHub discussions announcements
description: Minimum wait support to close on passing
- kind: added
description: Webhook secret fallback for key rotation
description: Display percentage of voters against the vote
- kind: changed
description: Bump Alpine to 3.20.1
description: Migrate service config to figment
- kind: changed
description: Some refactoring in votes processor
- kind: changed
description: Bump Alpine to 3.21.0
- kind: changed
description: Bump Rust to 1.83
- kind: changed
description: Upgrade dependencies
artifacthub.io/containsSecurityUpdates: "true"
artifacthub.io/images: |
- name: dbmigrator
image: public.ecr.aws/g6m3a0y9/gitvote-dbmigrator:v1.3.0
image: public.ecr.aws/g6m3a0y9/gitvote-dbmigrator:v1.4.0
- name: gitvote
image: public.ecr.aws/g6m3a0y9/gitvote:v1.3.0
image: public.ecr.aws/g6m3a0y9/gitvote:v1.4.0
artifacthub.io/links: |
- name: source
url: https://github.com/cncf/gitvote

View File

@ -8,14 +8,14 @@ stringData:
addr: {{ .Values.gitvote.addr }}
db:
host: {{ default (printf "%s-postgresql.%s" .Release.Name .Release.Namespace) .Values.db.host }}
port: {{ .Values.db.port }}
port: {{ .Values.db.port | atoi }}
dbname: {{ .Values.db.dbname }}
user: {{ .Values.db.user }}
password: {{ .Values.db.password }}
log:
format: {{ .Values.log.format }}
github:
appID: {{ .Values.gitvote.github.appID }}
appId: {{ .Values.gitvote.github.appID }}
appPrivateKey: {{ .Values.gitvote.github.appPrivateKey | quote }}
webhookSecret: {{ .Values.gitvote.github.webhookSecret | quote }}
{{- with .Values.gitvote.github.webhookSecretFallback }}

View File

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

View File

@ -136,6 +136,23 @@ profiles:
#
close_on_passing: false
# Close on passing minimum wait
# 
# When the close on passing feature is activated, voting will conclude once
# the pass threshold is met. However, there may be instances where it is
# preferable to implement a minimum wait time, even if the vote would
# already pass. This allows participants sufficient opportunity to engage
# and reflect before the vote is automatically finalized.
#
# Units supported:
#
# - day / days
# - week / weeks 
#
# close_on_passing_min_wait: "1 week"
#
close_on_passing_min_wait: null
# Announcements
#
# GitVote can announce the results of a vote when it is closed on GitHub

View File

@ -1,10 +1,16 @@
use crate::github::{DynGH, File, TeamSlug, UserName};
//! This module defines some types and functionality to represent and process
//! the `GitVote` configuration that GitHub repositories can use to enable and
//! customize the service.
use std::{collections::HashMap, time::Duration};
use anyhow::{bail, Result};
use ignore::gitignore::GitignoreBuilder;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, time::Duration};
use thiserror::Error;
use crate::github::{DynGH, File, TeamSlug, UserName};
/// Default configuration profile.
const DEFAULT_PROFILE: &str = "default";
@ -15,7 +21,7 @@ const ERR_TEAMS_NOT_ALLOWED: &str = "teams in allowed voters can only be used in
/// Type alias to represent a profile name.
type ProfileName = String;
/// GitVote configuration.
/// `GitVote` configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub(crate) struct Cfg {
#[serde(skip_serializing_if = "Option::is_none")]
@ -24,7 +30,7 @@ pub(crate) struct Cfg {
}
impl Cfg {
/// Get the GitVote configuration for the repository provided.
/// Get the `GitVote` configuration for the repository provided.
pub(crate) async fn get<'a>(
gh: DynGH,
inst_id: u64,
@ -84,6 +90,8 @@ pub(crate) struct CfgProfile {
pub periodic_status_check: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub close_on_passing: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub close_on_passing_min_wait: Option<String>,
}
impl CfgProfile {
@ -163,12 +171,15 @@ pub(crate) enum CfgError {
#[cfg(test)]
mod tests {
use super::*;
use crate::github::MockGH;
use crate::testutil::*;
use std::sync::Arc;
use futures::future;
use mockall::predicate::eq;
use std::sync::Arc;
use crate::github::MockGH;
use crate::testutil::*;
use super::*;
#[test]
fn automation_rule_matches() {

58
src/cfg_svc.rs Normal file
View File

@ -0,0 +1,58 @@
//! This module defines some types and functionality to represent and process
//! the `GitVote` service configuration.
use std::path::Path;
use anyhow::Result;
use deadpool_postgres::Config as Db;
use figment::{
providers::{Env, Format, Serialized, Yaml},
Figment,
};
use serde::{Deserialize, Serialize};
/// Server configuration.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct Cfg {
pub addr: String,
pub db: Db,
pub github: GitHubApp,
pub log: Log,
}
impl Cfg {
/// Create a new Cfg instance.
pub(crate) fn new(config_file: &Path) -> Result<Self> {
Figment::new()
.merge(Serialized::default("addr", "127.0.0.1:9000"))
.merge(Serialized::default("log.format", "pretty"))
.merge(Yaml::file(config_file))
.merge(Env::prefixed("GITVOTE_").split("_").lowercase(false))
.extract()
.map_err(Into::into)
}
}
/// Logs configuration.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub(crate) struct Log {
pub format: LogFormat,
}
/// Format to use in logs.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[serde(rename_all(deserialize = "lowercase"))]
pub(crate) enum LogFormat {
Json,
Pretty,
}
/// GitHub application configuration.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all(deserialize = "camelCase"))]
pub struct GitHubApp {
pub app_id: i64,
pub app_private_key: String,
pub webhook_secret: String,
pub webhook_secret_fallback: Option<String>,
}

View File

@ -1,23 +1,28 @@
use crate::{
cfg::{Cfg, CfgError},
github::*,
};
//! This module defines the commands supported and the logic to parse them from
//! 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::{
cfg_repo::{Cfg, CfgError},
github::{
split_full_name, DynGH, Event, IssueCommentEventAction, IssueEventAction, PullRequestEventAction,
},
};
/// Available commands.
const CMD_CREATE_VOTE: &str = "vote";
const CMD_CANCEL_VOTE: &str = "cancel-vote";
const CMD_CHECK_VOTE: &str = "check-vote";
lazy_static! {
/// Regex used to detect commands in issues/prs comments.
static ref CMD: Regex = Regex::new(r"(?m)^/(vote|cancel-vote|check-vote)-?([a-zA-Z0-9]*)\s*$")
.expect("invalid CMD regexp");
}
/// 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")
});
/// Represents a command to be executed, usually created from a GitHub event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -147,7 +152,7 @@ pub(crate) struct CreateVoteInput {
}
impl CreateVoteInput {
/// Create a new CreateVoteInput instance from the profile and event
/// Create a new `CreateVoteInput` instance from the profile and event
/// provided.
pub(crate) fn new(profile_name: Option<&str>, event: &Event) -> Self {
match event {
@ -199,7 +204,7 @@ pub(crate) struct CancelVoteInput {
}
impl CancelVoteInput {
/// Create a new CancelVoteInput instance from the event provided.
/// Create a new `CancelVoteInput` instance from the event provided.
pub(crate) fn new(event: &Event) -> Self {
match event {
Event::Issue(event) => Self {
@ -235,7 +240,7 @@ pub(crate) struct CheckVoteInput {
}
impl CheckVoteInput {
/// Create a new CheckVoteInput instance from the event provided.
/// Create a new `CheckVoteInput` instance from the event provided.
pub(crate) fn new(event: &Event) -> Self {
match event {
Event::Issue(event) => Self {
@ -256,11 +261,17 @@ impl CheckVoteInput {
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use std::{sync::Arc, vec};
use futures::future;
use mockall::predicate::eq;
use std::{sync::Arc, vec};
use crate::{
github::{File, MockGH},
testutil::*,
};
use super::*;
#[test]
fn manual_command_from_issue_event_unsupported_action() {

View File

@ -1,18 +1,22 @@
use crate::{
cfg::CfgProfile,
cmd::{CheckVoteInput, CreateVoteInput},
github::{self, split_full_name, DynGH},
results::{self, Vote, VoteResults},
};
//! This module defines an abstraction layer over the database.
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use deadpool_postgres::{Pool, Transaction};
#[cfg(test)]
use mockall::automock;
use std::sync::Arc;
use tokio_postgres::types::Json;
use uuid::Uuid;
use crate::{
cfg_repo::CfgProfile,
cmd::{CheckVoteInput, CreateVoteInput},
github::{self, split_full_name, DynGH},
results::{self, Vote, VoteResults},
};
/// Type alias to represent a DB trait object.
pub(crate) type DynDB = Arc<dyn DB + Send + Sync>;
@ -56,13 +60,13 @@ pub(crate) trait DB {
async fn update_vote_last_check(&self, vote_id: Uuid) -> Result<()>;
}
/// DB implementation backed by PostgreSQL.
/// DB implementation backed by `PostgreSQL`.
pub(crate) struct PgDB {
pool: Pool,
}
impl PgDB {
/// Create a new PgDB instance.
/// Create a new `PgDB` instance.
pub(crate) fn new(pool: Pool) -> Self {
Self { pool }
}
@ -91,7 +95,7 @@ impl PgDB {
async fn store_vote_results(
tx: &Transaction<'_>,
vote_id: Uuid,
results: &Option<VoteResults>,
results: Option<&VoteResults>,
) -> Result<()> {
tx.execute(
"
@ -154,7 +158,7 @@ impl DB for PgDB {
};
// Store results in database
PgDB::store_vote_results(&tx, vote.vote_id, &results).await?;
PgDB::store_vote_results(&tx, vote.vote_id, results.as_ref()).await?;
tx.commit().await?;
Ok(Some((vote, results)))
@ -190,6 +194,12 @@ impl DB for PgDB {
where closed = false
and cfg ? 'close_on_passing'
and (cfg->>'close_on_passing')::boolean = true
and
case
when cfg ? 'close_on_passing_min_wait' and string_to_interval(cfg->>'close_on_passing_min_wait') is not null then
current_timestamp > created_at + (cfg->>'close_on_passing_min_wait')::interval
else true
end
",
&[],
)

View File

@ -1,4 +1,7 @@
use crate::cfg::CfgProfile;
//! This module defines an abstraction layer over the GitHub API.
use std::sync::Arc;
use anyhow::{bail, Context, Error, Result};
use async_trait::async_trait;
use axum::http::HeaderValue;
@ -9,9 +12,10 @@ use mockall::automock;
use octocrab::{models::InstallationId, Octocrab, Page};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::Arc;
use thiserror::Error;
use crate::cfg_repo::CfgProfile;
/// GitHub API base url.
const GITHUB_API_URL: &str = "https://api.github.com";
@ -56,6 +60,7 @@ pub struct CreateDiscussion;
/// Trait that defines some operations a GH implementation must support.
#[async_trait]
#[allow(clippy::ref_option_ref)]
#[cfg_attr(test, automock)]
pub(crate) trait GH {
/// Add labels to the provided issue.
@ -96,7 +101,7 @@ pub(crate) trait GH {
cfg: &CfgProfile,
owner: &str,
repo: &str,
org: &Option<String>,
org: Option<&String>,
) -> Result<Vec<UserName>>;
/// Get all repository collaborators.
@ -169,7 +174,7 @@ pub(crate) struct GHApi {
}
impl GHApi {
/// Create a new GHApi instance.
/// Create a new `GHApi` instance.
pub(crate) fn new(app_client: Octocrab) -> Self {
Self { app_client }
}
@ -186,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(())
@ -201,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!({
@ -215,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(())
}
@ -230,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
@ -270,7 +275,7 @@ impl GH for GHApi {
cfg: &CfgProfile,
owner: &str,
repo: &str,
org: &Option<String>,
org: Option<&String>,
) -> Result<Vec<UserName>> {
let mut allowed_voters: Vec<UserName> = vec![];
@ -321,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();
@ -336,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?;
@ -345,20 +350,19 @@ impl GH for GHApi {
/// [GH::get_config_file]
async fn get_config_file(&self, inst_id: u64, owner: &str, repo: &str) -> Option<String> {
let client = self.app_client.installation(InstallationId(inst_id));
let Ok(client) = self.app_client.installation(InstallationId(inst_id)) else {
return None;
};
// 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] {
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;
}
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;
}
Err(_) => continue,
}
}
@ -367,7 +371,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?;
@ -382,7 +386,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(
@ -399,7 +403,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) =
@ -421,7 +425,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)
}
@ -435,14 +439,21 @@ impl GH for GHApi {
issue_number: i64,
label: &str,
) -> Result<()> {
let client = self.app_client.installation(InstallationId(inst_id));
client.issues(owner, repo).remove_label(issue_number as u64, label).await?;
Ok(())
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()),
}
}
/// [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

@ -1,21 +1,32 @@
use crate::{cmd::Command, db::DynDB, github::*, tmpl};
//! This module defines the handlers used to process HTTP requests to the
//! supported endpoints.
use anyhow::{format_err, Error, Result};
use askama::Template;
use axum::{
body::Bytes,
extract::{FromRef, State},
http::{HeaderMap, HeaderValue, StatusCode},
response::IntoResponse,
response::{Html, IntoResponse},
routing::{get, post},
Router,
};
use config::{Config, ConfigError};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::sync::Arc;
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
use tracing::{error, instrument, trace};
use crate::{
cfg_svc::Cfg,
cmd::Command,
db::DynDB,
github::{
split_full_name, CheckDetails, DynGH, Event, EventError, PullRequestEvent, PullRequestEventAction,
},
tmpl,
};
/// Header representing the kind of the event received.
const GITHUB_EVENT_HEADER: &str = "X-GitHub-Event";
@ -34,21 +45,12 @@ struct RouterState {
/// Setup HTTP server router.
pub(crate) fn setup_router(
cfg: &Arc<Config>,
cfg: &Cfg,
db: DynDB,
gh: DynGH,
cmds_tx: async_channel::Sender<Command>,
) -> Result<Router> {
// Setup webhook secret
let webhook_secret = cfg.get_string("github.webhookSecret")?;
let webhook_secret_fallback = match cfg.get_string("github.webhookSecretFallback") {
Ok(secret) => Some(secret),
Err(ConfigError::NotFound(_)) => None,
Err(err) => return Err(err.into()),
};
// Setup router
let router = Router::new()
) -> Router {
Router::new()
.route("/", get(index))
.route("/api/events", post(event))
.layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()))
@ -56,17 +58,19 @@ pub(crate) fn setup_router(
db,
gh,
cmds_tx,
webhook_secret,
webhook_secret_fallback,
});
Ok(router)
webhook_secret: cfg.github.webhook_secret.clone(),
webhook_secret_fallback: cfg.github.webhook_secret_fallback.clone(),
})
}
/// Handler that returns the index document.
#[allow(clippy::unused_async)]
async fn index() -> impl IntoResponse {
tmpl::Index {}
let template = tmpl::Index {};
match template.render() {
Ok(html) => Ok(Html(html)),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
/// Handler that processes webhook events from GitHub.
@ -93,7 +97,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[..])) {
@ -104,7 +108,7 @@ async fn event(
Err(EventError::InvalidBody(err)) => {
return Err((StatusCode::BAD_REQUEST, EventError::InvalidBody(err).to_string()))
}
Err(EventError::UnsupportedEvent) => return Ok(()),
Err(EventError::UnsupportedEvent) => return Ok("unsupported event"),
};
trace!(?event, "event received");
@ -113,6 +117,7 @@ async fn event(
Some(cmd) => {
trace!(?cmd, "command detected");
cmds_tx.send(cmd).await.unwrap();
return Ok("command queued");
}
None => {
if let Event::PullRequest(event) = event {
@ -122,9 +127,9 @@ async fn event(
})?;
}
}
};
}
Ok(())
Ok("no command detected")
}
/// Verify that the signature provided is valid.
@ -161,8 +166,8 @@ fn verify_signature(
/// Set a success check status to the pull request referenced in the event
/// provided when it's created or synchronized if no vote has been created on
/// it yet. This makes it possible to use the GitVote check in combination with
/// branch protection.
/// it yet. This makes it possible to use the `GitVote` check in combination
/// with branch protection.
async fn set_check_status(db: DynDB, gh: DynGH, event: &PullRequestEvent) -> Result<()> {
let (owner, repo) = split_full_name(&event.repository.full_name);
let inst_id = event.installation.id as u64;
@ -191,28 +196,33 @@ 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(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use crate::{cmd::CreateVoteInput, db::MockDB};
use std::sync::Arc;
use std::{fs, path::Path};
use async_channel::Receiver;
use axum::body::to_bytes;
use axum::{
body::Body,
body::{to_bytes, Body},
http::{header::CONTENT_TYPE, Request},
};
use figment::{providers::Serialized, Figment};
use futures::future;
use hyper::Response;
use mockall::predicate::eq;
use std::{fs, path::Path};
use tower::ServiceExt;
use crate::github::MockGH;
use crate::testutil::*;
use crate::{cmd::CreateVoteInput, db::MockDB};
use super::*;
#[tokio::test]
async fn index() {
let (router, _) = setup_test_router();
@ -427,7 +437,7 @@ mod tests {
#[tokio::test]
async fn event_pr_without_cmd_set_check_status_failed() {
let cfg = Arc::new(setup_test_config());
let cfg = setup_test_config();
let db = Arc::new(MockDB::new());
let mut gh = MockGH::new();
gh.expect_get_config_file()
@ -440,7 +450,7 @@ mod tests {
.returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR)))));
let gh = Arc::new(gh);
let (cmds_tx, cmds_rx) = async_channel::unbounded();
let router = setup_router(&cfg, db, gh, cmds_tx).unwrap();
let router = setup_router(&cfg, db, gh, cmds_tx);
let body = fs::read(Path::new(TESTDATA_PATH).join("event-pr-no-cmd.json")).unwrap();
let response = router
@ -600,15 +610,23 @@ mod tests {
}
fn setup_test_router() -> (Router, Receiver<Command>) {
let cfg = Arc::new(setup_test_config());
let cfg = setup_test_config();
let db = Arc::new(MockDB::new());
let gh = Arc::new(MockGH::new());
let (cmds_tx, cmds_rx) = async_channel::unbounded();
(setup_router(&cfg, db, gh, cmds_tx).unwrap(), cmds_rx)
(setup_router(&cfg, db, gh, cmds_tx), cmds_rx)
}
fn setup_test_config() -> Config {
Config::builder().set_default("github.webhookSecret", "secret").unwrap().build().unwrap()
fn setup_test_config() -> Cfg {
Figment::new()
.merge(Serialized::default("addr", "127.0.0.1:9000"))
.merge(Serialized::default("db.host", "127.0.0.1"))
.merge(Serialized::default("log.format", "pretty"))
.merge(Serialized::default("github.appId", 1234))
.merge(Serialized::default("github.appPrivateKey", "key"))
.merge(Serialized::default("github.webhookSecret", "secret"))
.extract()
.unwrap()
}
async fn get_body(response: Response<Body>) -> Bytes {

View File

@ -1,27 +1,27 @@
#![warn(clippy::all, clippy::pedantic)]
#![allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::doc_markdown,
clippy::wildcard_imports
)]
#![allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
use crate::{db::PgDB, github::GHApi};
use anyhow::{Context, Result};
use clap::Parser;
use config::{Config, File};
use deadpool_postgres::{Config as DbConfig, Runtime};
use deadpool_postgres::Runtime;
use octocrab::Octocrab;
use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode};
use postgres_openssl::MakeTlsConnector;
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
use tokio::{net::TcpListener, signal, sync::broadcast};
use tokio::{net::TcpListener, signal};
use tokio_util::sync::CancellationToken;
use tracing::{debug, info};
use tracing_subscriber::EnvFilter;
mod cfg;
use crate::{
cfg_svc::{Cfg, LogFormat},
db::PgDB,
github::GHApi,
};
mod cfg_repo;
mod cfg_svc;
mod cmd;
mod db;
mod github;
@ -45,56 +45,49 @@ async fn main() -> Result<()> {
let args = Args::parse();
// Setup configuration
let cfg = Config::builder()
.set_default("log.format", "pretty")?
.set_default("addr", "127.0.0.1:9000")?
.add_source(File::from(args.config))
.build()
.context("error setting up configuration")?;
let cfg = Arc::new(cfg);
let cfg = Cfg::new(&args.config).context("error setting up configuration")?;
// Setup logging
if std::env::var_os("RUST_LOG").is_none() {
std::env::set_var("RUST_LOG", "gitvote=debug");
}
let s = tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env());
match cfg.get_string("log.format").as_deref() {
Ok("json") => s.json().init(),
_ => s.init(),
};
let ts = tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env());
match cfg.log.format {
LogFormat::Json => ts.json().init(),
LogFormat::Pretty => ts.init(),
}
// Setup database
let mut builder = SslConnector::builder(SslMethod::tls())?;
builder.set_verify(SslVerifyMode::NONE);
let connector = MakeTlsConnector::new(builder.build());
let db_cfg: DbConfig = cfg.get("db")?;
let pool = db_cfg.create_pool(Some(Runtime::Tokio1), connector)?;
let pool = cfg.db.create_pool(Some(Runtime::Tokio1), connector)?;
let db = Arc::new(PgDB::new(pool));
// Setup GitHub client
let app_id = cfg.get_int("github.appID")? as u64;
let app_private_key = cfg.get_string("github.appPrivateKey")?;
let app_id = cfg.github.app_id as u64;
let app_private_key = cfg.github.app_private_key.clone();
let app_private_key = jsonwebtoken::EncodingKey::from_rsa_pem(app_private_key.as_bytes())?;
let app_client = Octocrab::builder().app(app_id.into(), app_private_key).build()?;
let gh = Arc::new(GHApi::new(app_client));
// Setup and launch votes processor
let (cmds_tx, cmds_rx) = async_channel::unbounded();
let (stop_tx, _): (broadcast::Sender<()>, _) = broadcast::channel(1);
let votes_processor = processor::Processor::new(db.clone(), gh.clone());
let votes_processor_done = votes_processor.start(cmds_tx.clone(), &cmds_rx, &stop_tx);
let cancel_token = CancellationToken::new();
let votes_processor = processor::Processor::new(db.clone(), gh.clone(), cmds_tx.clone(), cmds_rx);
let votes_processor_tasks = votes_processor.run(&cancel_token);
debug!("[votes processor] started");
// Setup and launch HTTP server
let router = handlers::setup_router(&cfg, db, gh, cmds_tx)?;
let addr: SocketAddr = cfg.get_string("addr")?.parse()?;
let router = handlers::setup_router(&cfg, db, gh, cmds_tx);
let addr: SocketAddr = cfg.addr.parse()?;
let listener = TcpListener::bind(addr).await?;
info!(%addr, "gitvote service started");
axum::serve(listener, router).with_graceful_shutdown(shutdown_signal()).await.unwrap();
// Ask votes processor to stop and wait for it to finish
drop(stop_tx);
votes_processor_done.await;
cancel_token.cancel();
votes_processor_tasks.await;
debug!("[votes processor] stopped");
info!("gitvote service stopped");

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,18 @@
use crate::{
cfg::CfgProfile,
github::{DynGH, UserName},
};
//! This module defines the logic to calculate vote results.
use std::{collections::HashMap, fmt};
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fmt};
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
use tokio_postgres::{types::Json, Row};
use uuid::Uuid;
use crate::{
cfg_repo::CfgProfile,
github::{DynGH, UserName},
};
/// Supported reactions.
pub(crate) const REACTION_IN_FAVOR: &str = "+1";
pub(crate) const REACTION_AGAINST: &str = "-1";
@ -103,6 +107,7 @@ pub(crate) struct VoteResults {
pub pass_threshold: f64,
pub in_favor: i64,
pub against: i64,
pub against_percentage: f64,
pub abstain: i64,
pub not_voted: i64,
pub binding: i64,
@ -132,7 +137,8 @@ pub(crate) async fn calculate<'a>(
let reactions = gh.get_comment_reactions(inst_id, owner, repo, vote.vote_comment_id).await?;
// Get list of allowed voters (users with binding votes)
let allowed_voters = gh.get_allowed_voters(inst_id, &vote.cfg, owner, repo, &vote.organization).await?;
let allowed_voters =
gh.get_allowed_voters(inst_id, &vote.cfg, owner, repo, vote.organization.as_ref()).await?;
// Track users votes
let mut votes: HashMap<UserName, UserVote> = HashMap::new();
@ -184,8 +190,11 @@ pub(crate) async fn calculate<'a>(
}
}
let mut in_favor_percentage = 0.0;
let mut against_percentage = 0.0;
#[allow(clippy::cast_precision_loss)]
if !allowed_voters.is_empty() {
in_favor_percentage = in_favor as f64 / allowed_voters.len() as f64 * 100.0;
against_percentage = against as f64 / allowed_voters.len() as f64 * 100.0;
}
let pending_voters: Vec<UserName> =
allowed_voters.iter().filter(|user| !votes.contains_key(*user)).cloned().collect();
@ -196,6 +205,7 @@ pub(crate) async fn calculate<'a>(
pass_threshold: vote.cfg.pass_threshold,
in_favor,
against,
against_percentage,
abstain,
not_voted: pending_voters.len() as i64,
binding,
@ -208,12 +218,15 @@ pub(crate) async fn calculate<'a>(
#[cfg(test)]
mod tests {
use super::*;
use crate::github::{MockGH, Reaction, User};
use crate::testutil::*;
use std::{sync::Arc, time::Duration};
use futures::future::{self};
use mockall::predicate::eq;
use std::{sync::Arc, time::Duration};
use crate::github::{MockGH, Reaction, User};
use crate::testutil::*;
use super::*;
#[test]
fn vote_option_from_reaction() {
@ -273,7 +286,13 @@ mod tests {
.times(1)
.returning(|_, _, _, _| Box::pin(future::ready(Ok($reactions))));
gh.expect_get_allowed_voters()
.with(eq(INST_ID), eq($cfg), eq(OWNER), eq(REPO), eq(Some(ORG.to_string())))
.withf(|inst_id, cfg, owner, repo, org| {
*inst_id == INST_ID
&& *cfg == $cfg
&& owner == OWNER
&& repo == REPO
&& *org == Some(ORG.to_string()).as_ref()
})
.times(1)
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok($allowed_voters))));
@ -321,6 +340,7 @@ mod tests {
pass_threshold: 50.0,
in_favor: 0,
against: 1,
against_percentage: 100.0,
abstain: 0,
not_voted: 0,
binding: 1,
@ -368,6 +388,7 @@ mod tests {
pass_threshold: 50.0,
in_favor: 0,
against: 0,
against_percentage: 0.0,
abstain: 0,
not_voted: 1,
binding: 0,
@ -419,6 +440,7 @@ mod tests {
pass_threshold: 50.0,
in_favor: 1,
against: 1,
against_percentage: 25.0,
abstain: 1,
not_voted: 1,
binding: 3,
@ -498,6 +520,7 @@ mod tests {
pass_threshold: 75.0,
in_favor: 3,
against: 0,
against_percentage: 0.0,
abstain: 0,
not_voted: 1,
binding: 3,

View File

@ -1,11 +1,15 @@
//! This modules defines some test utilities.
use std::{collections::HashMap, fs, path::Path, time::Duration};
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
use uuid::Uuid;
use crate::{
cfg::{AllowedVoters, Announcements, CfgProfile, DiscussionsAnnouncements},
cfg_repo::{AllowedVoters, Announcements, CfgProfile, DiscussionsAnnouncements},
github::*,
results::{UserVote, Vote, VoteOption, VoteResults},
};
use std::{collections::HashMap, fs, path::Path, time::Duration};
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
use uuid::Uuid;
pub(crate) const BRANCH: &str = "main";
pub(crate) const COMMENT_ID: i64 = 1234;
@ -157,6 +161,7 @@ pub(crate) fn setup_test_vote_results() -> VoteResults {
pass_threshold: 50.0,
in_favor: 1,
against: 0,
against_percentage: 0.0,
abstain: 0,
not_voted: 0,
binding: 1,

View File

@ -1,10 +1,13 @@
//! This module defines the templates used for the GitHub comments.
use askama::Template;
use crate::{
cfg::CfgProfile,
cfg_repo::CfgProfile,
cmd::CreateVoteInput,
github::{TeamSlug, UserName},
results::VoteResults,
};
use askama::Template;
/// Template for the config not found comment.
#[derive(Debug, Clone, Template)]
@ -29,7 +32,7 @@ pub(crate) struct InvalidConfig<'a> {
}
impl<'a> InvalidConfig<'a> {
/// Create a new InvalidConfig template.
/// Create a new `InvalidConfig` template.
pub(crate) fn new(reason: &'a str) -> Self {
Self { reason }
}
@ -44,7 +47,7 @@ pub(crate) struct NoVoteInProgress<'a> {
}
impl<'a> NoVoteInProgress<'a> {
/// Create a new NoVoteInProgress template.
/// Create a new `NoVoteInProgress` template.
pub(crate) fn new(user: &'a str, is_pull_request: bool) -> Self {
Self {
user,
@ -62,7 +65,7 @@ pub(crate) struct VoteCancelled<'a> {
}
impl<'a> VoteCancelled<'a> {
/// Create a new VoteCancelled template.
/// Create a new `VoteCancelled` template.
pub(crate) fn new(user: &'a str, is_pull_request: bool) -> Self {
Self {
user,
@ -84,7 +87,7 @@ pub(crate) struct VoteClosed<'a> {
}
impl<'a> VoteClosed<'a> {
/// Create a new VoteClosed template.
/// Create a new `VoteClosed` template.
pub(crate) fn new(results: &'a VoteResults) -> Self {
Self { results }
}
@ -100,7 +103,7 @@ pub(crate) struct VoteClosedAnnouncement<'a> {
}
impl<'a> VoteClosedAnnouncement<'a> {
/// Create a new VoteClosedAnnouncement template.
/// Create a new `VoteClosedAnnouncement` template.
pub(crate) fn new(issue_number: i64, issue_title: &'a str, results: &'a VoteResults) -> Self {
Self {
issue_number,
@ -125,7 +128,7 @@ pub(crate) struct VoteCreated<'a> {
}
impl<'a> VoteCreated<'a> {
/// Create a new VoteCreated template.
/// Create a new `VoteCreated` template.
pub(crate) fn new(input: &'a CreateVoteInput, cfg: &'a CfgProfile) -> Self {
// Prepare teams and users allowed to vote
let (mut teams, mut users): (&[TeamSlug], &[UserName]) = (&[], &[]);
@ -166,7 +169,7 @@ pub(crate) struct VoteInProgress<'a> {
}
impl<'a> VoteInProgress<'a> {
/// Create a new VoteInProgress template.
/// Create a new `VoteInProgress` template.
pub(crate) fn new(user: &'a str, is_pull_request: bool) -> Self {
Self {
user,
@ -183,7 +186,7 @@ pub(crate) struct VoteRestricted<'a> {
}
impl<'a> VoteRestricted<'a> {
/// Create a new VoteRestricted template.
/// Create a new `VoteRestricted` template.
pub(crate) fn new(user: &'a str) -> Self {
Self { user }
}
@ -197,7 +200,7 @@ pub(crate) struct VoteStatus<'a> {
}
impl<'a> VoteStatus<'a> {
/// Create a new VoteStatus template.
/// Create a new `VoteStatus` template.
pub(crate) fn new(results: &'a VoteResults) -> Self {
Self { results }
}
@ -213,11 +216,13 @@ mod filters {
#[allow(clippy::trivially_copy_pass_by_ref, clippy::unnecessary_wraps)]
pub(crate) fn non_binding(
votes: &HashMap<UserName, UserVote>,
_: &dyn askama::Values,
max: &i64,
) -> askama::Result<Vec<(UserName, UserVote)>> {
let mut non_binding_votes: Vec<(UserName, UserVote)> =
votes.iter().filter(|(_, v)| !v.binding).map(|(n, v)| (n.clone(), v.clone())).collect();
non_binding_votes.sort_by(|a, b| a.1.timestamp.cmp(&b.1.timestamp));
#[allow(clippy::cast_possible_truncation)]
Ok(non_binding_votes.into_iter().take(*max as usize).collect())
}
}

View File

@ -4,8 +4,7 @@
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 (passing threshold: `{{ results.pass_threshold }}%`).
`{{ "{:.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 |

View File

@ -1,6 +1,6 @@
## Vote status
So far `{{ "{:.2}"|format(results.in_favor_percentage) }}%` of the users with binding vote are in favor (passing threshold: `{{ results.pass_threshold }}%`).
So far `{{ "{:.2}"|format(results.in_favor_percentage) }}%` of the users with binding vote are in favor and `{{ "{:.2}"|format(results.against_percentage) }}%` are against (passing threshold: `{{ results.pass_threshold }}%`).
### Summary