mirror of https://github.com/cncf/gitvote.git
Compare commits
68 Commits
gitvote-ch
...
main
Author | SHA1 | Date |
---|---|---|
|
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 | |
|
57050c556b | |
|
5cc7cabad5 | |
|
d16c4f8925 | |
|
84709948e3 | |
|
9328e7fde9 | |
|
805caba314 | |
|
2169347775 | |
|
3dfae8bd3a | |
|
15fe57d02e | |
|
3f3e55f01e | |
|
69db07e807 | |
|
19b3ef4420 | |
|
e55afaf1e9 | |
|
4531612f77 | |
|
991e843100 | |
|
104c3d80e7 | |
|
f47b91c8a1 | |
|
b315f8958a | |
|
26247e7497 | |
|
c5ffcd6736 | |
|
4845ce597b | |
|
54bbc84455 | |
|
70c90bde57 | |
|
06dad9e1c2 | |
|
f26447bea0 | |
|
04296427d8 | |
|
e02360495b | |
|
4b7c5dd367 | |
|
cd50898ee4 | |
|
50ef9ea703 | |
|
021f386afb | |
|
5760621e26 | |
|
ce2a33d6cb | |
|
99088d6473 | |
|
81e7015cbd | |
|
25c538a4dc | |
|
a1e9b188f4 | |
|
12276f42ad | |
|
23742f949e | |
|
56f5921470 | |
|
f7abe55cf2 | |
|
9a577edff4 |
|
@ -4,19 +4,30 @@ updates:
|
|||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "wednesday"
|
||||
groups:
|
||||
backend:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "wednesday"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "wednesday"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/database/migrations"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "wednesday"
|
||||
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
name: Build images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-gitvote-dbmigrator-image:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: us-east-2
|
||||
- name: Login to AWS ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@v2
|
||||
- name: Build and push gitvote-dbmigrator image
|
||||
env:
|
||||
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
|
||||
run: |
|
||||
docker build -f database/migrations/Dockerfile -t $ECR_REGISTRY/gitvote-dbmigrator:$GITHUB_SHA .
|
||||
docker push $ECR_REGISTRY/gitvote-dbmigrator:$GITHUB_SHA
|
||||
|
||||
build-gitvote-image:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: us-east-2
|
||||
- name: Login to AWS ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@v2
|
||||
- name: Build and push gitvote image
|
||||
env:
|
||||
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
|
||||
run: |
|
||||
docker build -t $ECR_REGISTRY/gitvote:$GITHUB_SHA .
|
||||
docker push $ECR_REGISTRY/gitvote:$GITHUB_SHA
|
|
@ -1,4 +1,5 @@
|
|||
name: Helm CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
|
@ -8,22 +9,22 @@ permissions: read-all
|
|||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v3
|
||||
uses: azure/setup-helm@v4
|
||||
with:
|
||||
version: v3.9.2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@v2.4.0
|
||||
uses: helm/chart-testing-action@v2.7.0
|
||||
- name: Run chart-testing (list-changed)
|
||||
id: list-changed
|
||||
run: |
|
||||
|
@ -34,7 +35,7 @@ jobs:
|
|||
- name: Run chart-testing (lint)
|
||||
run: ct lint --config .ct.yaml --target-branch ${{ github.event.repository.default_branch }}
|
||||
- name: Create kind cluster
|
||||
uses: helm/kind-action@v1.8.0
|
||||
uses: helm/kind-action@v1.12.0
|
||||
if: steps.list-changed.outputs.changed == 'true'
|
||||
- name: Run chart-testing (install)
|
||||
run: ct install --config .ct.yaml --target-branch ${{ github.event.repository.default_branch }}
|
||||
|
|
|
@ -1,92 +1,35 @@
|
|||
name: CI
|
||||
on: [push, pull_request]
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
linter-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: stable
|
||||
profile: minimal
|
||||
toolchain: 1.87.0
|
||||
components: clippy, rustfmt
|
||||
override: true
|
||||
- name: Run clippy
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: clippy
|
||||
args: --all-targets --all-features -- --deny warnings
|
||||
run: cargo clippy --all-targets --all-features -- --deny warnings
|
||||
- name: Run rustfmt
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: fmt
|
||||
args: --all -- --check
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
tests-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: stable
|
||||
profile: minimal
|
||||
override: true
|
||||
toolchain: 1.87.0
|
||||
- name: Run backend tests
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: test
|
||||
|
||||
build-gitvote-dbmigrator-image:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs:
|
||||
- linter-backend
|
||||
- tests-backend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v2
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: us-east-2
|
||||
- name: Login to AWS ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@v1
|
||||
- name: Build and push gitvote-dbmigrator image
|
||||
env:
|
||||
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
|
||||
run: |
|
||||
docker build -f database/migrations/Dockerfile -t $ECR_REGISTRY/gitvote-dbmigrator:$GITHUB_SHA .
|
||||
docker push $ECR_REGISTRY/gitvote-dbmigrator:$GITHUB_SHA
|
||||
|
||||
build-gitvote-image:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs:
|
||||
- linter-backend
|
||||
- tests-backend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v2
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: us-east-2
|
||||
- name: Login to AWS ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@v1
|
||||
- name: Build and push gitvote image
|
||||
env:
|
||||
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
|
||||
run: |
|
||||
docker build -t $ECR_REGISTRY/gitvote:$GITHUB_SHA .
|
||||
docker push $ECR_REGISTRY/gitvote:$GITHUB_SHA
|
||||
run: cargo test
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
|
@ -8,12 +9,12 @@ permissions: read-all
|
|||
|
||||
jobs:
|
||||
build-and-publish-images:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Login to AWS Public ECR
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: public.ecr.aws
|
||||
username: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
|
@ -45,7 +46,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Configure Git
|
||||
|
@ -53,7 +54,7 @@ jobs:
|
|||
git config user.name "$GITHUB_ACTOR"
|
||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v3
|
||||
uses: azure/setup-helm@v4
|
||||
- name: Run chart-releaser
|
||||
run: |
|
||||
# From: https://github.com/metallb/metallb/blob/293f43c1f78ab1b5fa8879a76746b094bd9dd3ca/.github/workflows/publish.yaml#L134-L163
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
max_width = 110
|
||||
chain_width = 90
|
23
ADOPTERS.md
23
ADOPTERS.md
|
@ -2,7 +2,26 @@
|
|||
|
||||
If your organization is using GitVote, please consider adding it to this list by submitting a pull request.
|
||||
|
||||
- [AsyncAPI](https://github.com/asyncapi/community/blob/master/voting.md)
|
||||
- [CloudNativePG](https://cloudnative-pg.io)
|
||||
- [CNCF](https://cncf.io)
|
||||
- [ORAS](https://oras.land)
|
||||
- [ResBaz Arizona](https://researchbazaar.arizona.edu)
|
||||
- [DevRel Foundation](https://dev-rel.org/)
|
||||
- [Fintech Open Source Foundation](www.finos.org)
|
||||
- [JSON Schema](https://json-schema.org)
|
||||
- [K8sGateway](https://k8sgateway.io)
|
||||
- [KServe](https://kserve.github.io/website/latest/)
|
||||
- [Kuadrant](https://kuadrant.io)
|
||||
- [Kuma](https://kuma.io)
|
||||
- [Kyverno](https://kyverno.io)
|
||||
- [Microcks](https://microcks.io/)
|
||||
- [NFDI4Health](https://github.com/nfdi4health)
|
||||
- [Open Component Model](https://ocm.software)
|
||||
- [OpenGemini](https://opengemini.org)
|
||||
- [OpenSSF](https://openssf.org)
|
||||
- [ORAS](https://oras.land)
|
||||
- [OSCAL Compass](https://github.com/oscal-compass)
|
||||
- [Ratify Project](https://ratify.dev)
|
||||
- [ResBaz Arizona](https://researchbazaar.arizona.edu)
|
||||
- [TODO Group](https://todogroup.org)
|
||||
- [Universal Blue](https://universal-blue.org)
|
||||
- [WasmEdge](https://wasmedge.org/)
|
||||
|
|
File diff suppressed because it is too large
Load Diff
74
Cargo.toml
74
Cargo.toml
|
@ -1,57 +1,59 @@
|
|||
[package]
|
||||
name = "gitvote"
|
||||
description = "GitVote server"
|
||||
version = "1.1.0"
|
||||
version = "1.4.0"
|
||||
license = "Apache-2.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.87"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.75"
|
||||
askama = "0.12.0"
|
||||
askama_axum = "0.3.0"
|
||||
async-channel = "1.9.0"
|
||||
async-trait = "0.1.73"
|
||||
axum = { version = "0.6.20", features = ["macros"] }
|
||||
clap = { version = "4.3.24", features = ["derive"] }
|
||||
config = "0.13.3"
|
||||
deadpool-postgres = { version = "0.10.5", features = ["serde"] }
|
||||
futures = "0.3.28"
|
||||
anyhow = "1.0.98"
|
||||
askama = { version = "0.14.0", features = ["serde_json"] }
|
||||
async-channel = "2.3.1"
|
||||
async-trait = "0.1.88"
|
||||
axum = { version = "0.8.4", features = ["macros"] }
|
||||
clap = { version = "4.5.40", features = ["derive"] }
|
||||
deadpool-postgres = { version = "0.14.1", features = ["serde"] }
|
||||
figment = { version = "0.10.19", features = ["yaml", "env"] }
|
||||
futures = "0.3.31"
|
||||
graphql_client = { version = "0.14.0", features = ["reqwest"] }
|
||||
hex = "0.4.3"
|
||||
hmac = "0.12.1"
|
||||
humantime = "2.1.0"
|
||||
http = "1.3.1"
|
||||
humantime = "2.2.0"
|
||||
humantime-serde = "1.1.1"
|
||||
ignore = "0.4.20"
|
||||
jsonwebtoken = "8.3.0"
|
||||
lazy_static = "1.4.0"
|
||||
octocrab = "0.29.3"
|
||||
openssl = { version = "0.10.56", features = ["vendored"] }
|
||||
postgres-openssl = "0.5.0"
|
||||
regex = "1.9.3"
|
||||
reqwest = "0.11.19"
|
||||
serde = { version = "1.0.185", features = ["derive"] }
|
||||
serde_json = "1.0.105"
|
||||
serde_yaml = "0.9.25"
|
||||
sha2 = "0.10.7"
|
||||
thiserror = "1.0.47"
|
||||
time = { version = "0.3.27", features = ["serde"] }
|
||||
tokio = { version = "1.32.0", features = [
|
||||
ignore = "0.4.23"
|
||||
jsonwebtoken = "9.3.1"
|
||||
octocrab = "0.44.1"
|
||||
openssl = { version = "0.10.73", features = ["vendored"] }
|
||||
postgres-openssl = "0.5.1"
|
||||
regex = "1.11.1"
|
||||
reqwest = "0.12.20"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
serde_yaml = "0.9.34"
|
||||
sha2 = "0.10.9"
|
||||
thiserror = "2.0.12"
|
||||
time = { version = "0.3.41", features = ["serde"] }
|
||||
tokio = { version = "1.45.1", features = [
|
||||
"macros",
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
"time",
|
||||
] }
|
||||
tokio-postgres = { version = "0.7.9", features = [
|
||||
tokio-postgres = { version = "0.7.13", features = [
|
||||
"with-uuid-1",
|
||||
"with-serde_json-1",
|
||||
"with-time-0_3",
|
||||
] }
|
||||
tower = "0.4.13"
|
||||
tower-http = { version = "0.4.3", features = ["trace"] }
|
||||
tracing = "=0.1.37"
|
||||
tracing-subscriber = { version = "0.3.17", features = ["env-filter", "json"] }
|
||||
uuid = { version = "1.4.1", features = ["serde", "v4"] }
|
||||
tokio-util = { version = "0.7.15", features = ["rt"] }
|
||||
tower = { version = "0.5.2", features = ["util"] }
|
||||
tower-http = { version = "0.6.6", features = ["trace"] }
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] }
|
||||
uuid = { version = "1.17.0", features = ["serde", "v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
http-body = "0.4.5"
|
||||
hyper = "0.14.27"
|
||||
mockall = "0.11.4"
|
||||
http-body = "1.0.1"
|
||||
hyper = "1.6.0"
|
||||
mockall = "0.13.1"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# Build gitvote
|
||||
FROM rust:1-alpine3.18 as builder
|
||||
FROM rust:1-alpine3.22 as builder
|
||||
RUN apk --no-cache add musl-dev perl make
|
||||
WORKDIR /gitvote
|
||||
COPY src src
|
||||
|
@ -10,7 +10,7 @@ WORKDIR /gitvote/src
|
|||
RUN cargo build --release
|
||||
|
||||
# Final stage
|
||||
FROM alpine:3.18.3
|
||||
FROM alpine:3.22.0
|
||||
RUN apk --no-cache add ca-certificates && addgroup -S gitvote && adduser -S gitvote -G gitvote
|
||||
USER gitvote
|
||||
WORKDIR /home/gitvote
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
# GitVote Governance
|
||||
|
||||
This document defines the project governance for GitVote including how someone become a maintainer, how decisions are made, how changes are made to the governance, and more.
|
||||
|
||||
## Contributors
|
||||
|
||||
Anyone can propose a change to GitVote. This includes the code, the documentation, and even the governance. Details on contributing can be found in the [CONTRIBUTING.md](CONTRIBUTING.md) file.
|
||||
|
||||
## Maintainers
|
||||
|
||||
Maintainers are responsible for the development and operation of the project. This includes but is not limited to:
|
||||
|
||||
- Reviewing and merging pull requests
|
||||
- The operation of the GitVote service and GitHub application
|
||||
- Refining the projects governance
|
||||
- Overseeing the resolution and disclosure of security issues
|
||||
|
||||
Changes to maintainers use the following rules:
|
||||
|
||||
- New maintainers can be added with a [super-majority](https://en.wikipedia.org/wiki/Supermajority#Two-thirds_vote) vote. The vote must happen in a tracked location (e.g., mailing list, GitHub issue, etc).
|
||||
- If a maintainer is inactive for > 6 months they will automatically be removed unless a super-majority of the other maintainers agrees to extend the period of inactivity. This is useful when there is a known period of inactivity and a maintainer will be returning.
|
||||
- A maintainer may step down at any time and remove themselves.
|
||||
- If a maintainer needs to be removed, a super-majority vote of the other maintainer is required. This vote needs to happen in a tracked location.
|
||||
|
||||
## Decision Making
|
||||
|
||||
There are 3 ways decisions can be made for non-code related decisions. Those are:
|
||||
|
||||
1. [Lazy-consensus](http://communitymgt.wikia.com/wiki/Lazy_consensus) is the default method to make decisions.
|
||||
2. When a lazy-consensus decision cannot be made it will move to a [majority](https://en.wikipedia.org/wiki/Majority) vote unless otherwise specified in this governance.
|
||||
3. Some decisions require a super-majority of maintainer to approve. Those include:
|
||||
- Changes to the governance
|
||||
- Removing a maintainer
|
||||
- Licensing and intellectual property changes
|
||||
|
||||
Changes to source code requires a maintainer to approve the changes.
|
30
README.md
30
README.md
|
@ -1,6 +1,5 @@
|
|||
# GitVote
|
||||
|
||||
[](https://github.com/cncf/gitvote/actions/workflows/ci.yml)
|
||||
[](https://artifacthub.io/packages/helm/gitvote/gitvote)
|
||||
|
||||
**GitVote** is a GitHub application that allows holding a vote on *issues* and *pull requests*.
|
||||
|
@ -18,7 +17,8 @@ To create votes, you'll first need to add a [.gitvote.yml](https://github.com/cn
|
|||
- At the root of the repository where the vote was created
|
||||
- At the root of the `.github` repository, for organization wide configuration
|
||||
|
||||
Please note that the configuration file is **required** and no commands will be processed if it cannot be found. Once a vote is created, the configuration it will use during its lifetime will be the one present at the vote creation time.
|
||||
> [!IMPORTANT]
|
||||
> Please note that the configuration file is **required** and no commands will be processed if it cannot be found. Once a vote is created, the configuration it will use during its lifetime will be the one present at the vote creation time.
|
||||
|
||||
For more information about the configuration file format please see the [reference documentation](https://github.com/cncf/gitvote/blob/main/docs/config/.gitvote.yml).
|
||||
|
||||
|
@ -35,7 +35,8 @@ The command **must** be on a line by itself. Please note that GitVote only detec
|
|||
|
||||
Alternatively, if you have setup multiple configuration profiles, you can also start votes using any of them with the command `/vote-PROFILE`.
|
||||
|
||||
Only repositories collaborators can create votes. 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.
|
||||
> [!NOTE]
|
||||
> Only repositories collaborators can create votes. 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.
|
||||
|
||||
Shortly after the comment with the `/vote` command is posted, the vote will be created and the bot will post a new comment to the corresponding issue or pull request with the vote instructions.
|
||||
|
||||
|
@ -57,7 +58,8 @@ It is possible to vote `in favor`, `against` or to `abstain`, and each of these
|
|||
|
||||
Only votes from users with a binding vote as defined in the configuration file will be counted.
|
||||
|
||||
*Please note that voting multiple options is not allowed and those votes won't be counted.*
|
||||
> [!WARNING]
|
||||
> Voting multiple options is not allowed and those votes won't be counted.
|
||||
|
||||
### Checking votes
|
||||
|
||||
|
@ -65,7 +67,8 @@ It is possible to check the status of a vote in progress by calling the `/check-
|
|||
|
||||

|
||||
|
||||
*Please note that this command can only be called once a day per vote (additional calls will be ignored).*
|
||||
> [!NOTE]
|
||||
> This command can only be called once a day per vote (additional calls will be ignored).
|
||||
|
||||
### Closing votes
|
||||
|
||||
|
@ -79,13 +82,26 @@ It is possible to cancel a vote in progress by calling the `/cancel-vote` comman
|
|||
|
||||

|
||||
|
||||
### Checks in pull requests (experimental)
|
||||
### Checks in pull requests
|
||||
|
||||
When a vote on a pull request is closed, GitVote will add a check to the head commit with its result. If the vote passes, the result of the check will be *success*, whereas if it doesn't pass, it'll be *failure*. When used in combination with `protected branch`, this feature can be used to *require* a vote in favor before a pull request can be merged.
|
||||
|
||||

|
||||
|
||||
At the moment this feature is always enabled but we'll make it configurable so that votes creators can opt-out per configuration profile.
|
||||
### Announcements
|
||||
|
||||
GitVote is able to post announcements on GitHub discussions. When this feature is enabled, a new discussion will be created with the results of the vote once it is closed.
|
||||
|
||||
Announcements can be configured per vote profile, by adding the `announcements` configuration section to the `.gitvote.yml` file.
|
||||
|
||||
```yaml
|
||||
announcements:
|
||||
discussions:
|
||||
category: announcements # Category slug (i.e. spaces are replaced by hyphens)
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> This feature requires some extra permissions to be able to read and write discussions in your repositories. If you installed the GitVote GitHub application before this feature was available (June 2024), you should receive an email from GitHub requesting you to approve these new permissions. Please note that announcements won't be created until those permissions are granted.
|
||||
|
||||
## Adopters
|
||||
|
||||
|
|
|
@ -2,8 +2,8 @@ apiVersion: v2
|
|||
name: gitvote
|
||||
description: GitVote is a GitHub application that allows holding a vote on issues and pull requests
|
||||
type: application
|
||||
version: 1.1.0
|
||||
appVersion: 1.1.0
|
||||
version: 1.4.0
|
||||
appVersion: 1.4.0
|
||||
kubeVersion: ">= 1.19.0-0"
|
||||
home: https://gitvote.dev
|
||||
icon: https://raw.githubusercontent.com/cncf/gitvote/main/docs/logo/logo.png
|
||||
|
@ -25,25 +25,25 @@ annotations:
|
|||
artifacthub.io/category: skip-prediction
|
||||
artifacthub.io/changes: |
|
||||
- kind: added
|
||||
description: Support for automatic periodic status checks
|
||||
description: Minimum wait support to close on passing
|
||||
- kind: added
|
||||
description: Auto-close vote on passing feature
|
||||
- kind: added
|
||||
description: Mention pending voters when checking vote status
|
||||
- kind: added
|
||||
description: Allow excluding team maintainers from allowed voters
|
||||
description: Display percentage of voters against the vote
|
||||
- kind: changed
|
||||
description: Improve comments templates
|
||||
description: Migrate service config to figment
|
||||
- kind: changed
|
||||
description: Bump Alpine to 3.18.3
|
||||
description: Some refactoring in votes processor
|
||||
- kind: changed
|
||||
description: Bump Alpine to 3.21.0
|
||||
- kind: changed
|
||||
description: Bump Rust to 1.83
|
||||
- kind: changed
|
||||
description: Upgrade dependencies
|
||||
artifacthub.io/containsSecurityUpdates: "true"
|
||||
artifacthub.io/images: |
|
||||
- name: dbmigrator
|
||||
image: public.ecr.aws/g6m3a0y9/gitvote-dbmigrator
|
||||
image: public.ecr.aws/g6m3a0y9/gitvote-dbmigrator:v1.4.0
|
||||
- name: gitvote
|
||||
image: public.ecr.aws/g6m3a0y9/gitvote
|
||||
image: public.ecr.aws/g6m3a0y9/gitvote:v1.4.0
|
||||
artifacthub.io/links: |
|
||||
- name: source
|
||||
url: https://github.com/cncf/gitvote
|
||||
|
|
|
@ -14,6 +14,7 @@ Repository:
|
|||
|
||||
- **Checks**: *read/write*
|
||||
- **Contents**: *read*
|
||||
- **Discussions**: *read/write*
|
||||
- **Issues**: *read/write*
|
||||
- **Metadata**: *read*
|
||||
- **Pull requests**: *read/write*
|
||||
|
@ -47,6 +48,7 @@ gitvote:
|
|||
...
|
||||
-----END RSA PRIVATE KEY-----
|
||||
webhookSecret: "your-webhook-secret"
|
||||
webhookSecretFallback: "old-webhook-secret" # Handy for webhook secret rotation
|
||||
```
|
||||
|
||||
To install the chart with the release name `my-gitvote` run:
|
||||
|
|
|
@ -8,13 +8,17 @@ stringData:
|
|||
addr: {{ .Values.gitvote.addr }}
|
||||
db:
|
||||
host: {{ default (printf "%s-postgresql.%s" .Release.Name .Release.Namespace) .Values.db.host }}
|
||||
port: {{ .Values.db.port }}
|
||||
port: {{ .Values.db.port | atoi }}
|
||||
dbname: {{ .Values.db.dbname }}
|
||||
user: {{ .Values.db.user }}
|
||||
password: {{ .Values.db.password }}
|
||||
log:
|
||||
format: {{ .Values.log.format }}
|
||||
github:
|
||||
appID: {{ .Values.gitvote.github.appID }}
|
||||
appId: {{ .Values.gitvote.github.appID }}
|
||||
appPrivateKey: {{ .Values.gitvote.github.appPrivateKey | quote }}
|
||||
webhookSecret: {{ .Values.gitvote.github.webhookSecret | quote }}
|
||||
{{- with .Values.gitvote.github.webhookSecretFallback }}
|
||||
webhookSecretFallback: {{ . | quote }}
|
||||
{{- end }}
|
||||
|
||||
|
|
|
@ -54,6 +54,8 @@ gitvote:
|
|||
appPrivateKey: null
|
||||
# GitHub application webhook secret
|
||||
webhookSecret: null
|
||||
# GitHub application webhook secret fallback (handy for webhook secret rotation)
|
||||
webhookSecretFallback: null
|
||||
|
||||
# Ingress configuration
|
||||
ingress:
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
# Build tern
|
||||
FROM golang:1.21.0-alpine3.18 AS tern
|
||||
FROM golang:1.24.4-alpine3.22 AS tern
|
||||
RUN apk --no-cache add git
|
||||
RUN go install github.com/jackc/tern@latest
|
||||
|
||||
# Build final image
|
||||
FROM alpine:3.18.3
|
||||
FROM alpine:3.22.0
|
||||
RUN addgroup -S gitvote && adduser -S gitvote -G gitvote
|
||||
USER gitvote
|
||||
WORKDIR /home/gitvote
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
alter table vote add column issue_title text;
|
||||
|
||||
---- create above / drop below ----
|
||||
|
||||
alter table vote drop column issue_title;
|
|
@ -136,6 +136,38 @@ profiles:
|
|||
#
|
||||
close_on_passing: false
|
||||
|
||||
# Close on passing minimum wait
|
||||
#
|
||||
# When the close on passing feature is activated, voting will conclude once
|
||||
# the pass threshold is met. However, there may be instances where it is
|
||||
# preferable to implement a minimum wait time, even if the vote would
|
||||
# already pass. This allows participants sufficient opportunity to engage
|
||||
# and reflect before the vote is automatically finalized.
|
||||
#
|
||||
# Units supported:
|
||||
#
|
||||
# - day / days
|
||||
# - week / weeks
|
||||
#
|
||||
# close_on_passing_min_wait: "1 week"
|
||||
#
|
||||
close_on_passing_min_wait: null
|
||||
|
||||
# Announcements
|
||||
#
|
||||
# GitVote can announce the results of a vote when it is closed on GitHub
|
||||
# discussions. This feature won't be enabled if this configuration section
|
||||
# is not provided. The slug of the category where the announcement will be
|
||||
# posted to must be specified (i.e. announcements).
|
||||
#
|
||||
# announcements:
|
||||
# discussions:
|
||||
# category: announcements
|
||||
#
|
||||
announcements:
|
||||
discussions:
|
||||
category: announcements
|
||||
|
||||
# Additional configuration profiles
|
||||
#
|
||||
# In addition to the default configuration profile, it is possible to add more
|
||||
|
|
|
@ -1,10 +1,16 @@
|
|||
use crate::github::{DynGH, File, TeamSlug, UserName};
|
||||
use anyhow::{format_err, Result};
|
||||
//! This module defines some types and functionality to represent and process
|
||||
//! the `GitVote` configuration that GitHub repositories can use to enable and
|
||||
//! customize the service.
|
||||
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use ignore::gitignore::GitignoreBuilder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::github::{DynGH, File, TeamSlug, UserName};
|
||||
|
||||
/// Default configuration profile.
|
||||
const DEFAULT_PROFILE: &str = "default";
|
||||
|
||||
|
@ -15,7 +21,7 @@ const ERR_TEAMS_NOT_ALLOWED: &str = "teams in allowed voters can only be used in
|
|||
/// Type alias to represent a profile name.
|
||||
type ProfileName = String;
|
||||
|
||||
/// GitVote configuration.
|
||||
/// `GitVote` configuration.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub(crate) struct Cfg {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
@ -24,7 +30,7 @@ pub(crate) struct Cfg {
|
|||
}
|
||||
|
||||
impl Cfg {
|
||||
/// Get the GitVote configuration for the repository provided.
|
||||
/// Get the `GitVote` configuration for the repository provided.
|
||||
pub(crate) async fn get<'a>(
|
||||
gh: DynGH,
|
||||
inst_id: u64,
|
||||
|
@ -33,8 +39,8 @@ impl Cfg {
|
|||
) -> Result<Self, CfgError> {
|
||||
match gh.get_config_file(inst_id, owner, repo).await {
|
||||
Some(content) => {
|
||||
let cfg: Cfg = serde_yaml::from_str(&content)
|
||||
.map_err(|e| CfgError::InvalidConfig(e.to_string()))?;
|
||||
let cfg: Cfg =
|
||||
serde_yaml::from_str(&content).map_err(|e| CfgError::InvalidConfig(e.to_string()))?;
|
||||
Ok(cfg)
|
||||
}
|
||||
None => Err(CfgError::ConfigNotFound),
|
||||
|
@ -65,9 +71,7 @@ impl AutomationRule {
|
|||
builder.add_line(None, pattern)?;
|
||||
}
|
||||
let checker = builder.build()?;
|
||||
let matches = files
|
||||
.iter()
|
||||
.any(|file| checker.matched(&file.filename, false).is_ignore());
|
||||
let matches = files.iter().any(|file| checker.matched(&file.filename, false).is_ignore());
|
||||
Ok(matches)
|
||||
}
|
||||
}
|
||||
|
@ -81,9 +85,13 @@ pub(crate) struct CfgProfile {
|
|||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_voters: Option<AllowedVoters>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub announcements: Option<Announcements>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub periodic_status_check: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub close_on_passing: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub close_on_passing_min_wait: Option<String>,
|
||||
}
|
||||
|
||||
impl CfgProfile {
|
||||
|
@ -113,13 +121,11 @@ impl CfgProfile {
|
|||
// Only repositories that belong to some organization can use teams in
|
||||
// the allowed voters configuration section.
|
||||
if !is_org {
|
||||
if let Some(teams) = self
|
||||
.allowed_voters
|
||||
.as_ref()
|
||||
.and_then(|allowed_voters| allowed_voters.teams.as_ref())
|
||||
if let Some(teams) =
|
||||
self.allowed_voters.as_ref().and_then(|allowed_voters| allowed_voters.teams.as_ref())
|
||||
{
|
||||
if !teams.is_empty() {
|
||||
return Err(format_err!(ERR_TEAMS_NOT_ALLOWED));
|
||||
bail!(ERR_TEAMS_NOT_ALLOWED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -139,6 +145,19 @@ pub(crate) struct AllowedVoters {
|
|||
pub exclude_team_maintainers: Option<bool>,
|
||||
}
|
||||
|
||||
/// Announcements configuration.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub(crate) struct Announcements {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub discussions: Option<DiscussionsAnnouncements>,
|
||||
}
|
||||
|
||||
/// GitHub discussions announcements configuration.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub(crate) struct DiscussionsAnnouncements {
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
/// Errors that may occur while getting the configuration profile.
|
||||
#[derive(Debug, Error, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub(crate) enum CfgError {
|
||||
|
@ -152,12 +171,15 @@ pub(crate) enum CfgError {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::github::MockGH;
|
||||
use crate::testutil::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::future;
|
||||
use mockall::predicate::eq;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::github::MockGH;
|
||||
use crate::testutil::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn automation_rule_matches() {
|
||||
|
@ -200,13 +222,12 @@ mod tests {
|
|||
let mut gh = MockGH::new();
|
||||
gh.expect_get_config_file()
|
||||
.with(eq(INST_ID), eq(OWNER), eq(REPO))
|
||||
.times(1)
|
||||
.returning(|_, _, _| Box::pin(future::ready(None)));
|
||||
let gh = Arc::new(gh);
|
||||
|
||||
assert_eq!(
|
||||
CfgProfile::get(gh, INST_ID, OWNER, OWNER_IS_ORG, REPO, None)
|
||||
.await
|
||||
.unwrap_err(),
|
||||
CfgProfile::get(gh, INST_ID, OWNER, OWNER_IS_ORG, REPO, None).await.unwrap_err(),
|
||||
CfgError::ConfigNotFound
|
||||
);
|
||||
}
|
||||
|
@ -216,6 +237,7 @@ mod tests {
|
|||
let mut gh = MockGH::new();
|
||||
gh.expect_get_config_file()
|
||||
.with(eq(INST_ID), eq(OWNER), eq(REPO))
|
||||
.times(1)
|
||||
.returning(|_, _, _| Box::pin(future::ready(Some(get_test_invalid_config()))));
|
||||
let gh = Arc::new(gh);
|
||||
|
||||
|
@ -239,6 +261,7 @@ mod tests {
|
|||
let mut gh = MockGH::new();
|
||||
gh.expect_get_config_file()
|
||||
.with(eq(INST_ID), eq(OWNER), eq(REPO))
|
||||
.times(1)
|
||||
.returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config()))));
|
||||
let gh = Arc::new(gh);
|
||||
|
||||
|
@ -262,6 +285,7 @@ mod tests {
|
|||
let mut gh = MockGH::new();
|
||||
gh.expect_get_config_file()
|
||||
.with(eq(INST_ID), eq(OWNER), eq(REPO))
|
||||
.times(1)
|
||||
.returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config()))));
|
||||
let gh = Arc::new(gh);
|
||||
|
||||
|
@ -285,13 +309,12 @@ mod tests {
|
|||
let mut gh = MockGH::new();
|
||||
gh.expect_get_config_file()
|
||||
.with(eq(INST_ID), eq(OWNER), eq(REPO))
|
||||
.times(1)
|
||||
.returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config()))));
|
||||
let gh = Arc::new(gh);
|
||||
|
||||
assert_eq!(
|
||||
CfgProfile::get(gh, INST_ID, OWNER, OWNER_IS_ORG, REPO, None)
|
||||
.await
|
||||
.unwrap(),
|
||||
CfgProfile::get(gh, INST_ID, OWNER, OWNER_IS_ORG, REPO, None).await.unwrap(),
|
||||
CfgProfile {
|
||||
duration: Duration::from_secs(300),
|
||||
pass_threshold: 50.0,
|
||||
|
@ -306,6 +329,7 @@ mod tests {
|
|||
let mut gh = MockGH::new();
|
||||
gh.expect_get_config_file()
|
||||
.with(eq(INST_ID), eq(OWNER), eq(REPO))
|
||||
.times(1)
|
||||
.returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config()))));
|
||||
let gh = Arc::new(gh);
|
||||
|
|
@ -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>,
|
||||
}
|
58
src/cmd.rs
58
src/cmd.rs
|
@ -1,23 +1,28 @@
|
|||
use crate::{
|
||||
cfg::{Cfg, CfgError},
|
||||
github::*,
|
||||
};
|
||||
//! This module defines the commands supported and the logic to parse them from
|
||||
//! GitHub events.
|
||||
|
||||
use anyhow::Result;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::LazyLock;
|
||||
use tracing::error;
|
||||
|
||||
use crate::{
|
||||
cfg_repo::{Cfg, CfgError},
|
||||
github::{
|
||||
split_full_name, DynGH, Event, IssueCommentEventAction, IssueEventAction, PullRequestEventAction,
|
||||
},
|
||||
};
|
||||
|
||||
/// Available commands.
|
||||
const CMD_CREATE_VOTE: &str = "vote";
|
||||
const CMD_CANCEL_VOTE: &str = "cancel-vote";
|
||||
const CMD_CHECK_VOTE: &str = "check-vote";
|
||||
|
||||
lazy_static! {
|
||||
/// Regex used to detect commands in issues/prs comments.
|
||||
static ref CMD: Regex = Regex::new(r"(?m)^/(vote|cancel-vote|check-vote)-?([a-zA-Z0-9]*)\s*$")
|
||||
.expect("invalid CMD regexp");
|
||||
}
|
||||
/// Regex used to detect commands in issues/prs comments.
|
||||
static CMD: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?m)^/(vote|cancel-vote|check-vote)-?([a-zA-Z0-9]*)\s*$").expect("invalid CMD regexp")
|
||||
});
|
||||
|
||||
/// Represents a command to be executed, usually created from a GitHub event.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
@ -81,9 +86,7 @@ impl Command {
|
|||
CMD_CREATE_VOTE => {
|
||||
return Some(Command::CreateVote(CreateVoteInput::new(profile, event)))
|
||||
}
|
||||
CMD_CANCEL_VOTE => {
|
||||
return Some(Command::CancelVote(CancelVoteInput::new(event)))
|
||||
}
|
||||
CMD_CANCEL_VOTE => return Some(Command::CancelVote(CancelVoteInput::new(event))),
|
||||
CMD_CHECK_VOTE => return Some(Command::CheckVote(CheckVoteInput::new(event))),
|
||||
_ => return None,
|
||||
}
|
||||
|
@ -149,7 +152,7 @@ pub(crate) struct CreateVoteInput {
|
|||
}
|
||||
|
||||
impl CreateVoteInput {
|
||||
/// Create a new CreateVoteInput instance from the profile and event
|
||||
/// Create a new `CreateVoteInput` instance from the profile and event
|
||||
/// provided.
|
||||
pub(crate) fn new(profile_name: Option<&str>, event: &Event) -> Self {
|
||||
match event {
|
||||
|
@ -201,7 +204,7 @@ pub(crate) struct CancelVoteInput {
|
|||
}
|
||||
|
||||
impl CancelVoteInput {
|
||||
/// Create a new CancelVoteInput instance from the event provided.
|
||||
/// Create a new `CancelVoteInput` instance from the event provided.
|
||||
pub(crate) fn new(event: &Event) -> Self {
|
||||
match event {
|
||||
Event::Issue(event) => Self {
|
||||
|
@ -237,7 +240,7 @@ pub(crate) struct CheckVoteInput {
|
|||
}
|
||||
|
||||
impl CheckVoteInput {
|
||||
/// Create a new CheckVoteInput instance from the event provided.
|
||||
/// Create a new `CheckVoteInput` instance from the event provided.
|
||||
pub(crate) fn new(event: &Event) -> Self {
|
||||
match event {
|
||||
Event::Issue(event) => Self {
|
||||
|
@ -258,11 +261,17 @@ impl CheckVoteInput {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use std::{sync::Arc, vec};
|
||||
|
||||
use futures::future;
|
||||
use mockall::predicate::eq;
|
||||
use std::{sync::Arc, vec};
|
||||
|
||||
use crate::{
|
||||
github::{File, MockGH},
|
||||
testutil::*,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn manual_command_from_issue_event_unsupported_action() {
|
||||
|
@ -377,9 +386,11 @@ mod tests {
|
|||
let mut gh = MockGH::new();
|
||||
gh.expect_get_config_file()
|
||||
.with(eq(INST_ID), eq(ORG), eq(REPO))
|
||||
.times(1)
|
||||
.returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config()))));
|
||||
gh.expect_get_pr_files()
|
||||
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM))
|
||||
.times(1)
|
||||
.returning(|_, _, _, _| {
|
||||
Box::pin(future::ready(Ok(vec![File {
|
||||
filename: "README.md".to_string(),
|
||||
|
@ -392,13 +403,8 @@ mod tests {
|
|||
let event = Event::PullRequest(event);
|
||||
|
||||
assert_eq!(
|
||||
Command::from_event_automatic(gh, &event.clone())
|
||||
.await
|
||||
.unwrap(),
|
||||
Some(Command::CreateVote(CreateVoteInput::new(
|
||||
Some("default"),
|
||||
&event
|
||||
)))
|
||||
Command::from_event_automatic(gh, &event.clone()).await.unwrap(),
|
||||
Some(Command::CreateVote(CreateVoteInput::new(Some("default"), &event)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
83
src/db.rs
83
src/db.rs
|
@ -1,18 +1,22 @@
|
|||
use crate::{
|
||||
cfg::CfgProfile,
|
||||
cmd::{CheckVoteInput, CreateVoteInput},
|
||||
github::{split_full_name, DynGH},
|
||||
results::{self, Vote, VoteResults},
|
||||
};
|
||||
//! This module defines an abstraction layer over the database.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use deadpool_postgres::{Pool, Transaction};
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
use std::sync::Arc;
|
||||
use tokio_postgres::types::Json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
cfg_repo::CfgProfile,
|
||||
cmd::{CheckVoteInput, CreateVoteInput},
|
||||
github::{self, split_full_name, DynGH},
|
||||
results::{self, Vote, VoteResults},
|
||||
};
|
||||
|
||||
/// Type alias to represent a DB trait object.
|
||||
pub(crate) type DynDB = Arc<dyn DB + Send + Sync>;
|
||||
|
||||
|
@ -21,21 +25,13 @@ pub(crate) type DynDB = Arc<dyn DB + Send + Sync>;
|
|||
#[cfg_attr(test, automock)]
|
||||
pub(crate) trait DB {
|
||||
/// Cancel open vote (if exists) in the issue/pr provided.
|
||||
async fn cancel_vote(
|
||||
&self,
|
||||
repository_full_name: &str,
|
||||
issue_number: i64,
|
||||
) -> Result<Option<Uuid>>;
|
||||
async fn cancel_vote(&self, repository_full_name: &str, issue_number: i64) -> Result<Option<Uuid>>;
|
||||
|
||||
/// Close any pending finished vote.
|
||||
async fn close_finished_vote(&self, gh: DynGH) -> Result<Option<(Vote, VoteResults)>>;
|
||||
async fn close_finished_vote(&self, gh: DynGH) -> Result<Option<(Vote, Option<VoteResults>)>>;
|
||||
|
||||
/// Get open vote (if available) in the issue/pr provided.
|
||||
async fn get_open_vote(
|
||||
&self,
|
||||
repository_full_name: &str,
|
||||
issue_number: i64,
|
||||
) -> Result<Option<Vote>>;
|
||||
async fn get_open_vote(&self, repository_full_name: &str, issue_number: i64) -> Result<Option<Vote>>;
|
||||
|
||||
/// Get open votes that have close on passing enabled.
|
||||
async fn get_open_votes_with_close_on_passing(&self) -> Result<Vec<Vote>>;
|
||||
|
@ -64,13 +60,13 @@ pub(crate) trait DB {
|
|||
async fn update_vote_last_check(&self, vote_id: Uuid) -> Result<()>;
|
||||
}
|
||||
|
||||
/// DB implementation backed by PostgreSQL.
|
||||
/// DB implementation backed by `PostgreSQL`.
|
||||
pub(crate) struct PgDB {
|
||||
pool: Pool,
|
||||
}
|
||||
|
||||
impl PgDB {
|
||||
/// Create a new PgDB instance.
|
||||
/// Create a new `PgDB` instance.
|
||||
pub(crate) fn new(pool: Pool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
@ -84,6 +80,7 @@ impl PgDB {
|
|||
from vote
|
||||
where current_timestamp > ends_at
|
||||
and closed = false
|
||||
order by random()
|
||||
for update of vote skip locked
|
||||
limit 1
|
||||
",
|
||||
|
@ -98,7 +95,7 @@ impl PgDB {
|
|||
async fn store_vote_results(
|
||||
tx: &Transaction<'_>,
|
||||
vote_id: Uuid,
|
||||
results: &VoteResults,
|
||||
results: Option<&VoteResults>,
|
||||
) -> Result<()> {
|
||||
tx.execute(
|
||||
"
|
||||
|
@ -118,11 +115,7 @@ impl PgDB {
|
|||
#[async_trait]
|
||||
impl DB for PgDB {
|
||||
/// [DB::cancel_vote]
|
||||
async fn cancel_vote(
|
||||
&self,
|
||||
repository_full_name: &str,
|
||||
issue_number: i64,
|
||||
) -> Result<Option<Uuid>> {
|
||||
async fn cancel_vote(&self, repository_full_name: &str, issue_number: i64) -> Result<Option<Uuid>> {
|
||||
let db = self.pool.get().await?;
|
||||
let cancelled_vote_id = db
|
||||
.query_opt(
|
||||
|
@ -141,7 +134,7 @@ impl DB for PgDB {
|
|||
}
|
||||
|
||||
/// [DB::close_finished_vote]
|
||||
async fn close_finished_vote(&self, gh: DynGH) -> Result<Option<(Vote, VoteResults)>> {
|
||||
async fn close_finished_vote(&self, gh: DynGH) -> Result<Option<(Vote, Option<VoteResults>)>> {
|
||||
// Get pending finished vote (if any) from database
|
||||
let mut db = self.pool.get().await?;
|
||||
let tx = db.transaction().await?;
|
||||
|
@ -151,21 +144,28 @@ impl DB for PgDB {
|
|||
|
||||
// Calculate results
|
||||
let (owner, repo) = split_full_name(&vote.repository_full_name);
|
||||
let results = results::calculate(gh, owner, repo, &vote).await?;
|
||||
let results = match results::calculate(gh, owner, repo, &vote).await {
|
||||
Ok(results) => Some(results),
|
||||
Err(err) => {
|
||||
if github::is_not_found_error(&err) {
|
||||
// Vote comment was deleted. We still want to proceed and
|
||||
// close the vote so that we don't try again to close it.
|
||||
None
|
||||
} else {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Store results in database
|
||||
PgDB::store_vote_results(&tx, vote.vote_id, &results).await?;
|
||||
PgDB::store_vote_results(&tx, vote.vote_id, results.as_ref()).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Some((vote, results)))
|
||||
}
|
||||
|
||||
/// [DB::get_open_vote]
|
||||
async fn get_open_vote(
|
||||
&self,
|
||||
repository_full_name: &str,
|
||||
issue_number: i64,
|
||||
) -> Result<Option<Vote>> {
|
||||
async fn get_open_vote(&self, repository_full_name: &str, issue_number: i64) -> Result<Option<Vote>> {
|
||||
let db = self.pool.get().await?;
|
||||
let vote = db
|
||||
.query_opt(
|
||||
|
@ -194,6 +194,12 @@ impl DB for PgDB {
|
|||
where closed = false
|
||||
and cfg ? 'close_on_passing'
|
||||
and (cfg->>'close_on_passing')::boolean = true
|
||||
and
|
||||
case
|
||||
when cfg ? 'close_on_passing_min_wait' and string_to_interval(cfg->>'close_on_passing_min_wait') is not null then
|
||||
current_timestamp > created_at + (cfg->>'close_on_passing_min_wait')::interval
|
||||
else true
|
||||
end
|
||||
",
|
||||
&[],
|
||||
)
|
||||
|
@ -293,6 +299,7 @@ impl DB for PgDB {
|
|||
installation_id,
|
||||
issue_id,
|
||||
issue_number,
|
||||
issue_title,
|
||||
is_pull_request,
|
||||
repository_full_name,
|
||||
organization
|
||||
|
@ -305,9 +312,10 @@ impl DB for PgDB {
|
|||
$5::bigint,
|
||||
$6::bigint,
|
||||
$7::bigint,
|
||||
$8::boolean,
|
||||
$9::text,
|
||||
$10::text
|
||||
$8::text,
|
||||
$9::boolean,
|
||||
$10::text,
|
||||
$11::text
|
||||
)
|
||||
returning vote_id
|
||||
",
|
||||
|
@ -319,6 +327,7 @@ impl DB for PgDB {
|
|||
&input.installation_id,
|
||||
&input.issue_id,
|
||||
&input.issue_number,
|
||||
&input.issue_title,
|
||||
&input.is_pull_request,
|
||||
&input.repository_full_name,
|
||||
&input.organization,
|
||||
|
|
288
src/github.rs
288
src/github.rs
|
@ -1,16 +1,21 @@
|
|||
use crate::cfg::CfgProfile;
|
||||
use anyhow::Result;
|
||||
//! This module defines an abstraction layer over the GitHub API.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{bail, Context, Error, Result};
|
||||
use async_trait::async_trait;
|
||||
use axum::http::HeaderValue;
|
||||
use graphql_client::GraphQLQuery;
|
||||
use http::StatusCode;
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
use octocrab::{models::InstallationId, Octocrab, Page};
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::cfg_repo::CfgProfile;
|
||||
|
||||
/// GitHub API base url.
|
||||
const GITHUB_API_URL: &str = "https://api.github.com";
|
||||
|
||||
|
@ -35,10 +40,39 @@ pub(crate) type TeamSlug = String;
|
|||
/// Type alias to represent a username.
|
||||
pub(crate) type UserName = String;
|
||||
|
||||
/// Announcement repository query.
|
||||
#[derive(Debug, Clone, GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "src/graphql/github_schema.graphql",
|
||||
query_path = "src/graphql/announcement_repo_query.graphql",
|
||||
response_derives = "Debug, PartialEq, Eq"
|
||||
)]
|
||||
pub struct AnnouncementRepoQuery;
|
||||
|
||||
/// Create discussion mutation.
|
||||
#[derive(Debug, Clone, GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "src/graphql/github_schema.graphql",
|
||||
query_path = "src/graphql/create_discussion.graphql",
|
||||
response_derives = "Debug, PartialEq, Eq"
|
||||
)]
|
||||
pub struct CreateDiscussion;
|
||||
|
||||
/// Trait that defines some operations a GH implementation must support.
|
||||
#[async_trait]
|
||||
#[allow(clippy::ref_option_ref)]
|
||||
#[cfg_attr(test, automock)]
|
||||
pub(crate) trait GH {
|
||||
/// Add labels to the provided issue.
|
||||
async fn add_labels(
|
||||
&self,
|
||||
inst_id: u64,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
issue_number: i64,
|
||||
labels: &[&str],
|
||||
) -> Result<()>;
|
||||
|
||||
/// Create a check run for the head commit in the provided pull request.
|
||||
async fn create_check_run(
|
||||
&self,
|
||||
|
@ -49,6 +83,17 @@ pub(crate) trait GH {
|
|||
check_details: &CheckDetails,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Create a new discussion in the repository provided.
|
||||
async fn create_discussion(
|
||||
&self,
|
||||
inst_id: u64,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
category: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Get all users allowed to vote on a given vote.
|
||||
async fn get_allowed_voters(
|
||||
&self,
|
||||
|
@ -56,17 +101,12 @@ pub(crate) trait GH {
|
|||
cfg: &CfgProfile,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
org: &Option<String>,
|
||||
org: Option<&String>,
|
||||
) -> Result<Vec<UserName>>;
|
||||
|
||||
/// Get all repository collaborators.
|
||||
#[allow(dead_code)]
|
||||
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>>;
|
||||
|
||||
/// Get reactions for the provided comment.
|
||||
async fn get_comment_reactions(
|
||||
|
@ -81,13 +121,7 @@ pub(crate) trait GH {
|
|||
async fn get_config_file(&self, inst_id: u64, owner: &str, repo: &str) -> Option<String>;
|
||||
|
||||
/// Get pull request 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>>;
|
||||
|
||||
/// Get all members of the provided team.
|
||||
#[allow(dead_code)]
|
||||
|
@ -101,13 +135,7 @@ pub(crate) trait GH {
|
|||
|
||||
/// Verify if the GitVote check is required via branch protection in the
|
||||
/// repository's branch provided.
|
||||
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>;
|
||||
|
||||
/// Post the comment provided in the repository's issue given.
|
||||
async fn post_comment(
|
||||
|
@ -119,14 +147,18 @@ pub(crate) trait GH {
|
|||
body: &str,
|
||||
) -> Result<CommentId>;
|
||||
|
||||
/// Check if the user given is a collaborator of the provided repository.
|
||||
async fn user_is_collaborator(
|
||||
/// Remove label from the provided issue.
|
||||
async fn remove_label(
|
||||
&self,
|
||||
inst_id: u64,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
user: &str,
|
||||
) -> Result<bool>;
|
||||
issue_number: i64,
|
||||
label: &str,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Check if the user given is a collaborator of the provided repository.
|
||||
async fn user_is_collaborator(&self, inst_id: u64, owner: &str, repo: &str, user: &str) -> Result<bool>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
@ -142,7 +174,7 @@ pub(crate) struct GHApi {
|
|||
}
|
||||
|
||||
impl GHApi {
|
||||
/// Create a new GHApi instance.
|
||||
/// Create a new `GHApi` instance.
|
||||
pub(crate) fn new(app_client: Octocrab) -> Self {
|
||||
Self { app_client }
|
||||
}
|
||||
|
@ -150,6 +182,21 @@ impl GHApi {
|
|||
|
||||
#[async_trait]
|
||||
impl GH for GHApi {
|
||||
/// [GH::add_labels]
|
||||
async fn add_labels(
|
||||
&self,
|
||||
inst_id: u64,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
issue_number: i64,
|
||||
labels: &[&str],
|
||||
) -> Result<()> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
let labels = labels.iter().map(ToString::to_string).collect::<Vec<String>>();
|
||||
client.issues(owner, repo).add_labels(issue_number as u64, &labels).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [GH::create_check_run]
|
||||
async fn create_check_run(
|
||||
&self,
|
||||
|
@ -159,7 +206,7 @@ impl GH for GHApi {
|
|||
issue_number: i64,
|
||||
check_details: &CheckDetails,
|
||||
) -> Result<()> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
let pr = client.pulls(owner, repo).get(issue_number as u64).await?;
|
||||
let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/check-runs");
|
||||
let mut body = json!({
|
||||
|
@ -173,11 +220,54 @@ impl GH for GHApi {
|
|||
});
|
||||
if let Some(conclusion) = &check_details.conclusion {
|
||||
body["conclusion"] = json!(conclusion);
|
||||
};
|
||||
}
|
||||
let _: Value = client.post(url, Some(&body)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [GH::create_discussion]
|
||||
async fn create_discussion(
|
||||
&self,
|
||||
inst_id: u64,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
category: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
) -> Result<()> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
|
||||
// Fetch some repository details needed to create a discussion
|
||||
let response: graphql_client::Response<announcement_repo_query::ResponseData> = client
|
||||
.graphql(&AnnouncementRepoQuery::build_query(
|
||||
announcement_repo_query::Variables {
|
||||
owner: owner.to_string(),
|
||||
repo: repo.to_string(),
|
||||
category: category.to_string(),
|
||||
},
|
||||
))
|
||||
.await?;
|
||||
let Some((repository_id, category_id)) = response.data.and_then(|d| d.repository).and_then(|r| {
|
||||
let discussion_category = r.discussion_category?;
|
||||
Some((r.id, discussion_category.id))
|
||||
}) else {
|
||||
bail!("something went wrong while fetching repository details for announcement")
|
||||
};
|
||||
|
||||
// Create discussion
|
||||
let _: graphql_client::Response<create_discussion::ResponseData> = client
|
||||
.graphql(&CreateDiscussion::build_query(create_discussion::Variables {
|
||||
repository_id,
|
||||
category_id,
|
||||
title: title.to_string(),
|
||||
body: body.to_string(),
|
||||
}))
|
||||
.await
|
||||
.context("error creating announcement discussion")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [GH::get_allowed_voters]
|
||||
async fn get_allowed_voters(
|
||||
&self,
|
||||
|
@ -185,7 +275,7 @@ impl GH for GHApi {
|
|||
cfg: &CfgProfile,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
org: &Option<String>,
|
||||
org: Option<&String>,
|
||||
) -> Result<Vec<UserName>> {
|
||||
let mut allowed_voters: Vec<UserName> = vec![];
|
||||
|
||||
|
@ -194,8 +284,7 @@ impl GH for GHApi {
|
|||
// Teams
|
||||
if org.is_some() {
|
||||
if let Some(teams) = &cfg_allowed_voters.teams {
|
||||
let exclude_maintainers =
|
||||
cfg_allowed_voters.exclude_team_maintainers.unwrap_or(false);
|
||||
let exclude_maintainers = cfg_allowed_voters.exclude_team_maintainers.unwrap_or(false);
|
||||
for team in teams {
|
||||
if let Ok(members) = self
|
||||
.get_team_members(
|
||||
|
@ -236,21 +325,11 @@ impl GH for GHApi {
|
|||
}
|
||||
|
||||
/// [GH::get_collaborators]
|
||||
async fn get_collaborators(
|
||||
&self,
|
||||
inst_id: u64,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
) -> Result<Vec<UserName>> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
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 url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/collaborators");
|
||||
let first_page: Page<User> = client.get(url, None::<&()>).await?;
|
||||
let collaborators = client
|
||||
.all_pages(first_page)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|u| u.login)
|
||||
.collect();
|
||||
let collaborators = client.all_pages(first_page).await?.into_iter().map(|u| u.login).collect();
|
||||
Ok(collaborators)
|
||||
}
|
||||
|
||||
|
@ -262,10 +341,8 @@ impl GH for GHApi {
|
|||
repo: &str,
|
||||
comment_id: i64,
|
||||
) -> Result<Vec<Reaction>> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
let url = format!(
|
||||
"{GITHUB_API_URL}/repos/{owner}/{repo}/issues/comments/{comment_id}/reactions",
|
||||
);
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/issues/comments/{comment_id}/reactions",);
|
||||
let first_page: Page<Reaction> = client.get(url, None::<&()>).await?;
|
||||
let reactions = client.all_pages(first_page).await?;
|
||||
Ok(reactions)
|
||||
|
@ -273,26 +350,19 @@ impl GH for GHApi {
|
|||
|
||||
/// [GH::get_config_file]
|
||||
async fn get_config_file(&self, inst_id: u64, owner: &str, repo: &str) -> Option<String> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
let Ok(client) = self.app_client.installation(InstallationId(inst_id)) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Try to get the config file from the repository. Otherwise try
|
||||
// getting the organization wide config file in the .github repo.
|
||||
let mut content: Option<String> = None;
|
||||
for repo in &[repo, ORG_CONFIG_REPO] {
|
||||
match client
|
||||
.repos(owner, *repo)
|
||||
.get_content()
|
||||
.path(CONFIG_FILE)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if resp.items.len() == 1 {
|
||||
content = resp.items[0].decoded_content();
|
||||
break;
|
||||
}
|
||||
if let Ok(resp) = client.repos(owner, *repo).get_content().path(CONFIG_FILE).send().await {
|
||||
if resp.items.len() == 1 {
|
||||
content = resp.items[0].decoded_content();
|
||||
break;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -300,14 +370,8 @@ impl GH for GHApi {
|
|||
}
|
||||
|
||||
/// [GH::get_pr_files]
|
||||
async fn get_pr_files(
|
||||
&self,
|
||||
inst_id: u64,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
pr_number: i64,
|
||||
) -> Result<Vec<File>> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
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 url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/pulls/{pr_number}/files");
|
||||
let first_page: Page<File> = client.get(url, None::<&()>).await?;
|
||||
let files: Vec<File> = client.all_pages(first_page).await?;
|
||||
|
@ -322,7 +386,7 @@ impl GH for GHApi {
|
|||
team: &str,
|
||||
exclude_maintainers: bool,
|
||||
) -> Result<Vec<UserName>> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
let url = format!("{GITHUB_API_URL}/orgs/{org}/teams/{team}/members");
|
||||
let first_page: Page<User> = client
|
||||
.get(
|
||||
|
@ -332,34 +396,20 @@ impl GH for GHApi {
|
|||
})),
|
||||
)
|
||||
.await?;
|
||||
let members: Vec<UserName> = client
|
||||
.all_pages(first_page)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|u| u.login)
|
||||
.collect();
|
||||
let members: Vec<UserName> =
|
||||
client.all_pages(first_page).await?.into_iter().map(|u| u.login).collect();
|
||||
Ok(members)
|
||||
}
|
||||
|
||||
/// [GH::is_check_required]
|
||||
async fn is_check_required(
|
||||
&self,
|
||||
inst_id: u64,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
branch: &str,
|
||||
) -> Result<bool> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
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 url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/branches/{branch}");
|
||||
let branch: Branch = client.get(url, None::<&()>).await?;
|
||||
let is_check_required = if let Some(required_checks) = branch
|
||||
.protection
|
||||
.and_then(|protection| protection.required_status_checks)
|
||||
let is_check_required = if let Some(required_checks) =
|
||||
branch.protection.and_then(|protection| protection.required_status_checks)
|
||||
{
|
||||
required_checks
|
||||
.contexts
|
||||
.iter()
|
||||
.any(|context| context == GITVOTE_CHECK_NAME)
|
||||
required_checks.contexts.iter().any(|context| context == GITVOTE_CHECK_NAME)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
@ -375,23 +425,35 @@ impl GH for GHApi {
|
|||
issue_number: i64,
|
||||
body: &str,
|
||||
) -> Result<i64> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
let comment = client
|
||||
.issues(owner, repo)
|
||||
.create_comment(issue_number as u64, body)
|
||||
.await?;
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
let comment = client.issues(owner, repo).create_comment(issue_number as u64, body).await?;
|
||||
Ok(comment.id.0 as i64)
|
||||
}
|
||||
|
||||
/// [GH::user_is_collaborator]
|
||||
async fn user_is_collaborator(
|
||||
/// [GH::remove_label]
|
||||
async fn remove_label(
|
||||
&self,
|
||||
inst_id: u64,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
user: &str,
|
||||
) -> Result<bool> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
issue_number: i64,
|
||||
label: &str,
|
||||
) -> Result<()> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
match client.issues(owner, repo).remove_label(issue_number as u64, label).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(octocrab::Error::GitHub { source, backtrace: _ })
|
||||
if source.message == "Label does not exist" =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// [GH::user_is_collaborator]
|
||||
async fn user_is_collaborator(&self, inst_id: u64, owner: &str, repo: &str, user: &str) -> Result<bool> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/collaborators/{user}",);
|
||||
let resp = client._get(url).await?;
|
||||
if resp.status() == StatusCode::NO_CONTENT {
|
||||
|
@ -412,9 +474,7 @@ pub(crate) enum Event {
|
|||
impl TryFrom<(Option<&HeaderValue>, &[u8])> for Event {
|
||||
type Error = EventError;
|
||||
|
||||
fn try_from(
|
||||
(event_name, event_body): (Option<&HeaderValue>, &[u8]),
|
||||
) -> Result<Self, Self::Error> {
|
||||
fn try_from((event_name, event_body): (Option<&HeaderValue>, &[u8])) -> Result<Self, Self::Error> {
|
||||
match event_name {
|
||||
Some(event_name) => match event_name.as_bytes() {
|
||||
b"issues" => {
|
||||
|
@ -595,3 +655,13 @@ pub(crate) fn split_full_name(full_name: &str) -> (&str, &str) {
|
|||
let mut parts = full_name.split('/');
|
||||
(parts.next().unwrap(), parts.next().unwrap())
|
||||
}
|
||||
|
||||
/// Check if the provided error is a "Not Found" error from GitHub.
|
||||
pub(crate) fn is_not_found_error(err: &Error) -> bool {
|
||||
if let Some(octocrab::Error::GitHub { source, backtrace: _ }) = err.downcast_ref::<octocrab::Error>() {
|
||||
if source.message == "Not Found" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
query AnnouncementRepoQuery($owner: String!, $repo: String!, $category: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
id
|
||||
discussionCategory(slug: $category) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
mutation CreateDiscussion($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
|
||||
createDiscussion(input: {repositoryId: $repositoryId, categoryId: $categoryId, title: $title, body: $body}) {
|
||||
discussion {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
211
src/handlers.rs
211
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 askama::Template;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{FromRef, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
response::IntoResponse,
|
||||
response::{Html, IntoResponse},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use config::Config;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::{error, instrument, trace};
|
||||
|
||||
use crate::{
|
||||
cfg_svc::Cfg,
|
||||
cmd::Command,
|
||||
db::DynDB,
|
||||
github::{
|
||||
split_full_name, CheckDetails, DynGH, Event, EventError, PullRequestEvent, PullRequestEventAction,
|
||||
},
|
||||
tmpl,
|
||||
};
|
||||
|
||||
/// Header representing the kind of the event received.
|
||||
const GITHUB_EVENT_HEADER: &str = "X-GitHub-Event";
|
||||
|
||||
|
@ -29,20 +40,17 @@ struct RouterState {
|
|||
gh: DynGH,
|
||||
cmds_tx: async_channel::Sender<Command>,
|
||||
webhook_secret: String,
|
||||
webhook_secret_fallback: Option<String>,
|
||||
}
|
||||
|
||||
/// Setup HTTP server router.
|
||||
pub(crate) fn setup_router(
|
||||
cfg: &Arc<Config>,
|
||||
cfg: &Cfg,
|
||||
db: DynDB,
|
||||
gh: DynGH,
|
||||
cmds_tx: async_channel::Sender<Command>,
|
||||
) -> Result<Router> {
|
||||
// Setup webhook secret
|
||||
let webhook_secret = cfg.get_string("github.webhookSecret")?;
|
||||
|
||||
// Setup router
|
||||
let router = Router::new()
|
||||
) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/api/events", post(event))
|
||||
.layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()))
|
||||
|
@ -50,16 +58,19 @@ pub(crate) fn setup_router(
|
|||
db,
|
||||
gh,
|
||||
cmds_tx,
|
||||
webhook_secret,
|
||||
});
|
||||
|
||||
Ok(router)
|
||||
webhook_secret: cfg.github.webhook_secret.clone(),
|
||||
webhook_secret_fallback: cfg.github.webhook_secret_fallback.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Handler that returns the index document.
|
||||
#[allow(clippy::unused_async)]
|
||||
async fn index() -> impl IntoResponse {
|
||||
tmpl::Index {}
|
||||
let template = tmpl::Index {};
|
||||
match template.render() {
|
||||
Ok(html) => Ok(Html(html)),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler that processes webhook events from GitHub.
|
||||
|
@ -70,39 +81,34 @@ async fn event(
|
|||
State(gh): State<DynGH>,
|
||||
State(cmds_tx): State<async_channel::Sender<Command>>,
|
||||
State(webhook_secret): State<String>,
|
||||
State(webhook_secret_fallback): State<Option<String>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> impl IntoResponse {
|
||||
// Verify payload signature
|
||||
let webhook_secret = webhook_secret.as_bytes();
|
||||
let webhook_secret_fallback = webhook_secret_fallback.as_ref().map(String::as_bytes);
|
||||
if verify_signature(
|
||||
headers.get(GITHUB_SIGNATURE_HEADER),
|
||||
webhook_secret.as_bytes(),
|
||||
webhook_secret,
|
||||
webhook_secret_fallback,
|
||||
&body[..],
|
||||
)
|
||||
.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
|
||||
let event = match Event::try_from((headers.get(GITHUB_EVENT_HEADER), &body[..])) {
|
||||
Ok(event) => event,
|
||||
Err(EventError::MissingHeader) => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
EventError::MissingHeader.to_string(),
|
||||
))
|
||||
return Err((StatusCode::BAD_REQUEST, EventError::MissingHeader.to_string()))
|
||||
}
|
||||
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");
|
||||
|
||||
|
@ -111,6 +117,7 @@ async fn event(
|
|||
Some(cmd) => {
|
||||
trace!(?cmd, "command detected");
|
||||
cmds_tx.send(cmd).await.unwrap();
|
||||
return Ok("command queued");
|
||||
}
|
||||
None => {
|
||||
if let Event::PullRequest(event) = event {
|
||||
|
@ -120,20 +127,37 @@ async fn event(
|
|||
})?;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok("no command detected")
|
||||
}
|
||||
|
||||
/// Verify that the signature provided is valid.
|
||||
fn verify_signature(signature: Option<&HeaderValue>, secret: &[u8], body: &[u8]) -> Result<()> {
|
||||
fn verify_signature(
|
||||
signature: Option<&HeaderValue>,
|
||||
secret: &[u8],
|
||||
secret_fallback: Option<&[u8]>,
|
||||
body: &[u8],
|
||||
) -> Result<()> {
|
||||
if let Some(signature) = signature
|
||||
.and_then(|s| s.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("sha256="))
|
||||
.and_then(|s| hex::decode(s).ok())
|
||||
{
|
||||
// Try primary secret
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret)?;
|
||||
mac.update(body);
|
||||
let result = mac.verify_slice(&signature[..]);
|
||||
if result.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
if secret_fallback.is_none() {
|
||||
return result.map_err(Error::new);
|
||||
}
|
||||
|
||||
// Try fallback secret (if available)
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret_fallback.expect("secret should be set"))?;
|
||||
mac.update(body);
|
||||
mac.verify_slice(&signature[..]).map_err(Error::new)
|
||||
} else {
|
||||
Err(format_err!("no valid signature found"))
|
||||
|
@ -142,8 +166,8 @@ fn verify_signature(signature: Option<&HeaderValue>, secret: &[u8], body: &[u8])
|
|||
|
||||
/// Set a success check status to the pull request referenced in the event
|
||||
/// provided when it's created or synchronized if no vote has been created on
|
||||
/// it yet. This makes it possible to use the GitVote check in combination with
|
||||
/// branch protection.
|
||||
/// it yet. This makes it possible to use the `GitVote` check in combination
|
||||
/// with branch protection.
|
||||
async fn set_check_status(db: DynDB, gh: DynGH, event: &PullRequestEvent) -> Result<()> {
|
||||
let (owner, repo) = split_full_name(&event.repository.full_name);
|
||||
let inst_id = event.installation.id as u64;
|
||||
|
@ -160,57 +184,51 @@ async fn set_check_status(db: DynDB, gh: DynGH, event: &PullRequestEvent) -> Res
|
|||
if !gh.is_check_required(inst_id, owner, repo, branch).await? {
|
||||
return Ok(());
|
||||
}
|
||||
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::Synchronize => {
|
||||
if !gh.is_check_required(inst_id, owner, repo, branch).await? {
|
||||
return Ok(());
|
||||
}
|
||||
if db
|
||||
.has_vote(&event.repository.full_name, event.pull_request.number)
|
||||
.await?
|
||||
{
|
||||
if db.has_vote(&event.repository.full_name, event.pull_request.number).await? {
|
||||
return Ok(());
|
||||
}
|
||||
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 => {}
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use crate::{cmd::CreateVoteInput, db::MockDB};
|
||||
use std::sync::Arc;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
use async_channel::Receiver;
|
||||
use axum::{
|
||||
body::Body,
|
||||
body::{to_bytes, Body},
|
||||
http::{header::CONTENT_TYPE, Request},
|
||||
};
|
||||
use figment::{providers::Serialized, Figment};
|
||||
use futures::future;
|
||||
use http_body::combinators::UnsyncBoxBody;
|
||||
use hyper::Response;
|
||||
use mockall::predicate::eq;
|
||||
use std::{fs, path::Path};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::github::MockGH;
|
||||
use crate::testutil::*;
|
||||
use crate::{cmd::CreateVoteInput, db::MockDB};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn index() {
|
||||
let (router, _) = setup_test_router();
|
||||
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.oneshot(Request::builder().method("GET").uri("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
@ -218,9 +236,7 @@ mod tests {
|
|||
assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8");
|
||||
assert_eq!(
|
||||
get_body(response).await,
|
||||
fs::read_to_string("templates/index.html")
|
||||
.unwrap()
|
||||
.trim_end_matches('\n')
|
||||
fs::read_to_string("templates/index.html").unwrap().trim_end_matches('\n')
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -229,13 +245,7 @@ mod tests {
|
|||
let (router, _) = setup_test_router();
|
||||
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/events")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.oneshot(Request::builder().method("POST").uri("/api/events").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
@ -253,11 +263,9 @@ mod tests {
|
|||
.method("POST")
|
||||
.uri("/api/events")
|
||||
.header(GITHUB_SIGNATURE_HEADER, "invalid-signature")
|
||||
.body(
|
||||
fs::read(Path::new(TESTDATA_PATH).join("event-cmd.json"))
|
||||
.unwrap()
|
||||
.into(),
|
||||
)
|
||||
.body(Body::from(
|
||||
fs::read(Path::new(TESTDATA_PATH).join("event-cmd.json")).unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
|
@ -278,17 +286,14 @@ mod tests {
|
|||
.method("POST")
|
||||
.uri("/api/events")
|
||||
.header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice()))
|
||||
.body(body.into())
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
get_body(response).await,
|
||||
EventError::MissingHeader.to_string()
|
||||
);
|
||||
assert_eq!(get_body(response).await, EventError::MissingHeader.to_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
@ -303,7 +308,7 @@ mod tests {
|
|||
.uri("/api/events")
|
||||
.header(GITHUB_EVENT_HEADER, "issue_comment")
|
||||
.header(GITHUB_SIGNATURE_HEADER, generate_signature(body))
|
||||
.body(body.to_vec().into())
|
||||
.body(Body::from(body.to_vec()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
|
@ -328,7 +333,7 @@ mod tests {
|
|||
.uri("/api/events")
|
||||
.header(GITHUB_EVENT_HEADER, "unsupported")
|
||||
.header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice()))
|
||||
.body(body.into())
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
|
@ -350,7 +355,7 @@ mod tests {
|
|||
.uri("/api/events")
|
||||
.header(GITHUB_EVENT_HEADER, "issue_comment")
|
||||
.header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice()))
|
||||
.body(body.into())
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
|
@ -372,7 +377,7 @@ mod tests {
|
|||
.uri("/api/events")
|
||||
.header(GITHUB_EVENT_HEADER, "issue_comment")
|
||||
.header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice()))
|
||||
.body(body.into())
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
|
@ -407,7 +412,7 @@ mod tests {
|
|||
.uri("/api/events")
|
||||
.header(GITHUB_EVENT_HEADER, "issue_comment")
|
||||
.header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice()))
|
||||
.body(body.into())
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
|
@ -432,18 +437,20 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn event_pr_without_cmd_set_check_status_failed() {
|
||||
let cfg = Arc::new(setup_test_config());
|
||||
let cfg = setup_test_config();
|
||||
let db = Arc::new(MockDB::new());
|
||||
let mut gh = MockGH::new();
|
||||
gh.expect_get_config_file()
|
||||
.with(eq(INST_ID), eq(ORG), eq(REPO))
|
||||
.times(1)
|
||||
.returning(|_, _, _| Box::pin(future::ready(None)));
|
||||
gh.expect_is_check_required()
|
||||
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH))
|
||||
.times(1)
|
||||
.returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR)))));
|
||||
let gh = Arc::new(gh);
|
||||
let (cmds_tx, cmds_rx) = async_channel::unbounded();
|
||||
let router = setup_router(&cfg, db, gh, cmds_tx).unwrap();
|
||||
let router = setup_router(&cfg, db, gh, cmds_tx);
|
||||
|
||||
let body = fs::read(Path::new(TESTDATA_PATH).join("event-pr-no-cmd.json")).unwrap();
|
||||
let response = router
|
||||
|
@ -453,7 +460,7 @@ mod tests {
|
|||
.uri("/api/events")
|
||||
.header(GITHUB_EVENT_HEADER, "pull_request")
|
||||
.header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice()))
|
||||
.body(body.into())
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
|
@ -480,6 +487,7 @@ mod tests {
|
|||
let mut gh = MockGH::new();
|
||||
gh.expect_is_check_required()
|
||||
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH))
|
||||
.times(1)
|
||||
.returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR)))));
|
||||
let gh = Arc::new(gh);
|
||||
let mut event = setup_test_pr_event();
|
||||
|
@ -494,6 +502,7 @@ mod tests {
|
|||
let mut gh = MockGH::new();
|
||||
gh.expect_is_check_required()
|
||||
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH))
|
||||
.times(1)
|
||||
.returning(|_, _, _, _| Box::pin(future::ready(Ok(false))));
|
||||
let gh = Arc::new(gh);
|
||||
let mut event = setup_test_pr_event();
|
||||
|
@ -508,6 +517,7 @@ mod tests {
|
|||
let mut gh = MockGH::new();
|
||||
gh.expect_is_check_required()
|
||||
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH))
|
||||
.times(1)
|
||||
.returning(|_, _, _, _| Box::pin(future::ready(Ok(true))));
|
||||
gh.expect_create_check_run()
|
||||
.with(
|
||||
|
@ -521,6 +531,7 @@ mod tests {
|
|||
summary: "No vote found".to_string(),
|
||||
}),
|
||||
)
|
||||
.times(1)
|
||||
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(()))));
|
||||
let gh = Arc::new(gh);
|
||||
let mut event = setup_test_pr_event();
|
||||
|
@ -535,6 +546,7 @@ mod tests {
|
|||
let mut gh = MockGH::new();
|
||||
gh.expect_is_check_required()
|
||||
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH))
|
||||
.times(1)
|
||||
.returning(|_, _, _, _| Box::pin(future::ready(Ok(false))));
|
||||
let gh = Arc::new(gh);
|
||||
let mut event = setup_test_pr_event();
|
||||
|
@ -548,11 +560,13 @@ mod tests {
|
|||
let mut db = MockDB::new();
|
||||
db.expect_has_vote()
|
||||
.with(eq(REPOFN), eq(ISSUE_NUM))
|
||||
.times(1)
|
||||
.returning(|_, _| Box::pin(future::ready(Ok(true))));
|
||||
let db = Arc::new(db);
|
||||
let mut gh = MockGH::new();
|
||||
gh.expect_is_check_required()
|
||||
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH))
|
||||
.times(1)
|
||||
.returning(|_, _, _, _| Box::pin(future::ready(Ok(true))));
|
||||
let gh = Arc::new(gh);
|
||||
let mut event = setup_test_pr_event();
|
||||
|
@ -566,11 +580,13 @@ mod tests {
|
|||
let mut db = MockDB::new();
|
||||
db.expect_has_vote()
|
||||
.with(eq(REPOFN), eq(ISSUE_NUM))
|
||||
.times(1)
|
||||
.returning(|_, _| Box::pin(future::ready(Ok(false))));
|
||||
let db = Arc::new(db);
|
||||
let mut gh = MockGH::new();
|
||||
gh.expect_is_check_required()
|
||||
.with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH))
|
||||
.times(1)
|
||||
.returning(|_, _, _, _| Box::pin(future::ready(Ok(true))));
|
||||
gh.expect_create_check_run()
|
||||
.with(
|
||||
|
@ -584,6 +600,7 @@ mod tests {
|
|||
summary: "No vote found".to_string(),
|
||||
}),
|
||||
)
|
||||
.times(1)
|
||||
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok(()))));
|
||||
let gh = Arc::new(gh);
|
||||
let mut event = setup_test_pr_event();
|
||||
|
@ -593,23 +610,27 @@ mod tests {
|
|||
}
|
||||
|
||||
fn setup_test_router() -> (Router, Receiver<Command>) {
|
||||
let cfg = Arc::new(setup_test_config());
|
||||
let cfg = setup_test_config();
|
||||
let db = Arc::new(MockDB::new());
|
||||
let gh = Arc::new(MockGH::new());
|
||||
let (cmds_tx, cmds_rx) = async_channel::unbounded();
|
||||
(setup_router(&cfg, db, gh, cmds_tx).unwrap(), cmds_rx)
|
||||
(setup_router(&cfg, db, gh, cmds_tx), cmds_rx)
|
||||
}
|
||||
|
||||
fn setup_test_config() -> Config {
|
||||
Config::builder()
|
||||
.set_default("github.webhookSecret", "secret")
|
||||
.unwrap()
|
||||
.build()
|
||||
fn setup_test_config() -> Cfg {
|
||||
Figment::new()
|
||||
.merge(Serialized::default("addr", "127.0.0.1:9000"))
|
||||
.merge(Serialized::default("db.host", "127.0.0.1"))
|
||||
.merge(Serialized::default("log.format", "pretty"))
|
||||
.merge(Serialized::default("github.appId", 1234))
|
||||
.merge(Serialized::default("github.appPrivateKey", "key"))
|
||||
.merge(Serialized::default("github.webhookSecret", "secret"))
|
||||
.extract()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn get_body(response: Response<UnsyncBoxBody<Bytes, axum::Error>>) -> Bytes {
|
||||
hyper::body::to_bytes(response.into_body()).await.unwrap()
|
||||
async fn get_body(response: Response<Body>) -> Bytes {
|
||||
to_bytes(response.into_body(), usize::MAX).await.unwrap()
|
||||
}
|
||||
|
||||
fn generate_signature(body: &[u8]) -> String {
|
||||
|
|
86
src/main.rs
86
src/main.rs
|
@ -1,27 +1,27 @@
|
|||
#![warn(clippy::all, clippy::pedantic)]
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::doc_markdown,
|
||||
clippy::wildcard_imports
|
||||
)]
|
||||
#![allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
|
||||
|
||||
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
|
||||
use crate::{db::PgDB, github::GHApi};
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use config::{Config, File};
|
||||
use deadpool_postgres::{Config as DbConfig, Runtime};
|
||||
use deadpool_postgres::Runtime;
|
||||
use octocrab::Octocrab;
|
||||
use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode};
|
||||
use postgres_openssl::MakeTlsConnector;
|
||||
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
use tokio::{signal, sync::broadcast};
|
||||
use tokio::{net::TcpListener, signal};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, info};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
mod cfg;
|
||||
use crate::{
|
||||
cfg_svc::{Cfg, LogFormat},
|
||||
db::PgDB,
|
||||
github::GHApi,
|
||||
};
|
||||
|
||||
mod cfg_repo;
|
||||
mod cfg_svc;
|
||||
mod cmd;
|
||||
mod db;
|
||||
mod github;
|
||||
|
@ -45,61 +45,49 @@ async fn main() -> Result<()> {
|
|||
let args = Args::parse();
|
||||
|
||||
// Setup configuration
|
||||
let cfg = Config::builder()
|
||||
.set_default("log.format", "pretty")?
|
||||
.set_default("addr", "127.0.0.1:9000")?
|
||||
.add_source(File::from(args.config))
|
||||
.build()
|
||||
.context("error setting up configuration")?;
|
||||
let cfg = Arc::new(cfg);
|
||||
let cfg = Cfg::new(&args.config).context("error setting up configuration")?;
|
||||
|
||||
// Setup logging
|
||||
if std::env::var_os("RUST_LOG").is_none() {
|
||||
std::env::set_var("RUST_LOG", "gitvote=debug");
|
||||
}
|
||||
let s = tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env());
|
||||
match cfg.get_string("log.format").as_deref() {
|
||||
Ok("json") => s.json().init(),
|
||||
_ => s.init(),
|
||||
};
|
||||
let ts = tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env());
|
||||
match cfg.log.format {
|
||||
LogFormat::Json => ts.json().init(),
|
||||
LogFormat::Pretty => ts.init(),
|
||||
}
|
||||
|
||||
// Setup database
|
||||
let mut builder = SslConnector::builder(SslMethod::tls())?;
|
||||
builder.set_verify(SslVerifyMode::NONE);
|
||||
let connector = MakeTlsConnector::new(builder.build());
|
||||
let db_cfg: DbConfig = cfg.get("db")?;
|
||||
let pool = db_cfg.create_pool(Some(Runtime::Tokio1), connector)?;
|
||||
let pool = cfg.db.create_pool(Some(Runtime::Tokio1), connector)?;
|
||||
let db = Arc::new(PgDB::new(pool));
|
||||
|
||||
// Setup GitHub client
|
||||
let app_id = cfg.get_int("github.appID")? as u64;
|
||||
let app_private_key = cfg.get_string("github.appPrivateKey")?;
|
||||
let app_id = cfg.github.app_id as u64;
|
||||
let app_private_key = cfg.github.app_private_key.clone();
|
||||
let app_private_key = jsonwebtoken::EncodingKey::from_rsa_pem(app_private_key.as_bytes())?;
|
||||
let app_client = Octocrab::builder()
|
||||
.app(app_id.into(), app_private_key)
|
||||
.build()?;
|
||||
let app_client = Octocrab::builder().app(app_id.into(), app_private_key).build()?;
|
||||
let gh = Arc::new(GHApi::new(app_client));
|
||||
|
||||
// Setup and launch votes processor
|
||||
let (cmds_tx, cmds_rx) = async_channel::unbounded();
|
||||
let (stop_tx, _): (broadcast::Sender<()>, _) = broadcast::channel(1);
|
||||
let votes_processor = processor::Processor::new(db.clone(), gh.clone());
|
||||
let votes_processor_done = votes_processor.start(cmds_tx.clone(), &cmds_rx, &stop_tx);
|
||||
let cancel_token = CancellationToken::new();
|
||||
let votes_processor = processor::Processor::new(db.clone(), gh.clone(), cmds_tx.clone(), cmds_rx);
|
||||
let votes_processor_tasks = votes_processor.run(&cancel_token);
|
||||
debug!("[votes processor] started");
|
||||
|
||||
// Setup and launch HTTP server
|
||||
let router = handlers::setup_router(&cfg, db, gh, cmds_tx)?;
|
||||
let addr: SocketAddr = cfg.get_string("addr")?.parse()?;
|
||||
let router = handlers::setup_router(&cfg, db, gh, cmds_tx);
|
||||
let addr: SocketAddr = cfg.addr.parse()?;
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
info!(%addr, "gitvote service started");
|
||||
axum::Server::bind(&addr)
|
||||
.serve(router.into_make_service())
|
||||
.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
|
||||
drop(stop_tx);
|
||||
votes_processor_done.await;
|
||||
cancel_token.cancel();
|
||||
votes_processor_tasks.await;
|
||||
debug!("[votes processor] stopped");
|
||||
info!("gitvote service stopped");
|
||||
|
||||
|
@ -111,9 +99,7 @@ async fn main() -> Result<()> {
|
|||
async fn shutdown_signal() {
|
||||
// Setup signal handlers
|
||||
let ctrl_c = async {
|
||||
signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install ctrl+c signal handler");
|
||||
signal::ctrl_c().await.expect("failed to install ctrl+c signal handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
|
@ -129,7 +115,7 @@ async fn shutdown_signal() {
|
|||
|
||||
// Wait for any of the signals
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
() = ctrl_c => {},
|
||||
() = terminate => {},
|
||||
}
|
||||
}
|
||||
|
|
1172
src/processor.rs
1172
src/processor.rs
File diff suppressed because it is too large
Load Diff
|
@ -1,14 +1,18 @@
|
|||
use crate::{
|
||||
cfg::CfgProfile,
|
||||
github::{DynGH, UserName},
|
||||
};
|
||||
use anyhow::{format_err, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
//! This module defines the logic to calculate vote results.
|
||||
|
||||
use std::{collections::HashMap, fmt};
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||
use tokio_postgres::{types::Json, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
cfg_repo::CfgProfile,
|
||||
github::{DynGH, UserName},
|
||||
};
|
||||
|
||||
/// Supported reactions.
|
||||
pub(crate) const REACTION_IN_FAVOR: &str = "+1";
|
||||
pub(crate) const REACTION_AGAINST: &str = "-1";
|
||||
|
@ -16,6 +20,7 @@ pub(crate) const REACTION_ABSTAIN: &str = "eyes";
|
|||
|
||||
/// Vote information.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub(crate) struct Vote {
|
||||
pub vote_id: Uuid,
|
||||
pub vote_comment_id: i64,
|
||||
|
@ -29,6 +34,7 @@ pub(crate) struct Vote {
|
|||
pub installation_id: i64,
|
||||
pub issue_id: i64,
|
||||
pub issue_number: i64,
|
||||
pub issue_title: Option<String>,
|
||||
pub is_pull_request: bool,
|
||||
pub repository_full_name: String,
|
||||
pub organization: Option<String>,
|
||||
|
@ -52,6 +58,7 @@ impl From<&Row> for Vote {
|
|||
installation_id: row.get("installation_id"),
|
||||
issue_id: row.get("issue_id"),
|
||||
issue_number: row.get("issue_number"),
|
||||
issue_title: row.get("issue_title"),
|
||||
is_pull_request: row.get("is_pull_request"),
|
||||
repository_full_name: row.get("repository_full_name"),
|
||||
organization: row.get("organization"),
|
||||
|
@ -75,7 +82,7 @@ impl VoteOption {
|
|||
REACTION_IN_FAVOR => Self::InFavor,
|
||||
REACTION_AGAINST => Self::Against,
|
||||
REACTION_ABSTAIN => Self::Abstain,
|
||||
_ => return Err(format_err!("reaction not supported")),
|
||||
_ => bail!("reaction not supported"),
|
||||
};
|
||||
Ok(vote_option)
|
||||
}
|
||||
|
@ -100,6 +107,7 @@ pub(crate) struct VoteResults {
|
|||
pub pass_threshold: f64,
|
||||
pub in_favor: i64,
|
||||
pub against: i64,
|
||||
pub against_percentage: f64,
|
||||
pub abstain: i64,
|
||||
pub not_voted: i64,
|
||||
pub binding: i64,
|
||||
|
@ -126,14 +134,11 @@ pub(crate) async fn calculate<'a>(
|
|||
) -> Result<VoteResults> {
|
||||
// Get vote comment reactions (aka votes)
|
||||
let inst_id = vote.installation_id as u64;
|
||||
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)
|
||||
let allowed_voters = gh
|
||||
.get_allowed_voters(inst_id, &vote.cfg, owner, repo, &vote.organization)
|
||||
.await?;
|
||||
let allowed_voters =
|
||||
gh.get_allowed_voters(inst_id, &vote.cfg, owner, repo, vote.organization.as_ref()).await?;
|
||||
|
||||
// Track users votes
|
||||
let mut votes: HashMap<UserName, UserVote> = HashMap::new();
|
||||
|
@ -185,14 +190,14 @@ pub(crate) async fn calculate<'a>(
|
|||
}
|
||||
}
|
||||
let mut in_favor_percentage = 0.0;
|
||||
let mut against_percentage = 0.0;
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
if !allowed_voters.is_empty() {
|
||||
in_favor_percentage = in_favor as f64 / allowed_voters.len() as f64 * 100.0;
|
||||
against_percentage = against as f64 / allowed_voters.len() as f64 * 100.0;
|
||||
}
|
||||
let pending_voters: Vec<UserName> = allowed_voters
|
||||
.iter()
|
||||
.filter(|user| !votes.contains_key(*user))
|
||||
.cloned()
|
||||
.collect();
|
||||
let pending_voters: Vec<UserName> =
|
||||
allowed_voters.iter().filter(|user| !votes.contains_key(*user)).cloned().collect();
|
||||
|
||||
Ok(VoteResults {
|
||||
passed: in_favor_percentage >= vote.cfg.pass_threshold,
|
||||
|
@ -200,6 +205,7 @@ pub(crate) async fn calculate<'a>(
|
|||
pass_threshold: vote.cfg.pass_threshold,
|
||||
in_favor,
|
||||
against,
|
||||
against_percentage,
|
||||
abstain,
|
||||
not_voted: pending_voters.len() as i64,
|
||||
binding,
|
||||
|
@ -212,12 +218,15 @@ pub(crate) async fn calculate<'a>(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::github::{MockGH, Reaction, User};
|
||||
use crate::testutil::*;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use futures::future::{self};
|
||||
use mockall::predicate::eq;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use crate::github::{MockGH, Reaction, User};
|
||||
use crate::testutil::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vote_option_from_reaction() {
|
||||
|
@ -263,6 +272,7 @@ mod tests {
|
|||
installation_id: INST_ID as i64,
|
||||
issue_id: ISSUE_ID,
|
||||
issue_number: ISSUE_NUM,
|
||||
issue_title: Some(TITLE.to_string()),
|
||||
is_pull_request: false,
|
||||
repository_full_name: REPOFN.to_string(),
|
||||
organization: Some(ORG.to_string()),
|
||||
|
@ -273,9 +283,17 @@ mod tests {
|
|||
let mut gh = MockGH::new();
|
||||
gh.expect_get_comment_reactions()
|
||||
.with(eq(INST_ID), eq(OWNER), eq(REPO), eq(COMMENT_ID))
|
||||
.times(1)
|
||||
.returning(|_, _, _, _| Box::pin(future::ready(Ok($reactions))));
|
||||
gh.expect_get_allowed_voters()
|
||||
.with(eq(INST_ID), eq($cfg), eq(OWNER), eq(REPO), eq(Some(ORG.to_string())))
|
||||
.withf(|inst_id, cfg, owner, repo, org| {
|
||||
*inst_id == INST_ID
|
||||
&& *cfg == $cfg
|
||||
&& owner == OWNER
|
||||
&& repo == REPO
|
||||
&& *org == Some(ORG.to_string()).as_ref()
|
||||
})
|
||||
.times(1)
|
||||
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok($allowed_voters))));
|
||||
|
||||
// Calculate vote results and check we get what we expect
|
||||
|
@ -322,6 +340,7 @@ mod tests {
|
|||
pass_threshold: 50.0,
|
||||
in_favor: 0,
|
||||
against: 1,
|
||||
against_percentage: 100.0,
|
||||
abstain: 0,
|
||||
not_voted: 0,
|
||||
binding: 1,
|
||||
|
@ -369,6 +388,7 @@ mod tests {
|
|||
pass_threshold: 50.0,
|
||||
in_favor: 0,
|
||||
against: 0,
|
||||
against_percentage: 0.0,
|
||||
abstain: 0,
|
||||
not_voted: 1,
|
||||
binding: 0,
|
||||
|
@ -420,6 +440,7 @@ mod tests {
|
|||
pass_threshold: 50.0,
|
||||
in_favor: 1,
|
||||
against: 1,
|
||||
against_percentage: 25.0,
|
||||
abstain: 1,
|
||||
not_voted: 1,
|
||||
binding: 3,
|
||||
|
@ -499,6 +520,7 @@ mod tests {
|
|||
pass_threshold: 75.0,
|
||||
in_favor: 3,
|
||||
against: 0,
|
||||
against_percentage: 0.0,
|
||||
abstain: 0,
|
||||
not_voted: 1,
|
||||
binding: 3,
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
//! This modules defines some test utilities.
|
||||
|
||||
use std::{collections::HashMap, fs, path::Path, time::Duration};
|
||||
|
||||
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
cfg::{AllowedVoters, CfgProfile},
|
||||
cfg_repo::{AllowedVoters, Announcements, CfgProfile, DiscussionsAnnouncements},
|
||||
github::*,
|
||||
results::{UserVote, Vote, VoteOption, VoteResults},
|
||||
};
|
||||
use std::{collections::HashMap, fs, path::Path, time::Duration};
|
||||
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) const BRANCH: &str = "main";
|
||||
pub(crate) const COMMENT_ID: i64 = 1234;
|
||||
|
@ -21,6 +25,7 @@ pub(crate) const REPO: &str = "repo";
|
|||
pub(crate) const REPOFN: &str = "org/repo";
|
||||
pub(crate) const TESTDATA_PATH: &str = "src/testdata";
|
||||
pub(crate) const TITLE: &str = "Test title";
|
||||
pub(crate) const DISCUSSIONS_CATEGORY: &str = "announcements";
|
||||
pub(crate) const USER: &str = "user";
|
||||
pub(crate) const USER1: &str = "user1";
|
||||
pub(crate) const USER2: &str = "user2";
|
||||
|
@ -131,11 +136,17 @@ pub(crate) fn setup_test_vote() -> Vote {
|
|||
users: Some(vec![USER1.to_string()]),
|
||||
..Default::default()
|
||||
}),
|
||||
announcements: Some(Announcements {
|
||||
discussions: Some(DiscussionsAnnouncements {
|
||||
category: DISCUSSIONS_CATEGORY.to_string(),
|
||||
}),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
installation_id: INST_ID as i64,
|
||||
issue_id: ISSUE_ID,
|
||||
issue_number: ISSUE_NUM,
|
||||
issue_title: Some(TITLE.to_string()),
|
||||
is_pull_request: false,
|
||||
repository_full_name: REPOFN.to_string(),
|
||||
organization: Some(ORG.to_string()),
|
||||
|
@ -150,6 +161,7 @@ pub(crate) fn setup_test_vote_results() -> VoteResults {
|
|||
pass_threshold: 50.0,
|
||||
in_favor: 1,
|
||||
against: 0,
|
||||
against_percentage: 0.0,
|
||||
abstain: 0,
|
||||
not_voted: 0,
|
||||
binding: 1,
|
||||
|
|
52
src/tmpl.rs
52
src/tmpl.rs
|
@ -1,10 +1,13 @@
|
|||
//! This module defines the templates used for the GitHub comments.
|
||||
|
||||
use askama::Template;
|
||||
|
||||
use crate::{
|
||||
cfg::CfgProfile,
|
||||
cfg_repo::CfgProfile,
|
||||
cmd::CreateVoteInput,
|
||||
github::{TeamSlug, UserName},
|
||||
results::VoteResults,
|
||||
};
|
||||
use askama::Template;
|
||||
|
||||
/// Template for the config not found comment.
|
||||
#[derive(Debug, Clone, Template)]
|
||||
|
@ -29,7 +32,7 @@ pub(crate) struct InvalidConfig<'a> {
|
|||
}
|
||||
|
||||
impl<'a> InvalidConfig<'a> {
|
||||
/// Create a new InvalidConfig template.
|
||||
/// Create a new `InvalidConfig` template.
|
||||
pub(crate) fn new(reason: &'a str) -> Self {
|
||||
Self { reason }
|
||||
}
|
||||
|
@ -44,7 +47,7 @@ pub(crate) struct NoVoteInProgress<'a> {
|
|||
}
|
||||
|
||||
impl<'a> NoVoteInProgress<'a> {
|
||||
/// Create a new NoVoteInProgress template.
|
||||
/// Create a new `NoVoteInProgress` template.
|
||||
pub(crate) fn new(user: &'a str, is_pull_request: bool) -> Self {
|
||||
Self {
|
||||
user,
|
||||
|
@ -62,7 +65,7 @@ pub(crate) struct VoteCancelled<'a> {
|
|||
}
|
||||
|
||||
impl<'a> VoteCancelled<'a> {
|
||||
/// Create a new VoteCancelled template.
|
||||
/// Create a new `VoteCancelled` template.
|
||||
pub(crate) fn new(user: &'a str, is_pull_request: bool) -> Self {
|
||||
Self {
|
||||
user,
|
||||
|
@ -84,12 +87,32 @@ pub(crate) struct VoteClosed<'a> {
|
|||
}
|
||||
|
||||
impl<'a> VoteClosed<'a> {
|
||||
/// Create a new VoteClosed template.
|
||||
/// Create a new `VoteClosed` template.
|
||||
pub(crate) fn new(results: &'a VoteResults) -> Self {
|
||||
Self { results }
|
||||
}
|
||||
}
|
||||
|
||||
/// Template for the vote closed announcement.
|
||||
#[derive(Debug, Clone, Template)]
|
||||
#[template(path = "vote-closed-announcement.md")]
|
||||
pub(crate) struct VoteClosedAnnouncement<'a> {
|
||||
issue_number: i64,
|
||||
issue_title: &'a str,
|
||||
results: &'a VoteResults,
|
||||
}
|
||||
|
||||
impl<'a> VoteClosedAnnouncement<'a> {
|
||||
/// Create a new `VoteClosedAnnouncement` template.
|
||||
pub(crate) fn new(issue_number: i64, issue_title: &'a str, results: &'a VoteResults) -> Self {
|
||||
Self {
|
||||
issue_number,
|
||||
issue_title,
|
||||
results,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Template for the vote created comment.
|
||||
#[derive(Debug, Clone, Template)]
|
||||
#[template(path = "vote-created.md")]
|
||||
|
@ -105,7 +128,7 @@ pub(crate) struct VoteCreated<'a> {
|
|||
}
|
||||
|
||||
impl<'a> VoteCreated<'a> {
|
||||
/// Create a new VoteCreated template.
|
||||
/// Create a new `VoteCreated` template.
|
||||
pub(crate) fn new(input: &'a CreateVoteInput, cfg: &'a CfgProfile) -> Self {
|
||||
// Prepare teams and users allowed to vote
|
||||
let (mut teams, mut users): (&[TeamSlug], &[UserName]) = (&[], &[]);
|
||||
|
@ -146,7 +169,7 @@ pub(crate) struct VoteInProgress<'a> {
|
|||
}
|
||||
|
||||
impl<'a> VoteInProgress<'a> {
|
||||
/// Create a new VoteInProgress template.
|
||||
/// Create a new `VoteInProgress` template.
|
||||
pub(crate) fn new(user: &'a str, is_pull_request: bool) -> Self {
|
||||
Self {
|
||||
user,
|
||||
|
@ -163,7 +186,7 @@ pub(crate) struct VoteRestricted<'a> {
|
|||
}
|
||||
|
||||
impl<'a> VoteRestricted<'a> {
|
||||
/// Create a new VoteRestricted template.
|
||||
/// Create a new `VoteRestricted` template.
|
||||
pub(crate) fn new(user: &'a str) -> Self {
|
||||
Self { user }
|
||||
}
|
||||
|
@ -177,7 +200,7 @@ pub(crate) struct VoteStatus<'a> {
|
|||
}
|
||||
|
||||
impl<'a> VoteStatus<'a> {
|
||||
/// Create a new VoteStatus template.
|
||||
/// Create a new `VoteStatus` template.
|
||||
pub(crate) fn new(results: &'a VoteResults) -> Self {
|
||||
Self { results }
|
||||
}
|
||||
|
@ -193,14 +216,13 @@ mod filters {
|
|||
#[allow(clippy::trivially_copy_pass_by_ref, clippy::unnecessary_wraps)]
|
||||
pub(crate) fn non_binding(
|
||||
votes: &HashMap<UserName, UserVote>,
|
||||
_: &dyn askama::Values,
|
||||
max: &i64,
|
||||
) -> askama::Result<Vec<(UserName, UserVote)>> {
|
||||
let mut non_binding_votes: Vec<(UserName, UserVote)> = votes
|
||||
.iter()
|
||||
.filter(|(_, v)| !v.binding)
|
||||
.map(|(n, v)| (n.clone(), v.clone()))
|
||||
.collect();
|
||||
let mut non_binding_votes: Vec<(UserName, UserVote)> =
|
||||
votes.iter().filter(|(_, v)| !v.binding).map(|(n, v)| (n.clone(), v.clone())).collect();
|
||||
non_binding_votes.sort_by(|a, b| a.1.timestamp.cmp(&b.1.timestamp));
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
Ok(non_binding_votes.into_iter().take(*max as usize).collect())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
{% extends "vote-closed.md" %}
|
||||
|
||||
{% block introduction %}
|
||||
The vote for "**{{ issue_title }}** (**#{{ issue_number }}**)" is now closed.
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}Vote results{% endblock %}
|
|
@ -1,9 +1,10 @@
|
|||
## Vote closed
|
||||
{% block introduction %}{% endblock %}
|
||||
|
||||
## {% block title %}Vote closed{% endblock %}
|
||||
|
||||
The vote {% if results.passed %}**passed**! 🎉{% else %}**did not pass**.{% endif %}
|
||||
|
||||
`{{ "{:.2}"|format(results.in_favor_percentage) }}%` of the users with binding vote were in favor (passing threshold: `{{ results.pass_threshold }}%`).
|
||||
|
||||
`{{ "{:.2}"|format(results.in_favor_percentage) }}%` of the users with binding vote were in favor and `{{ "{:.2}"|format(results.against_percentage) }}%` were against (passing threshold: `{{ results.pass_threshold }}%`).
|
||||
### Summary
|
||||
|
||||
| In favor | Against | Abstain | Not voted |
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
## Vote status
|
||||
|
||||
So far `{{ "{:.2}"|format(results.in_favor_percentage) }}%` of the users with binding vote are in favor (passing threshold: `{{ results.pass_threshold }}%`).
|
||||
So far `{{ "{:.2}"|format(results.in_favor_percentage) }}%` of the users with binding vote are in favor and `{{ "{:.2}"|format(results.against_percentage) }}%` are against (passing threshold: `{{ results.pass_threshold }}%`).
|
||||
|
||||
### Summary
|
||||
|
||||
|
|
Loading…
Reference in New Issue