mirror of https://github.com/cncf/gitvote.git
Compare commits
29 Commits
gitvote-ch
...
main
Author | SHA1 | Date |
---|---|---|
|
2308d17e77 | |
|
d7e472efb4 | |
|
c383f85c3d | |
|
8e7eafba8d | |
|
9b4063c6f8 | |
|
426286cf74 | |
|
9183e10d6a | |
|
3915b82736 | |
|
38a1ef34ce | |
|
c4d5cbeb8e | |
|
216216c007 | |
|
fbdb69ced9 | |
|
ff0ccdc9a7 | |
|
278c1134a0 | |
|
bfe7209197 | |
|
8c36a74f79 | |
|
1778be36dc | |
|
2c2f0627d5 | |
|
0dad63922d | |
|
577c3d50a7 | |
|
3543eec09d | |
|
e9b07333d1 | |
|
ea0863fc9b | |
|
4636393256 | |
|
b6dc2083be | |
|
9ac717ac30 | |
|
dc419b2471 | |
|
f9ea1811c7 | |
|
9e0ebf3649 |
|
@ -9,7 +9,7 @@ permissions: read-all
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-and-test:
|
lint-and-test:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
@ -22,9 +22,9 @@ jobs:
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.7
|
python-version: 3.8
|
||||||
- name: Set up chart-testing
|
- 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)
|
- name: Run chart-testing (list-changed)
|
||||||
id: list-changed
|
id: list-changed
|
||||||
run: |
|
run: |
|
||||||
|
@ -35,7 +35,7 @@ jobs:
|
||||||
- name: Run chart-testing (lint)
|
- name: Run chart-testing (lint)
|
||||||
run: ct lint --config .ct.yaml --target-branch ${{ github.event.repository.default_branch }}
|
run: ct lint --config .ct.yaml --target-branch ${{ github.event.repository.default_branch }}
|
||||||
- name: Create kind cluster
|
- 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'
|
if: steps.list-changed.outputs.changed == 'true'
|
||||||
- name: Run chart-testing (install)
|
- name: Run chart-testing (install)
|
||||||
run: ct install --config .ct.yaml --target-branch ${{ github.event.repository.default_branch }}
|
run: ct install --config .ct.yaml --target-branch ${{ github.event.repository.default_branch }}
|
||||||
|
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
- name: Setup Rust
|
- name: Setup Rust
|
||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: 1.76.0
|
toolchain: 1.88.0
|
||||||
components: clippy, rustfmt
|
components: clippy, rustfmt
|
||||||
- name: Run clippy
|
- name: Run clippy
|
||||||
run: cargo clippy --all-targets --all-features -- --deny warnings
|
run: cargo clippy --all-targets --all-features -- --deny warnings
|
||||||
|
@ -30,6 +30,6 @@ jobs:
|
||||||
- name: Setup Rust
|
- name: Setup Rust
|
||||||
uses: dtolnay/rust-toolchain@master
|
uses: dtolnay/rust-toolchain@master
|
||||||
with:
|
with:
|
||||||
toolchain: 1.75.0
|
toolchain: 1.88.0
|
||||||
- name: Run backend tests
|
- name: Run backend tests
|
||||||
run: cargo test
|
run: cargo test
|
||||||
|
|
|
@ -9,16 +9,18 @@ permissions: read-all
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-publish-images:
|
build-and-publish-images:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Login to AWS Public ECR
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: public.ecr.aws
|
registry: ghcr.io
|
||||||
username: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Extract tag name
|
- name: Extract tag name
|
||||||
id: extract_tag_name
|
id: extract_tag_name
|
||||||
run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
|
@ -26,17 +28,17 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
docker build \
|
docker build \
|
||||||
-f database/migrations/Dockerfile \
|
-f database/migrations/Dockerfile \
|
||||||
-t public.ecr.aws/g6m3a0y9/gitvote-dbmigrator:${{steps.extract_tag_name.outputs.tag}} \
|
-t ghcr.io/${{ github.repository }}/dbmigrator:${{steps.extract_tag_name.outputs.tag}} \
|
||||||
-t public.ecr.aws/g6m3a0y9/gitvote-dbmigrator:latest \
|
-t ghcr.io/${{ github.repository }}/dbmigrator:latest \
|
||||||
.
|
.
|
||||||
docker push --all-tags public.ecr.aws/g6m3a0y9/gitvote-dbmigrator
|
docker push --all-tags ghcr.io/${{ github.repository }}/dbmigrator
|
||||||
- name: Build and push gitvote image
|
- name: Build and push gitvote image
|
||||||
run: |
|
run: |
|
||||||
docker build \
|
docker build \
|
||||||
-t public.ecr.aws/g6m3a0y9/gitvote:${{steps.extract_tag_name.outputs.tag}} \
|
-t ghcr.io/${{ github.repository }}/server:${{steps.extract_tag_name.outputs.tag}} \
|
||||||
-t public.ecr.aws/g6m3a0y9/gitvote:latest \
|
-t ghcr.io/${{ github.repository }}/server:latest \
|
||||||
.
|
.
|
||||||
docker push --all-tags public.ecr.aws/g6m3a0y9/gitvote
|
docker push --all-tags ghcr.io/${{ github.repository }}/server
|
||||||
|
|
||||||
package-and-publish-helm-chart:
|
package-and-publish-helm-chart:
|
||||||
needs:
|
needs:
|
||||||
|
|
18
ADOPTERS.md
18
ADOPTERS.md
|
@ -2,10 +2,26 @@
|
||||||
|
|
||||||
If your organization is using GitVote, please consider adding it to this list by submitting a pull request.
|
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)
|
- [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)
|
- [Kuma](https://kuma.io)
|
||||||
|
- [Kyverno](https://kyverno.io)
|
||||||
|
- [Microcks](https://microcks.io/)
|
||||||
|
- [NFDI4Health](https://github.com/nfdi4health)
|
||||||
- [Open Component Model](https://ocm.software)
|
- [Open Component Model](https://ocm.software)
|
||||||
|
- [OpenGemini](https://opengemini.org)
|
||||||
|
- [OpenSSF](https://openssf.org)
|
||||||
- [ORAS](https://oras.land)
|
- [ORAS](https://oras.land)
|
||||||
|
- [OSCAL Compass](https://github.com/oscal-compass)
|
||||||
|
- [Ratify Project](https://ratify.dev)
|
||||||
- [ResBaz Arizona](https://researchbazaar.arizona.edu)
|
- [ResBaz Arizona](https://researchbazaar.arizona.edu)
|
||||||
|
- [TODO Group](https://todogroup.org)
|
||||||
- [Universal Blue](https://universal-blue.org)
|
- [Universal Blue](https://universal-blue.org)
|
||||||
|
- [WasmEdge](https://wasmedge.org/)
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
70
Cargo.toml
70
Cargo.toml
|
@ -1,59 +1,59 @@
|
||||||
[package]
|
[package]
|
||||||
name = "gitvote"
|
name = "gitvote"
|
||||||
description = "GitVote server"
|
description = "GitVote server"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
rust-version = "1.88"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.86"
|
anyhow = "1.0.98"
|
||||||
askama = "0.12.1"
|
askama = { version = "0.14.0", features = ["serde_json"] }
|
||||||
askama_axum = "0.4.0"
|
async-channel = "2.5.0"
|
||||||
async-channel = "2.3.1"
|
async-trait = "0.1.88"
|
||||||
async-trait = "0.1.80"
|
axum = { version = "0.8.4", features = ["macros"] }
|
||||||
axum = { version = "0.7.5", features = ["macros"] }
|
clap = { version = "4.5.41", features = ["derive"] }
|
||||||
clap = { version = "4.5.7", features = ["derive"] }
|
deadpool-postgres = { version = "0.14.1", features = ["serde"] }
|
||||||
config = "0.13.4"
|
figment = { version = "0.10.19", features = ["yaml", "env"] }
|
||||||
deadpool-postgres = { version = "0.14.0", features = ["serde"] }
|
futures = "0.3.31"
|
||||||
futures = "0.3.30"
|
|
||||||
graphql_client = { version = "0.14.0", features = ["reqwest"] }
|
graphql_client = { version = "0.14.0", features = ["reqwest"] }
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
hmac = "0.12.1"
|
hmac = "0.12.1"
|
||||||
http = "0.2.12"
|
http = "1.3.1"
|
||||||
humantime = "2.1.0"
|
humantime = "2.2.0"
|
||||||
humantime-serde = "1.1.1"
|
humantime-serde = "1.1.1"
|
||||||
ignore = "0.4.22"
|
ignore = "0.4.23"
|
||||||
jsonwebtoken = "9.3.0"
|
jsonwebtoken = "9.3.1"
|
||||||
lazy_static = "1.5.0"
|
octocrab = "0.44.1"
|
||||||
octocrab = "=0.33.3"
|
openssl = { version = "0.10.73", features = ["vendored"] }
|
||||||
openssl = { version = "0.10.64", features = ["vendored"] }
|
postgres-openssl = "0.5.1"
|
||||||
postgres-openssl = "0.5.0"
|
regex = "1.11.1"
|
||||||
regex = "1.10.5"
|
reqwest = "0.12.22"
|
||||||
reqwest = "0.12.5"
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
serde = { version = "1.0.203", features = ["derive"] }
|
serde_json = "1.0.140"
|
||||||
serde_json = "1.0.118"
|
|
||||||
serde_yaml = "0.9.34"
|
serde_yaml = "0.9.34"
|
||||||
sha2 = "0.10.8"
|
sha2 = "0.10.9"
|
||||||
thiserror = "1.0.61"
|
thiserror = "2.0.12"
|
||||||
time = { version = "0.3.36", features = ["serde"] }
|
time = { version = "0.3.41", features = ["serde"] }
|
||||||
tokio = { version = "1.38.0", features = [
|
tokio = { version = "1.46.1", features = [
|
||||||
"macros",
|
"macros",
|
||||||
"rt-multi-thread",
|
"rt-multi-thread",
|
||||||
"signal",
|
"signal",
|
||||||
"time",
|
"time",
|
||||||
] }
|
] }
|
||||||
tokio-postgres = { version = "0.7.10", features = [
|
tokio-postgres = { version = "0.7.13", features = [
|
||||||
"with-uuid-1",
|
"with-uuid-1",
|
||||||
"with-serde_json-1",
|
"with-serde_json-1",
|
||||||
"with-time-0_3",
|
"with-time-0_3",
|
||||||
] }
|
] }
|
||||||
tower = "0.4.13"
|
tokio-util = { version = "0.7.15", features = ["rt"] }
|
||||||
tower-http = { version = "0.5.2", features = ["trace"] }
|
tower = { version = "0.5.2", features = ["util"] }
|
||||||
|
tower-http = { version = "0.6.6", features = ["trace"] }
|
||||||
tracing = "0.1.40"
|
tracing = "0.1.40"
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json"] }
|
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] }
|
||||||
uuid = { version = "1.9.1", features = ["serde", "v4"] }
|
uuid = { version = "1.17.0", features = ["serde", "v4"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
http-body = "1.0.0"
|
http-body = "1.0.1"
|
||||||
hyper = "1.3.1"
|
hyper = "1.6.0"
|
||||||
mockall = "0.12.1"
|
mockall = "0.13.1"
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# Build gitvote
|
# 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
|
RUN apk --no-cache add musl-dev perl make
|
||||||
WORKDIR /gitvote
|
WORKDIR /gitvote
|
||||||
COPY src src
|
COPY src src
|
||||||
|
@ -10,7 +10,7 @@ WORKDIR /gitvote/src
|
||||||
RUN cargo build --release
|
RUN cargo build --release
|
||||||
|
|
||||||
# Final stage
|
# 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
|
RUN apk --no-cache add ca-certificates && addgroup -S gitvote && adduser -S gitvote -G gitvote
|
||||||
USER gitvote
|
USER gitvote
|
||||||
WORKDIR /home/gitvote
|
WORKDIR /home/gitvote
|
||||||
|
|
|
@ -2,8 +2,8 @@ apiVersion: v2
|
||||||
name: gitvote
|
name: gitvote
|
||||||
description: GitVote is a GitHub application that allows holding a vote on issues and pull requests
|
description: GitVote is a GitHub application that allows holding a vote on issues and pull requests
|
||||||
type: application
|
type: application
|
||||||
version: 1.3.0
|
version: 1.4.1
|
||||||
appVersion: 1.3.0
|
appVersion: 1.4.0
|
||||||
kubeVersion: ">= 1.19.0-0"
|
kubeVersion: ">= 1.19.0-0"
|
||||||
home: https://gitvote.dev
|
home: https://gitvote.dev
|
||||||
icon: https://raw.githubusercontent.com/cncf/gitvote/main/docs/logo/logo.png
|
icon: https://raw.githubusercontent.com/cncf/gitvote/main/docs/logo/logo.png
|
||||||
|
@ -25,19 +25,25 @@ annotations:
|
||||||
artifacthub.io/category: skip-prediction
|
artifacthub.io/category: skip-prediction
|
||||||
artifacthub.io/changes: |
|
artifacthub.io/changes: |
|
||||||
- kind: added
|
- kind: added
|
||||||
description: Support for GitHub discussions announcements
|
description: Minimum wait support to close on passing
|
||||||
- kind: added
|
- kind: added
|
||||||
description: Webhook secret fallback for key rotation
|
description: Display percentage of voters against the vote
|
||||||
- kind: changed
|
- 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
|
- kind: changed
|
||||||
description: Upgrade dependencies
|
description: Upgrade dependencies
|
||||||
artifacthub.io/containsSecurityUpdates: "true"
|
artifacthub.io/containsSecurityUpdates: "true"
|
||||||
artifacthub.io/images: |
|
artifacthub.io/images: |
|
||||||
- name: dbmigrator
|
- name: dbmigrator
|
||||||
image: public.ecr.aws/g6m3a0y9/gitvote-dbmigrator:v1.3.0
|
image: ghcr.io/cncf/gitvote/dbmigrator:v1.4.1
|
||||||
- name: gitvote
|
- name: gitvote
|
||||||
image: public.ecr.aws/g6m3a0y9/gitvote:v1.3.0
|
image: ghcr.io/cncf/gitvote/server:v1.4.1
|
||||||
artifacthub.io/links: |
|
artifacthub.io/links: |
|
||||||
- name: source
|
- name: source
|
||||||
url: https://github.com/cncf/gitvote
|
url: https://github.com/cncf/gitvote
|
||||||
|
|
|
@ -8,14 +8,14 @@ stringData:
|
||||||
addr: {{ .Values.gitvote.addr }}
|
addr: {{ .Values.gitvote.addr }}
|
||||||
db:
|
db:
|
||||||
host: {{ default (printf "%s-postgresql.%s" .Release.Name .Release.Namespace) .Values.db.host }}
|
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 }}
|
dbname: {{ .Values.db.dbname }}
|
||||||
user: {{ .Values.db.user }}
|
user: {{ .Values.db.user }}
|
||||||
password: {{ .Values.db.password }}
|
password: {{ .Values.db.password }}
|
||||||
log:
|
log:
|
||||||
format: {{ .Values.log.format }}
|
format: {{ .Values.log.format }}
|
||||||
github:
|
github:
|
||||||
appID: {{ .Values.gitvote.github.appID }}
|
appId: {{ .Values.gitvote.github.appID }}
|
||||||
appPrivateKey: {{ .Values.gitvote.github.appPrivateKey | quote }}
|
appPrivateKey: {{ .Values.gitvote.github.appPrivateKey | quote }}
|
||||||
webhookSecret: {{ .Values.gitvote.github.webhookSecret | quote }}
|
webhookSecret: {{ .Values.gitvote.github.webhookSecret | quote }}
|
||||||
{{- with .Values.gitvote.github.webhookSecretFallback }}
|
{{- with .Values.gitvote.github.webhookSecretFallback }}
|
||||||
|
|
|
@ -36,7 +36,7 @@ dbmigrator:
|
||||||
job:
|
job:
|
||||||
image:
|
image:
|
||||||
# Database migrator image repository (without the tag)
|
# Database migrator image repository (without the tag)
|
||||||
repository: public.ecr.aws/g6m3a0y9/gitvote-dbmigrator
|
repository: ghcr.io/cncf/gitvote/dbmigrator
|
||||||
|
|
||||||
# GitVote service configuration
|
# GitVote service configuration
|
||||||
gitvote:
|
gitvote:
|
||||||
|
@ -74,7 +74,7 @@ gitvote:
|
||||||
deploy:
|
deploy:
|
||||||
replicaCount: 1
|
replicaCount: 1
|
||||||
image:
|
image:
|
||||||
repository: public.ecr.aws/g6m3a0y9/gitvote
|
repository: ghcr.io/cncf/gitvote/server
|
||||||
resources: {}
|
resources: {}
|
||||||
|
|
||||||
# PostgreSQL configuration
|
# PostgreSQL configuration
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
# Build tern
|
# Build tern
|
||||||
FROM golang:1.22.4-alpine3.20 AS tern
|
FROM golang:1.24.5-alpine3.22 AS tern
|
||||||
RUN apk --no-cache add git
|
RUN apk --no-cache add git
|
||||||
RUN go install github.com/jackc/tern@latest
|
RUN go install github.com/jackc/tern@latest
|
||||||
|
|
||||||
# Build final image
|
# Build final image
|
||||||
FROM alpine:3.20.1
|
FROM alpine:3.22.0
|
||||||
RUN addgroup -S gitvote && adduser -S gitvote -G gitvote
|
RUN addgroup -S gitvote && adduser -S gitvote -G gitvote
|
||||||
USER gitvote
|
USER gitvote
|
||||||
WORKDIR /home/gitvote
|
WORKDIR /home/gitvote
|
||||||
|
|
|
@ -136,6 +136,23 @@ profiles:
|
||||||
#
|
#
|
||||||
close_on_passing: false
|
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
|
# Announcements
|
||||||
#
|
#
|
||||||
# GitVote can announce the results of a vote when it is closed on GitHub
|
# GitVote can announce the results of a vote when it is closed on GitHub
|
||||||
|
|
|
@ -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 anyhow::{bail, Result};
|
||||||
use ignore::gitignore::GitignoreBuilder;
|
use ignore::gitignore::GitignoreBuilder;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{collections::HashMap, time::Duration};
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::github::{DynGH, File, TeamSlug, UserName};
|
||||||
|
|
||||||
/// Default configuration profile.
|
/// Default configuration profile.
|
||||||
const DEFAULT_PROFILE: &str = "default";
|
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 alias to represent a profile name.
|
||||||
type ProfileName = String;
|
type ProfileName = String;
|
||||||
|
|
||||||
/// GitVote configuration.
|
/// `GitVote` configuration.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||||
pub(crate) struct Cfg {
|
pub(crate) struct Cfg {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
@ -24,7 +30,7 @@ pub(crate) struct Cfg {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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>(
|
pub(crate) async fn get<'a>(
|
||||||
gh: DynGH,
|
gh: DynGH,
|
||||||
inst_id: u64,
|
inst_id: u64,
|
||||||
|
@ -84,6 +90,8 @@ pub(crate) struct CfgProfile {
|
||||||
pub periodic_status_check: Option<String>,
|
pub periodic_status_check: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub close_on_passing: Option<bool>,
|
pub close_on_passing: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub close_on_passing_min_wait: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CfgProfile {
|
impl CfgProfile {
|
||||||
|
@ -163,12 +171,15 @@ pub(crate) enum CfgError {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use std::sync::Arc;
|
||||||
use crate::github::MockGH;
|
|
||||||
use crate::testutil::*;
|
|
||||||
use futures::future;
|
use futures::future;
|
||||||
use mockall::predicate::eq;
|
use mockall::predicate::eq;
|
||||||
use std::sync::Arc;
|
|
||||||
|
use crate::github::MockGH;
|
||||||
|
use crate::testutil::*;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn automation_rule_matches() {
|
fn automation_rule_matches() {
|
|
@ -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>,
|
||||||
|
}
|
43
src/cmd.rs
43
src/cmd.rs
|
@ -1,23 +1,28 @@
|
||||||
use crate::{
|
//! This module defines the commands supported and the logic to parse them from
|
||||||
cfg::{Cfg, CfgError},
|
//! GitHub events.
|
||||||
github::*,
|
|
||||||
};
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use lazy_static::lazy_static;
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::LazyLock;
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
cfg_repo::{Cfg, CfgError},
|
||||||
|
github::{
|
||||||
|
split_full_name, DynGH, Event, IssueCommentEventAction, IssueEventAction, PullRequestEventAction,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/// Available commands.
|
/// Available commands.
|
||||||
const CMD_CREATE_VOTE: &str = "vote";
|
const CMD_CREATE_VOTE: &str = "vote";
|
||||||
const CMD_CANCEL_VOTE: &str = "cancel-vote";
|
const CMD_CANCEL_VOTE: &str = "cancel-vote";
|
||||||
const CMD_CHECK_VOTE: &str = "check-vote";
|
const CMD_CHECK_VOTE: &str = "check-vote";
|
||||||
|
|
||||||
lazy_static! {
|
/// Regex used to detect commands in issues/prs comments.
|
||||||
/// Regex used to detect commands in issues/prs comments.
|
static CMD: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
static ref CMD: Regex = Regex::new(r"(?m)^/(vote|cancel-vote|check-vote)-?([a-zA-Z0-9]*)\s*$")
|
Regex::new(r"(?m)^/(vote|cancel-vote|check-vote)-?([a-zA-Z0-9]*)\s*$").expect("invalid CMD regexp")
|
||||||
.expect("invalid CMD regexp");
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents a command to be executed, usually created from a GitHub event.
|
/// Represents a command to be executed, usually created from a GitHub event.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
@ -147,7 +152,7 @@ pub(crate) struct CreateVoteInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CreateVoteInput {
|
impl CreateVoteInput {
|
||||||
/// Create a new CreateVoteInput instance from the profile and event
|
/// Create a new `CreateVoteInput` instance from the profile and event
|
||||||
/// provided.
|
/// provided.
|
||||||
pub(crate) fn new(profile_name: Option<&str>, event: &Event) -> Self {
|
pub(crate) fn new(profile_name: Option<&str>, event: &Event) -> Self {
|
||||||
match event {
|
match event {
|
||||||
|
@ -199,7 +204,7 @@ pub(crate) struct CancelVoteInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub(crate) fn new(event: &Event) -> Self {
|
||||||
match event {
|
match event {
|
||||||
Event::Issue(event) => Self {
|
Event::Issue(event) => Self {
|
||||||
|
@ -235,7 +240,7 @@ pub(crate) struct CheckVoteInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub(crate) fn new(event: &Event) -> Self {
|
||||||
match event {
|
match event {
|
||||||
Event::Issue(event) => Self {
|
Event::Issue(event) => Self {
|
||||||
|
@ -256,11 +261,17 @@ impl CheckVoteInput {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use std::{sync::Arc, vec};
|
||||||
use crate::testutil::*;
|
|
||||||
use futures::future;
|
use futures::future;
|
||||||
use mockall::predicate::eq;
|
use mockall::predicate::eq;
|
||||||
use std::{sync::Arc, vec};
|
|
||||||
|
use crate::{
|
||||||
|
github::{File, MockGH},
|
||||||
|
testutil::*,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn manual_command_from_issue_event_unsupported_action() {
|
fn manual_command_from_issue_event_unsupported_action() {
|
||||||
|
|
32
src/db.rs
32
src/db.rs
|
@ -1,18 +1,22 @@
|
||||||
use crate::{
|
//! This module defines an abstraction layer over the database.
|
||||||
cfg::CfgProfile,
|
|
||||||
cmd::{CheckVoteInput, CreateVoteInput},
|
use std::sync::Arc;
|
||||||
github::{self, split_full_name, DynGH},
|
|
||||||
results::{self, Vote, VoteResults},
|
|
||||||
};
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use deadpool_postgres::{Pool, Transaction};
|
use deadpool_postgres::{Pool, Transaction};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use mockall::automock;
|
use mockall::automock;
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio_postgres::types::Json;
|
use tokio_postgres::types::Json;
|
||||||
use uuid::Uuid;
|
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.
|
/// Type alias to represent a DB trait object.
|
||||||
pub(crate) type DynDB = Arc<dyn DB + Send + Sync>;
|
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<()>;
|
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 {
|
pub(crate) struct PgDB {
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PgDB {
|
impl PgDB {
|
||||||
/// Create a new PgDB instance.
|
/// Create a new `PgDB` instance.
|
||||||
pub(crate) fn new(pool: Pool) -> Self {
|
pub(crate) fn new(pool: Pool) -> Self {
|
||||||
Self { pool }
|
Self { pool }
|
||||||
}
|
}
|
||||||
|
@ -91,7 +95,7 @@ impl PgDB {
|
||||||
async fn store_vote_results(
|
async fn store_vote_results(
|
||||||
tx: &Transaction<'_>,
|
tx: &Transaction<'_>,
|
||||||
vote_id: Uuid,
|
vote_id: Uuid,
|
||||||
results: &Option<VoteResults>,
|
results: Option<&VoteResults>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"
|
"
|
||||||
|
@ -154,7 +158,7 @@ impl DB for PgDB {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store results in database
|
// 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?;
|
tx.commit().await?;
|
||||||
|
|
||||||
Ok(Some((vote, results)))
|
Ok(Some((vote, results)))
|
||||||
|
@ -190,6 +194,12 @@ impl DB for PgDB {
|
||||||
where closed = false
|
where closed = false
|
||||||
and cfg ? 'close_on_passing'
|
and cfg ? 'close_on_passing'
|
||||||
and (cfg->>'close_on_passing')::boolean = true
|
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
|
||||||
",
|
",
|
||||||
&[],
|
&[],
|
||||||
)
|
)
|
||||||
|
|
|
@ -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 anyhow::{bail, Context, Error, Result};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use axum::http::HeaderValue;
|
use axum::http::HeaderValue;
|
||||||
|
@ -9,9 +12,10 @@ use mockall::automock;
|
||||||
use octocrab::{models::InstallationId, Octocrab, Page};
|
use octocrab::{models::InstallationId, Octocrab, Page};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::sync::Arc;
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::cfg_repo::CfgProfile;
|
||||||
|
|
||||||
/// GitHub API base url.
|
/// GitHub API base url.
|
||||||
const GITHUB_API_URL: &str = "https://api.github.com";
|
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.
|
/// Trait that defines some operations a GH implementation must support.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
#[allow(clippy::ref_option_ref)]
|
||||||
#[cfg_attr(test, automock)]
|
#[cfg_attr(test, automock)]
|
||||||
pub(crate) trait GH {
|
pub(crate) trait GH {
|
||||||
/// Add labels to the provided issue.
|
/// Add labels to the provided issue.
|
||||||
|
@ -96,7 +101,7 @@ pub(crate) trait GH {
|
||||||
cfg: &CfgProfile,
|
cfg: &CfgProfile,
|
||||||
owner: &str,
|
owner: &str,
|
||||||
repo: &str,
|
repo: &str,
|
||||||
org: &Option<String>,
|
org: Option<&String>,
|
||||||
) -> Result<Vec<UserName>>;
|
) -> Result<Vec<UserName>>;
|
||||||
|
|
||||||
/// Get all repository collaborators.
|
/// Get all repository collaborators.
|
||||||
|
@ -169,7 +174,7 @@ pub(crate) struct GHApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GHApi {
|
impl GHApi {
|
||||||
/// Create a new GHApi instance.
|
/// Create a new `GHApi` instance.
|
||||||
pub(crate) fn new(app_client: Octocrab) -> Self {
|
pub(crate) fn new(app_client: Octocrab) -> Self {
|
||||||
Self { app_client }
|
Self { app_client }
|
||||||
}
|
}
|
||||||
|
@ -186,7 +191,7 @@ impl GH for GHApi {
|
||||||
issue_number: i64,
|
issue_number: i64,
|
||||||
labels: &[&str],
|
labels: &[&str],
|
||||||
) -> Result<()> {
|
) -> 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>>();
|
let labels = labels.iter().map(ToString::to_string).collect::<Vec<String>>();
|
||||||
client.issues(owner, repo).add_labels(issue_number as u64, &labels).await?;
|
client.issues(owner, repo).add_labels(issue_number as u64, &labels).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
@ -201,7 +206,7 @@ impl GH for GHApi {
|
||||||
issue_number: i64,
|
issue_number: i64,
|
||||||
check_details: &CheckDetails,
|
check_details: &CheckDetails,
|
||||||
) -> Result<()> {
|
) -> 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 pr = client.pulls(owner, repo).get(issue_number as u64).await?;
|
||||||
let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/check-runs");
|
let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/check-runs");
|
||||||
let mut body = json!({
|
let mut body = json!({
|
||||||
|
@ -215,7 +220,7 @@ impl GH for GHApi {
|
||||||
});
|
});
|
||||||
if let Some(conclusion) = &check_details.conclusion {
|
if let Some(conclusion) = &check_details.conclusion {
|
||||||
body["conclusion"] = json!(conclusion);
|
body["conclusion"] = json!(conclusion);
|
||||||
};
|
}
|
||||||
let _: Value = client.post(url, Some(&body)).await?;
|
let _: Value = client.post(url, Some(&body)).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -230,7 +235,7 @@ impl GH for GHApi {
|
||||||
title: &str,
|
title: &str,
|
||||||
body: &str,
|
body: &str,
|
||||||
) -> Result<()> {
|
) -> 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
|
// Fetch some repository details needed to create a discussion
|
||||||
let response: graphql_client::Response<announcement_repo_query::ResponseData> = client
|
let response: graphql_client::Response<announcement_repo_query::ResponseData> = client
|
||||||
|
@ -270,7 +275,7 @@ impl GH for GHApi {
|
||||||
cfg: &CfgProfile,
|
cfg: &CfgProfile,
|
||||||
owner: &str,
|
owner: &str,
|
||||||
repo: &str,
|
repo: &str,
|
||||||
org: &Option<String>,
|
org: Option<&String>,
|
||||||
) -> Result<Vec<UserName>> {
|
) -> Result<Vec<UserName>> {
|
||||||
let mut allowed_voters: Vec<UserName> = vec![];
|
let mut allowed_voters: Vec<UserName> = vec![];
|
||||||
|
|
||||||
|
@ -321,7 +326,7 @@ impl GH for GHApi {
|
||||||
|
|
||||||
/// [GH::get_collaborators]
|
/// [GH::get_collaborators]
|
||||||
async fn get_collaborators(&self, inst_id: u64, owner: &str, repo: &str) -> Result<Vec<UserName>> {
|
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 url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/collaborators");
|
||||||
let first_page: Page<User> = client.get(url, None::<&()>).await?;
|
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();
|
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,
|
repo: &str,
|
||||||
comment_id: i64,
|
comment_id: i64,
|
||||||
) -> Result<Vec<Reaction>> {
|
) -> 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 url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/issues/comments/{comment_id}/reactions",);
|
||||||
let first_page: Page<Reaction> = client.get(url, None::<&()>).await?;
|
let first_page: Page<Reaction> = client.get(url, None::<&()>).await?;
|
||||||
let reactions = client.all_pages(first_page).await?;
|
let reactions = client.all_pages(first_page).await?;
|
||||||
|
@ -345,21 +350,20 @@ impl GH for GHApi {
|
||||||
|
|
||||||
/// [GH::get_config_file]
|
/// [GH::get_config_file]
|
||||||
async fn get_config_file(&self, inst_id: u64, owner: &str, repo: &str) -> Option<String> {
|
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
|
// Try to get the config file from the repository. Otherwise try
|
||||||
// getting the organization wide config file in the .github repo.
|
// getting the organization wide config file in the .github repo.
|
||||||
let mut content: Option<String> = None;
|
let mut content: Option<String> = None;
|
||||||
for repo in &[repo, ORG_CONFIG_REPO] {
|
for repo in &[repo, ORG_CONFIG_REPO] {
|
||||||
match client.repos(owner, *repo).get_content().path(CONFIG_FILE).send().await {
|
if let Ok(resp) = client.repos(owner, *repo).get_content().path(CONFIG_FILE).send().await {
|
||||||
Ok(resp) => {
|
|
||||||
if resp.items.len() == 1 {
|
if resp.items.len() == 1 {
|
||||||
content = resp.items[0].decoded_content();
|
content = resp.items[0].decoded_content();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => continue,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
content
|
content
|
||||||
|
@ -367,7 +371,7 @@ impl GH for GHApi {
|
||||||
|
|
||||||
/// [GH::get_pr_files]
|
/// [GH::get_pr_files]
|
||||||
async fn get_pr_files(&self, inst_id: u64, owner: &str, repo: &str, pr_number: i64) -> Result<Vec<File>> {
|
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 url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/pulls/{pr_number}/files");
|
||||||
let first_page: Page<File> = client.get(url, None::<&()>).await?;
|
let first_page: Page<File> = client.get(url, None::<&()>).await?;
|
||||||
let files: Vec<File> = client.all_pages(first_page).await?;
|
let files: Vec<File> = client.all_pages(first_page).await?;
|
||||||
|
@ -382,7 +386,7 @@ impl GH for GHApi {
|
||||||
team: &str,
|
team: &str,
|
||||||
exclude_maintainers: bool,
|
exclude_maintainers: bool,
|
||||||
) -> Result<Vec<UserName>> {
|
) -> 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 url = format!("{GITHUB_API_URL}/orgs/{org}/teams/{team}/members");
|
||||||
let first_page: Page<User> = client
|
let first_page: Page<User> = client
|
||||||
.get(
|
.get(
|
||||||
|
@ -399,7 +403,7 @@ impl GH for GHApi {
|
||||||
|
|
||||||
/// [GH::is_check_required]
|
/// [GH::is_check_required]
|
||||||
async fn is_check_required(&self, inst_id: u64, owner: &str, repo: &str, branch: &str) -> Result<bool> {
|
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 url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/branches/{branch}");
|
||||||
let branch: Branch = client.get(url, None::<&()>).await?;
|
let branch: Branch = client.get(url, None::<&()>).await?;
|
||||||
let is_check_required = if let Some(required_checks) =
|
let is_check_required = if let Some(required_checks) =
|
||||||
|
@ -421,7 +425,7 @@ impl GH for GHApi {
|
||||||
issue_number: i64,
|
issue_number: i64,
|
||||||
body: &str,
|
body: &str,
|
||||||
) -> Result<i64> {
|
) -> 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?;
|
let comment = client.issues(owner, repo).create_comment(issue_number as u64, body).await?;
|
||||||
Ok(comment.id.0 as i64)
|
Ok(comment.id.0 as i64)
|
||||||
}
|
}
|
||||||
|
@ -435,14 +439,21 @@ impl GH for GHApi {
|
||||||
issue_number: i64,
|
issue_number: i64,
|
||||||
label: &str,
|
label: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let client = self.app_client.installation(InstallationId(inst_id));
|
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||||
client.issues(owner, repo).remove_label(issue_number as u64, label).await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
Err(err) => Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// [GH::user_is_collaborator]
|
/// [GH::user_is_collaborator]
|
||||||
async fn user_is_collaborator(&self, inst_id: u64, owner: &str, repo: &str, user: &str) -> Result<bool> {
|
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 url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/collaborators/{user}",);
|
||||||
let resp = client._get(url).await?;
|
let resp = client._get(url).await?;
|
||||||
if resp.status() == StatusCode::NO_CONTENT {
|
if resp.status() == StatusCode::NO_CONTENT {
|
||||||
|
|
100
src/handlers.rs
100
src/handlers.rs
|
@ -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 anyhow::{format_err, Error, Result};
|
||||||
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
extract::{FromRef, State},
|
extract::{FromRef, State},
|
||||||
http::{HeaderMap, HeaderValue, StatusCode},
|
http::{HeaderMap, HeaderValue, StatusCode},
|
||||||
response::IntoResponse,
|
response::{Html, IntoResponse},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use config::{Config, ConfigError};
|
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
use std::sync::Arc;
|
|
||||||
use tower::ServiceBuilder;
|
use tower::ServiceBuilder;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
use tracing::{error, instrument, trace};
|
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.
|
/// Header representing the kind of the event received.
|
||||||
const GITHUB_EVENT_HEADER: &str = "X-GitHub-Event";
|
const GITHUB_EVENT_HEADER: &str = "X-GitHub-Event";
|
||||||
|
|
||||||
|
@ -34,21 +45,12 @@ struct RouterState {
|
||||||
|
|
||||||
/// Setup HTTP server router.
|
/// Setup HTTP server router.
|
||||||
pub(crate) fn setup_router(
|
pub(crate) fn setup_router(
|
||||||
cfg: &Arc<Config>,
|
cfg: &Cfg,
|
||||||
db: DynDB,
|
db: DynDB,
|
||||||
gh: DynGH,
|
gh: DynGH,
|
||||||
cmds_tx: async_channel::Sender<Command>,
|
cmds_tx: async_channel::Sender<Command>,
|
||||||
) -> Result<Router> {
|
) -> Router {
|
||||||
// Setup webhook secret
|
Router::new()
|
||||||
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()
|
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
.route("/api/events", post(event))
|
.route("/api/events", post(event))
|
||||||
.layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()))
|
.layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()))
|
||||||
|
@ -56,17 +58,19 @@ pub(crate) fn setup_router(
|
||||||
db,
|
db,
|
||||||
gh,
|
gh,
|
||||||
cmds_tx,
|
cmds_tx,
|
||||||
webhook_secret,
|
webhook_secret: cfg.github.webhook_secret.clone(),
|
||||||
webhook_secret_fallback,
|
webhook_secret_fallback: cfg.github.webhook_secret_fallback.clone(),
|
||||||
});
|
})
|
||||||
|
|
||||||
Ok(router)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handler that returns the index document.
|
/// Handler that returns the index document.
|
||||||
#[allow(clippy::unused_async)]
|
#[allow(clippy::unused_async)]
|
||||||
async fn index() -> impl IntoResponse {
|
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.
|
/// Handler that processes webhook events from GitHub.
|
||||||
|
@ -93,7 +97,7 @@ async fn event(
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
return Err((StatusCode::BAD_REQUEST, "no valid signature found".to_string()));
|
return Err((StatusCode::BAD_REQUEST, "no valid signature found".to_string()));
|
||||||
};
|
}
|
||||||
|
|
||||||
// Parse event
|
// Parse event
|
||||||
let event = match Event::try_from((headers.get(GITHUB_EVENT_HEADER), &body[..])) {
|
let event = match Event::try_from((headers.get(GITHUB_EVENT_HEADER), &body[..])) {
|
||||||
|
@ -104,7 +108,7 @@ async fn event(
|
||||||
Err(EventError::InvalidBody(err)) => {
|
Err(EventError::InvalidBody(err)) => {
|
||||||
return Err((StatusCode::BAD_REQUEST, EventError::InvalidBody(err).to_string()))
|
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");
|
trace!(?event, "event received");
|
||||||
|
|
||||||
|
@ -113,6 +117,7 @@ async fn event(
|
||||||
Some(cmd) => {
|
Some(cmd) => {
|
||||||
trace!(?cmd, "command detected");
|
trace!(?cmd, "command detected");
|
||||||
cmds_tx.send(cmd).await.unwrap();
|
cmds_tx.send(cmd).await.unwrap();
|
||||||
|
return Ok("command queued");
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
if let Event::PullRequest(event) = event {
|
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.
|
/// 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
|
/// 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
|
/// 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
|
/// it yet. This makes it possible to use the `GitVote` check in combination
|
||||||
/// branch protection.
|
/// with branch protection.
|
||||||
async fn set_check_status(db: DynDB, gh: DynGH, event: &PullRequestEvent) -> Result<()> {
|
async fn set_check_status(db: DynDB, gh: DynGH, event: &PullRequestEvent) -> Result<()> {
|
||||||
let (owner, repo) = split_full_name(&event.repository.full_name);
|
let (owner, repo) = split_full_name(&event.repository.full_name);
|
||||||
let inst_id = event.installation.id as u64;
|
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?;
|
gh.create_check_run(inst_id, owner, repo, pr, &check_details).await?;
|
||||||
}
|
}
|
||||||
PullRequestEventAction::Other => {}
|
PullRequestEventAction::Other => {}
|
||||||
};
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use std::sync::Arc;
|
||||||
use crate::testutil::*;
|
use std::{fs, path::Path};
|
||||||
use crate::{cmd::CreateVoteInput, db::MockDB};
|
|
||||||
use async_channel::Receiver;
|
use async_channel::Receiver;
|
||||||
use axum::body::to_bytes;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::{to_bytes, Body},
|
||||||
http::{header::CONTENT_TYPE, Request},
|
http::{header::CONTENT_TYPE, Request},
|
||||||
};
|
};
|
||||||
|
use figment::{providers::Serialized, Figment};
|
||||||
use futures::future;
|
use futures::future;
|
||||||
use hyper::Response;
|
use hyper::Response;
|
||||||
use mockall::predicate::eq;
|
use mockall::predicate::eq;
|
||||||
use std::{fs, path::Path};
|
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
use crate::github::MockGH;
|
||||||
|
use crate::testutil::*;
|
||||||
|
use crate::{cmd::CreateVoteInput, db::MockDB};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn index() {
|
async fn index() {
|
||||||
let (router, _) = setup_test_router();
|
let (router, _) = setup_test_router();
|
||||||
|
@ -427,7 +437,7 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn event_pr_without_cmd_set_check_status_failed() {
|
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 db = Arc::new(MockDB::new());
|
||||||
let mut gh = MockGH::new();
|
let mut gh = MockGH::new();
|
||||||
gh.expect_get_config_file()
|
gh.expect_get_config_file()
|
||||||
|
@ -440,7 +450,7 @@ mod tests {
|
||||||
.returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR)))));
|
.returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR)))));
|
||||||
let gh = Arc::new(gh);
|
let gh = Arc::new(gh);
|
||||||
let (cmds_tx, cmds_rx) = async_channel::unbounded();
|
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 body = fs::read(Path::new(TESTDATA_PATH).join("event-pr-no-cmd.json")).unwrap();
|
||||||
let response = router
|
let response = router
|
||||||
|
@ -600,15 +610,23 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setup_test_router() -> (Router, Receiver<Command>) {
|
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 db = Arc::new(MockDB::new());
|
||||||
let gh = Arc::new(MockGH::new());
|
let gh = Arc::new(MockGH::new());
|
||||||
let (cmds_tx, cmds_rx) = async_channel::unbounded();
|
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 {
|
fn setup_test_config() -> Cfg {
|
||||||
Config::builder().set_default("github.webhookSecret", "secret").unwrap().build().unwrap()
|
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 {
|
async fn get_body(response: Response<Body>) -> Bytes {
|
||||||
|
|
67
src/main.rs
67
src/main.rs
|
@ -1,27 +1,27 @@
|
||||||
#![warn(clippy::all, clippy::pedantic)]
|
#![warn(clippy::all, clippy::pedantic)]
|
||||||
#![allow(
|
#![allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
|
||||||
clippy::cast_possible_truncation,
|
|
||||||
clippy::cast_possible_wrap,
|
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
|
||||||
clippy::cast_precision_loss,
|
|
||||||
clippy::cast_sign_loss,
|
|
||||||
clippy::doc_markdown,
|
|
||||||
clippy::wildcard_imports
|
|
||||||
)]
|
|
||||||
|
|
||||||
use crate::{db::PgDB, github::GHApi};
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use config::{Config, File};
|
use deadpool_postgres::Runtime;
|
||||||
use deadpool_postgres::{Config as DbConfig, Runtime};
|
|
||||||
use octocrab::Octocrab;
|
use octocrab::Octocrab;
|
||||||
use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode};
|
use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode};
|
||||||
use postgres_openssl::MakeTlsConnector;
|
use postgres_openssl::MakeTlsConnector;
|
||||||
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
|
use tokio::{net::TcpListener, signal};
|
||||||
use tokio::{net::TcpListener, signal, sync::broadcast};
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
use tracing_subscriber::EnvFilter;
|
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 cmd;
|
||||||
mod db;
|
mod db;
|
||||||
mod github;
|
mod github;
|
||||||
|
@ -45,56 +45,49 @@ async fn main() -> Result<()> {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
// Setup configuration
|
// Setup configuration
|
||||||
let cfg = Config::builder()
|
let cfg = Cfg::new(&args.config).context("error setting up configuration")?;
|
||||||
.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);
|
|
||||||
|
|
||||||
// Setup logging
|
// Setup logging
|
||||||
if std::env::var_os("RUST_LOG").is_none() {
|
if std::env::var_os("RUST_LOG").is_none() {
|
||||||
std::env::set_var("RUST_LOG", "gitvote=debug");
|
std::env::set_var("RUST_LOG", "gitvote=debug");
|
||||||
}
|
}
|
||||||
let s = tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env());
|
let ts = tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env());
|
||||||
match cfg.get_string("log.format").as_deref() {
|
match cfg.log.format {
|
||||||
Ok("json") => s.json().init(),
|
LogFormat::Json => ts.json().init(),
|
||||||
_ => s.init(),
|
LogFormat::Pretty => ts.init(),
|
||||||
};
|
}
|
||||||
|
|
||||||
// Setup database
|
// Setup database
|
||||||
let mut builder = SslConnector::builder(SslMethod::tls())?;
|
let mut builder = SslConnector::builder(SslMethod::tls())?;
|
||||||
builder.set_verify(SslVerifyMode::NONE);
|
builder.set_verify(SslVerifyMode::NONE);
|
||||||
let connector = MakeTlsConnector::new(builder.build());
|
let connector = MakeTlsConnector::new(builder.build());
|
||||||
let db_cfg: DbConfig = cfg.get("db")?;
|
let pool = cfg.db.create_pool(Some(Runtime::Tokio1), connector)?;
|
||||||
let pool = db_cfg.create_pool(Some(Runtime::Tokio1), connector)?;
|
|
||||||
let db = Arc::new(PgDB::new(pool));
|
let db = Arc::new(PgDB::new(pool));
|
||||||
|
|
||||||
// Setup GitHub client
|
// Setup GitHub client
|
||||||
let app_id = cfg.get_int("github.appID")? as u64;
|
let app_id = cfg.github.app_id as u64;
|
||||||
let app_private_key = cfg.get_string("github.appPrivateKey")?;
|
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_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 app_client = Octocrab::builder().app(app_id.into(), app_private_key).build()?;
|
||||||
let gh = Arc::new(GHApi::new(app_client));
|
let gh = Arc::new(GHApi::new(app_client));
|
||||||
|
|
||||||
// Setup and launch votes processor
|
// Setup and launch votes processor
|
||||||
let (cmds_tx, cmds_rx) = async_channel::unbounded();
|
let (cmds_tx, cmds_rx) = async_channel::unbounded();
|
||||||
let (stop_tx, _): (broadcast::Sender<()>, _) = broadcast::channel(1);
|
let cancel_token = CancellationToken::new();
|
||||||
let votes_processor = processor::Processor::new(db.clone(), gh.clone());
|
let votes_processor = processor::Processor::new(db.clone(), gh.clone(), cmds_tx.clone(), cmds_rx);
|
||||||
let votes_processor_done = votes_processor.start(cmds_tx.clone(), &cmds_rx, &stop_tx);
|
let votes_processor_tasks = votes_processor.run(&cancel_token);
|
||||||
debug!("[votes processor] started");
|
debug!("[votes processor] started");
|
||||||
|
|
||||||
// Setup and launch HTTP server
|
// Setup and launch HTTP server
|
||||||
let router = handlers::setup_router(&cfg, db, gh, cmds_tx)?;
|
let router = handlers::setup_router(&cfg, db, gh, cmds_tx);
|
||||||
let addr: SocketAddr = cfg.get_string("addr")?.parse()?;
|
let addr: SocketAddr = cfg.addr.parse()?;
|
||||||
let listener = TcpListener::bind(addr).await?;
|
let listener = TcpListener::bind(addr).await?;
|
||||||
info!(%addr, "gitvote service started");
|
info!(%addr, "gitvote service started");
|
||||||
axum::serve(listener, router).with_graceful_shutdown(shutdown_signal()).await.unwrap();
|
axum::serve(listener, router).with_graceful_shutdown(shutdown_signal()).await.unwrap();
|
||||||
|
|
||||||
// Ask votes processor to stop and wait for it to finish
|
// Ask votes processor to stop and wait for it to finish
|
||||||
drop(stop_tx);
|
cancel_token.cancel();
|
||||||
votes_processor_done.await;
|
votes_processor_tasks.await;
|
||||||
debug!("[votes processor] stopped");
|
debug!("[votes processor] stopped");
|
||||||
info!("gitvote service stopped");
|
info!("gitvote service stopped");
|
||||||
|
|
||||||
|
|
816
src/processor.rs
816
src/processor.rs
File diff suppressed because it is too large
Load Diff
|
@ -1,14 +1,18 @@
|
||||||
use crate::{
|
//! This module defines the logic to calculate vote results.
|
||||||
cfg::CfgProfile,
|
|
||||||
github::{DynGH, UserName},
|
use std::{collections::BTreeMap, fmt};
|
||||||
};
|
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{collections::HashMap, fmt};
|
|
||||||
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||||
use tokio_postgres::{types::Json, Row};
|
use tokio_postgres::{types::Json, Row};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
cfg_repo::CfgProfile,
|
||||||
|
github::{DynGH, UserName},
|
||||||
|
};
|
||||||
|
|
||||||
/// Supported reactions.
|
/// Supported reactions.
|
||||||
pub(crate) const REACTION_IN_FAVOR: &str = "+1";
|
pub(crate) const REACTION_IN_FAVOR: &str = "+1";
|
||||||
pub(crate) const REACTION_AGAINST: &str = "-1";
|
pub(crate) const REACTION_AGAINST: &str = "-1";
|
||||||
|
@ -103,12 +107,13 @@ pub(crate) struct VoteResults {
|
||||||
pub pass_threshold: f64,
|
pub pass_threshold: f64,
|
||||||
pub in_favor: i64,
|
pub in_favor: i64,
|
||||||
pub against: i64,
|
pub against: i64,
|
||||||
|
pub against_percentage: f64,
|
||||||
pub abstain: i64,
|
pub abstain: i64,
|
||||||
pub not_voted: i64,
|
pub not_voted: i64,
|
||||||
pub binding: i64,
|
pub binding: i64,
|
||||||
pub non_binding: i64,
|
pub non_binding: i64,
|
||||||
pub allowed_voters: i64,
|
pub allowed_voters: i64,
|
||||||
pub votes: HashMap<UserName, UserVote>,
|
pub votes: BTreeMap<UserName, UserVote>,
|
||||||
pub pending_voters: Vec<UserName>,
|
pub pending_voters: Vec<UserName>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -132,10 +137,11 @@ pub(crate) async fn calculate<'a>(
|
||||||
let reactions = gh.get_comment_reactions(inst_id, owner, repo, vote.vote_comment_id).await?;
|
let reactions = gh.get_comment_reactions(inst_id, owner, repo, vote.vote_comment_id).await?;
|
||||||
|
|
||||||
// Get list of allowed voters (users with binding votes)
|
// 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
|
// Track users votes
|
||||||
let mut votes: HashMap<UserName, UserVote> = HashMap::new();
|
let mut votes: BTreeMap<UserName, UserVote> = BTreeMap::new();
|
||||||
let mut multiple_options_voters: Vec<UserName> = Vec::new();
|
let mut multiple_options_voters: Vec<UserName> = Vec::new();
|
||||||
for reaction in reactions {
|
for reaction in reactions {
|
||||||
// Get vote option from reaction
|
// Get vote option from reaction
|
||||||
|
@ -184,8 +190,11 @@ pub(crate) async fn calculate<'a>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut in_favor_percentage = 0.0;
|
let mut in_favor_percentage = 0.0;
|
||||||
|
let mut against_percentage = 0.0;
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
if !allowed_voters.is_empty() {
|
if !allowed_voters.is_empty() {
|
||||||
in_favor_percentage = in_favor as f64 / allowed_voters.len() as f64 * 100.0;
|
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> =
|
let pending_voters: Vec<UserName> =
|
||||||
allowed_voters.iter().filter(|user| !votes.contains_key(*user)).cloned().collect();
|
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,
|
pass_threshold: vote.cfg.pass_threshold,
|
||||||
in_favor,
|
in_favor,
|
||||||
against,
|
against,
|
||||||
|
against_percentage,
|
||||||
abstain,
|
abstain,
|
||||||
not_voted: pending_voters.len() as i64,
|
not_voted: pending_voters.len() as i64,
|
||||||
binding,
|
binding,
|
||||||
|
@ -208,12 +218,15 @@ pub(crate) async fn calculate<'a>(
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use std::{sync::Arc, time::Duration};
|
||||||
use crate::github::{MockGH, Reaction, User};
|
|
||||||
use crate::testutil::*;
|
|
||||||
use futures::future::{self};
|
use futures::future::{self};
|
||||||
use mockall::predicate::eq;
|
use mockall::predicate::eq;
|
||||||
use std::{sync::Arc, time::Duration};
|
|
||||||
|
use crate::github::{MockGH, Reaction, User};
|
||||||
|
use crate::testutil::*;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn vote_option_from_reaction() {
|
fn vote_option_from_reaction() {
|
||||||
|
@ -273,7 +286,13 @@ mod tests {
|
||||||
.times(1)
|
.times(1)
|
||||||
.returning(|_, _, _, _| Box::pin(future::ready(Ok($reactions))));
|
.returning(|_, _, _, _| Box::pin(future::ready(Ok($reactions))));
|
||||||
gh.expect_get_allowed_voters()
|
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)
|
.times(1)
|
||||||
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok($allowed_voters))));
|
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok($allowed_voters))));
|
||||||
|
|
||||||
|
@ -321,11 +340,12 @@ mod tests {
|
||||||
pass_threshold: 50.0,
|
pass_threshold: 50.0,
|
||||||
in_favor: 0,
|
in_favor: 0,
|
||||||
against: 1,
|
against: 1,
|
||||||
|
against_percentage: 100.0,
|
||||||
abstain: 0,
|
abstain: 0,
|
||||||
not_voted: 0,
|
not_voted: 0,
|
||||||
binding: 1,
|
binding: 1,
|
||||||
non_binding: 0,
|
non_binding: 0,
|
||||||
votes: HashMap::from([
|
votes: BTreeMap::from([
|
||||||
(
|
(
|
||||||
USER1.to_string(),
|
USER1.to_string(),
|
||||||
UserVote {
|
UserVote {
|
||||||
|
@ -368,11 +388,12 @@ mod tests {
|
||||||
pass_threshold: 50.0,
|
pass_threshold: 50.0,
|
||||||
in_favor: 0,
|
in_favor: 0,
|
||||||
against: 0,
|
against: 0,
|
||||||
|
against_percentage: 0.0,
|
||||||
abstain: 0,
|
abstain: 0,
|
||||||
not_voted: 1,
|
not_voted: 1,
|
||||||
binding: 0,
|
binding: 0,
|
||||||
non_binding: 0,
|
non_binding: 0,
|
||||||
votes: HashMap::new(),
|
votes: BTreeMap::new(),
|
||||||
allowed_voters: 1,
|
allowed_voters: 1,
|
||||||
pending_voters: vec![USER1.to_string()],
|
pending_voters: vec![USER1.to_string()],
|
||||||
}
|
}
|
||||||
|
@ -419,11 +440,12 @@ mod tests {
|
||||||
pass_threshold: 50.0,
|
pass_threshold: 50.0,
|
||||||
in_favor: 1,
|
in_favor: 1,
|
||||||
against: 1,
|
against: 1,
|
||||||
|
against_percentage: 25.0,
|
||||||
abstain: 1,
|
abstain: 1,
|
||||||
not_voted: 1,
|
not_voted: 1,
|
||||||
binding: 3,
|
binding: 3,
|
||||||
non_binding: 1,
|
non_binding: 1,
|
||||||
votes: HashMap::from([
|
votes: BTreeMap::from([
|
||||||
(
|
(
|
||||||
USER1.to_string(),
|
USER1.to_string(),
|
||||||
UserVote {
|
UserVote {
|
||||||
|
@ -498,11 +520,12 @@ mod tests {
|
||||||
pass_threshold: 75.0,
|
pass_threshold: 75.0,
|
||||||
in_favor: 3,
|
in_favor: 3,
|
||||||
against: 0,
|
against: 0,
|
||||||
|
against_percentage: 0.0,
|
||||||
abstain: 0,
|
abstain: 0,
|
||||||
not_voted: 1,
|
not_voted: 1,
|
||||||
binding: 3,
|
binding: 3,
|
||||||
non_binding: 0,
|
non_binding: 0,
|
||||||
votes: HashMap::from([
|
votes: BTreeMap::from([
|
||||||
(
|
(
|
||||||
USER1.to_string(),
|
USER1.to_string(),
|
||||||
UserVote {
|
UserVote {
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Configuration file not found. Please see <https://github.com/cncf/gitvote#configuration>.
|
|
@ -0,0 +1 @@
|
||||||
|
The requested configuration profile was not found in the configuration file.
|
|
@ -0,0 +1,5 @@
|
||||||
|
Something went wrong while processing the configuration file:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Missing required field: pass_threshold
|
||||||
|
```
|
|
@ -0,0 +1 @@
|
||||||
|
There is no vote in progress to cancel in this issue @testuser.
|
|
@ -0,0 +1 @@
|
||||||
|
There is no vote in progress to cancel in this pull request @testuser.
|
|
@ -0,0 +1,3 @@
|
||||||
|
## Vote cancelled
|
||||||
|
|
||||||
|
@testuser has cancelled the vote in progress in this issue.
|
|
@ -0,0 +1,3 @@
|
||||||
|
## Vote cancelled
|
||||||
|
|
||||||
|
@testuser has cancelled the vote in progress in this pull request.
|
|
@ -0,0 +1 @@
|
||||||
|
Votes can only be checked once a day.
|
|
@ -0,0 +1,22 @@
|
||||||
|
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 |
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
## 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 |
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
## 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>
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
## 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.
|
|
@ -0,0 +1,29 @@
|
||||||
|
## 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.
|
|
@ -0,0 +1,3 @@
|
||||||
|
There is already a vote in progress in this issue @testuser.
|
||||||
|
|
||||||
|
Please wait until it is closed before creating a new one.
|
|
@ -0,0 +1,3 @@
|
||||||
|
There is already a vote in progress in this pull request @testuser.
|
||||||
|
|
||||||
|
Please wait until it is closed before creating a new one.
|
|
@ -0,0 +1,3 @@
|
||||||
|
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.
|
|
@ -0,0 +1,25 @@
|
||||||
|
## 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,11 +1,15 @@
|
||||||
|
//! This modules defines some test utilities.
|
||||||
|
|
||||||
|
use std::{collections::BTreeMap, fs, path::Path, time::Duration};
|
||||||
|
|
||||||
|
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
cfg::{AllowedVoters, Announcements, CfgProfile, DiscussionsAnnouncements},
|
cfg_repo::{AllowedVoters, Announcements, CfgProfile, DiscussionsAnnouncements},
|
||||||
github::*,
|
github::*,
|
||||||
results::{UserVote, Vote, VoteOption, VoteResults},
|
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 BRANCH: &str = "main";
|
||||||
pub(crate) const COMMENT_ID: i64 = 1234;
|
pub(crate) const COMMENT_ID: i64 = 1234;
|
||||||
|
@ -157,11 +161,12 @@ pub(crate) fn setup_test_vote_results() -> VoteResults {
|
||||||
pass_threshold: 50.0,
|
pass_threshold: 50.0,
|
||||||
in_favor: 1,
|
in_favor: 1,
|
||||||
against: 0,
|
against: 0,
|
||||||
|
against_percentage: 0.0,
|
||||||
abstain: 0,
|
abstain: 0,
|
||||||
not_voted: 0,
|
not_voted: 0,
|
||||||
binding: 1,
|
binding: 1,
|
||||||
non_binding: 0,
|
non_binding: 0,
|
||||||
votes: HashMap::from([(
|
votes: BTreeMap::from([(
|
||||||
USER1.to_string(),
|
USER1.to_string(),
|
||||||
UserVote {
|
UserVote {
|
||||||
vote_option: VoteOption::InFavor,
|
vote_option: VoteOption::InFavor,
|
||||||
|
|
483
src/tmpl.rs
483
src/tmpl.rs
|
@ -1,10 +1,13 @@
|
||||||
|
//! This module defines the templates used for the GitHub comments.
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
cfg::CfgProfile,
|
cfg_repo::CfgProfile,
|
||||||
cmd::CreateVoteInput,
|
cmd::CreateVoteInput,
|
||||||
github::{TeamSlug, UserName},
|
github::{TeamSlug, UserName},
|
||||||
results::VoteResults,
|
results::VoteResults,
|
||||||
};
|
};
|
||||||
use askama::Template;
|
|
||||||
|
|
||||||
/// Template for the config not found comment.
|
/// Template for the config not found comment.
|
||||||
#[derive(Debug, Clone, Template)]
|
#[derive(Debug, Clone, Template)]
|
||||||
|
@ -29,7 +32,7 @@ pub(crate) struct InvalidConfig<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> InvalidConfig<'a> {
|
impl<'a> InvalidConfig<'a> {
|
||||||
/// Create a new InvalidConfig template.
|
/// Create a new `InvalidConfig` template.
|
||||||
pub(crate) fn new(reason: &'a str) -> Self {
|
pub(crate) fn new(reason: &'a str) -> Self {
|
||||||
Self { reason }
|
Self { reason }
|
||||||
}
|
}
|
||||||
|
@ -44,7 +47,7 @@ pub(crate) struct NoVoteInProgress<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> 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 {
|
pub(crate) fn new(user: &'a str, is_pull_request: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
user,
|
user,
|
||||||
|
@ -62,7 +65,7 @@ pub(crate) struct VoteCancelled<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> 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 {
|
pub(crate) fn new(user: &'a str, is_pull_request: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
user,
|
user,
|
||||||
|
@ -84,7 +87,7 @@ pub(crate) struct VoteClosed<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> VoteClosed<'a> {
|
impl<'a> VoteClosed<'a> {
|
||||||
/// Create a new VoteClosed template.
|
/// Create a new `VoteClosed` template.
|
||||||
pub(crate) fn new(results: &'a VoteResults) -> Self {
|
pub(crate) fn new(results: &'a VoteResults) -> Self {
|
||||||
Self { results }
|
Self { results }
|
||||||
}
|
}
|
||||||
|
@ -100,7 +103,7 @@ pub(crate) struct VoteClosedAnnouncement<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> 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 {
|
pub(crate) fn new(issue_number: i64, issue_title: &'a str, results: &'a VoteResults) -> Self {
|
||||||
Self {
|
Self {
|
||||||
issue_number,
|
issue_number,
|
||||||
|
@ -125,7 +128,7 @@ pub(crate) struct VoteCreated<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> 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 {
|
pub(crate) fn new(input: &'a CreateVoteInput, cfg: &'a CfgProfile) -> Self {
|
||||||
// Prepare teams and users allowed to vote
|
// Prepare teams and users allowed to vote
|
||||||
let (mut teams, mut users): (&[TeamSlug], &[UserName]) = (&[], &[]);
|
let (mut teams, mut users): (&[TeamSlug], &[UserName]) = (&[], &[]);
|
||||||
|
@ -166,7 +169,7 @@ pub(crate) struct VoteInProgress<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> 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 {
|
pub(crate) fn new(user: &'a str, is_pull_request: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
user,
|
user,
|
||||||
|
@ -183,7 +186,7 @@ pub(crate) struct VoteRestricted<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> VoteRestricted<'a> {
|
impl<'a> VoteRestricted<'a> {
|
||||||
/// Create a new VoteRestricted template.
|
/// Create a new `VoteRestricted` template.
|
||||||
pub(crate) fn new(user: &'a str) -> Self {
|
pub(crate) fn new(user: &'a str) -> Self {
|
||||||
Self { user }
|
Self { user }
|
||||||
}
|
}
|
||||||
|
@ -197,27 +200,481 @@ pub(crate) struct VoteStatus<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> VoteStatus<'a> {
|
impl<'a> VoteStatus<'a> {
|
||||||
/// Create a new VoteStatus template.
|
/// Create a new `VoteStatus` template.
|
||||||
pub(crate) fn new(results: &'a VoteResults) -> Self {
|
pub(crate) fn new(results: &'a VoteResults) -> Self {
|
||||||
Self { results }
|
Self { results }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mod filters {
|
mod filters {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use crate::{github::UserName, results::UserVote};
|
use crate::{github::UserName, results::UserVote};
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
/// Template filter that returns up to the requested number of non-binding
|
/// Template filter that returns up to the requested number of non-binding
|
||||||
/// votes from the votes collection provided sorted by timestamp (oldest
|
/// votes from the votes collection provided sorted by timestamp (oldest
|
||||||
/// first).
|
/// first).
|
||||||
#[allow(clippy::trivially_copy_pass_by_ref, clippy::unnecessary_wraps)]
|
#[allow(clippy::trivially_copy_pass_by_ref, clippy::unnecessary_wraps)]
|
||||||
pub(crate) fn non_binding(
|
pub(crate) fn non_binding(
|
||||||
votes: &HashMap<UserName, UserVote>,
|
votes: &BTreeMap<UserName, UserVote>,
|
||||||
|
_: &dyn askama::Values,
|
||||||
max: &i64,
|
max: &i64,
|
||||||
) -> askama::Result<Vec<(UserName, UserVote)>> {
|
) -> askama::Result<Vec<(UserName, UserVote)>> {
|
||||||
let mut non_binding_votes: 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();
|
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));
|
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())
|
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" %}
|
{%- extends "vote-closed.md" -%}
|
||||||
|
{% block introduction -%}
|
||||||
{% block introduction %}
|
|
||||||
The vote for "**{{ issue_title }}** (**#{{ issue_number }}**)" is now closed.
|
The vote for "**{{ issue_title }}** (**#{{ issue_number }}**)" is now closed.
|
||||||
{% endblock %}
|
{{ "" +}}
|
||||||
|
{% endblock +%}
|
||||||
|
|
||||||
{% block title %}Vote results{% endblock %}
|
{%- block title %}Vote results{% endblock -%}
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
{% block introduction %}{% endblock %}
|
{%- block introduction %}{%+ endblock -%}
|
||||||
|
|
||||||
## {% block title %}Vote closed{% endblock %}
|
## {% block title %}Vote closed{% endblock %}
|
||||||
|
|
||||||
The vote {% if results.passed %}**passed**! 🎉{% else %}**did not pass**.{% endif %}
|
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
|
### Summary
|
||||||
|
|
||||||
|
@ -12,28 +11,27 @@ The vote {% if results.passed %}**passed**! 🎉{% else %}**did not pass**.{% en
|
||||||
| :--------------------: | :-------------------: | :------------------: | :---------------------: |
|
| :--------------------: | :-------------------: | :------------------: | :---------------------: |
|
||||||
| {{ results.in_favor }} | {{ results.against }} | {{ results.abstain}} | {{ results.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 ~%}
|
{%- if results.binding > 0 ~%}
|
||||||
### Binding votes ({{ results.binding }})
|
### Binding votes ({{ results.binding }})
|
||||||
|
{{ "" }}
|
||||||
{{~ "| User | Vote | Timestamp |" }}
|
{{~ "| User | Vote | Timestamp |" }}
|
||||||
{{~ "| ---- | :---: | :-------: |" }}
|
{{~ "| ---- | :---: | :-------: |" }}
|
||||||
{%- for (user, vote) in results.votes ~%}
|
{%- for (user, vote) in results.votes ~%}
|
||||||
{%- if vote.binding ~%}
|
{%- if vote.binding ~%}
|
||||||
| @{{ user }} | {{ vote.vote_option }} | {{ vote.timestamp }} {{ "|" -}}
|
| @{{ user }} | {{ vote.vote_option }} | {{ vote.timestamp }} {{ "|" -}}
|
||||||
{% endif -%}
|
{% endif -%}
|
||||||
{% endfor -%}
|
{% endfor %}
|
||||||
{% endif -%}
|
{% endif -%}
|
||||||
|
|
||||||
{% if results.non_binding > 0 ~%}
|
{% if results.non_binding > 0 ~%}
|
||||||
<details>
|
<details>
|
||||||
<summary><h3>Non-binding votes ({{ results.non_binding }})</h3></summary>
|
<summary><h3>Non-binding votes ({{ results.non_binding }})</h3></summary>
|
||||||
|
|
||||||
{% let max_non_binding = 300 -%}
|
{%~ let max_non_binding = 300 %}
|
||||||
{% if results.non_binding > max_non_binding %}
|
{%- if results.non_binding > max_non_binding %}
|
||||||
<i>(displaying only the first {{ max_non_binding }} non-binding votes)</i>
|
<i>(displaying only the first {{ max_non_binding }} non-binding votes)</i>
|
||||||
{% endif %}
|
{%- endif %}
|
||||||
|
|
||||||
{{~ "| User | Vote | Timestamp |" }}
|
{{~ "| User | Vote | Timestamp |" }}
|
||||||
{{~ "| ---- | :---: | :-------: |" }}
|
{{~ "| ---- | :---: | :-------: |" }}
|
||||||
|
@ -41,6 +39,5 @@ The vote {% if results.passed %}**passed**! 🎉{% else %}**did not pass**.{% en
|
||||||
| @{{ user }} | {{ vote.vote_option }} | {{ vote.timestamp }} {{ "|" -}}
|
| @{{ user }} | {{ vote.vote_option }} | {{ vote.timestamp }} {{ "|" -}}
|
||||||
{% endfor ~%}
|
{% endfor ~%}
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif -%}
|
||||||
|
{% endif -%}
|
||||||
{% endif %}
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
**@{{ creator }}** has called for a vote on `{{ issue_title }}` (#{{ issue_number }}).
|
**@{{ 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() ~%}
|
{% if !teams.is_empty() ~%}
|
||||||
The members of the following teams have binding votes:
|
The members of the following teams have binding votes:
|
||||||
|
|
||||||
|
@ -24,9 +24,9 @@
|
||||||
{% endif -%}
|
{% endif -%}
|
||||||
|
|
||||||
{% else ~%}
|
{% else ~%}
|
||||||
|
{{ " " ~}}
|
||||||
All repository collaborators have binding votes.
|
All repository collaborators have binding votes.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
Non-binding votes are also appreciated as a sign of support!
|
Non-binding votes are also appreciated as a sign of support!
|
||||||
|
|
||||||
## How to vote
|
## How to vote
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
## Vote status
|
## 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
|
### Summary
|
||||||
|
|
||||||
|
@ -19,14 +19,13 @@ So far `{{ "{:.2}"|format(results.in_favor_percentage) }}%` of the users with bi
|
||||||
{% endfor -%}
|
{% endfor -%}
|
||||||
{%- for user in results.pending_voters ~%}
|
{%- for user in results.pending_voters ~%}
|
||||||
| @{{ user }} | *Pending* | {{ "|" -}}
|
| @{{ user }} | *Pending* | {{ "|" -}}
|
||||||
{% endfor -%}
|
{%- endfor %}
|
||||||
|
|
||||||
{% if results.non_binding > 0 ~%}
|
{% if results.non_binding > 0 ~%}
|
||||||
<details>
|
<details>
|
||||||
<summary><h3>Non-binding votes ({{ results.non_binding }})</h3></summary>
|
<summary><h3>Non-binding votes ({{ results.non_binding }})</h3></summary>
|
||||||
|
|
||||||
{% let max_non_binding = 300 -%}
|
{%~ let max_non_binding = 300 %}
|
||||||
{% if results.non_binding > max_non_binding %}
|
{%- if results.non_binding > max_non_binding %}
|
||||||
<i>(displaying only the first {{ max_non_binding }} non-binding votes)</i>
|
<i>(displaying only the first {{ max_non_binding }} non-binding votes)</i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue