mirror of https://github.com/cncf/gitvote.git
Compare commits
26 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 |
|
@ -9,7 +9,7 @@ permissions: read-all
|
|||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
@ -24,7 +24,7 @@ jobs:
|
|||
with:
|
||||
python-version: 3.7
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@v2.6.1
|
||||
uses: helm/chart-testing-action@v2.7.0
|
||||
- name: Run chart-testing (list-changed)
|
||||
id: list-changed
|
||||
run: |
|
||||
|
@ -35,7 +35,7 @@ jobs:
|
|||
- name: Run chart-testing (lint)
|
||||
run: ct lint --config .ct.yaml --target-branch ${{ github.event.repository.default_branch }}
|
||||
- name: Create kind cluster
|
||||
uses: helm/kind-action@v1.10.0
|
||||
uses: helm/kind-action@v1.12.0
|
||||
if: steps.list-changed.outputs.changed == 'true'
|
||||
- name: Run chart-testing (install)
|
||||
run: ct install --config .ct.yaml --target-branch ${{ github.event.repository.default_branch }}
|
||||
|
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: 1.76.0
|
||||
toolchain: 1.87.0
|
||||
components: clippy, rustfmt
|
||||
- name: Run clippy
|
||||
run: cargo clippy --all-targets --all-features -- --deny warnings
|
||||
|
@ -30,6 +30,6 @@ jobs:
|
|||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: 1.75.0
|
||||
toolchain: 1.87.0
|
||||
- name: Run backend tests
|
||||
run: cargo test
|
||||
|
|
|
@ -9,7 +9,7 @@ permissions: read-all
|
|||
|
||||
jobs:
|
||||
build-and-publish-images:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
|
18
ADOPTERS.md
18
ADOPTERS.md
|
@ -2,10 +2,26 @@
|
|||
|
||||
If your organization is using GitVote, please consider adding it to this list by submitting a pull request.
|
||||
|
||||
- [AWS Labs](https://github.com/awslabs)
|
||||
- [AsyncAPI](https://github.com/asyncapi/community/blob/master/voting.md)
|
||||
- [CloudNativePG](https://cloudnative-pg.io)
|
||||
- [CNCF](https://cncf.io)
|
||||
- [DevRel Foundation](https://dev-rel.org/)
|
||||
- [Fintech Open Source Foundation](www.finos.org)
|
||||
- [JSON Schema](https://json-schema.org)
|
||||
- [K8sGateway](https://k8sgateway.io)
|
||||
- [KServe](https://kserve.github.io/website/latest/)
|
||||
- [Kuadrant](https://kuadrant.io)
|
||||
- [Kuma](https://kuma.io)
|
||||
- [Kyverno](https://kyverno.io)
|
||||
- [Microcks](https://microcks.io/)
|
||||
- [NFDI4Health](https://github.com/nfdi4health)
|
||||
- [Open Component Model](https://ocm.software)
|
||||
- [OpenGemini](https://opengemini.org)
|
||||
- [OpenSSF](https://openssf.org)
|
||||
- [ORAS](https://oras.land)
|
||||
- [OSCAL Compass](https://github.com/oscal-compass)
|
||||
- [Ratify Project](https://ratify.dev)
|
||||
- [ResBaz Arizona](https://researchbazaar.arizona.edu)
|
||||
- [TODO Group](https://todogroup.org)
|
||||
- [Universal Blue](https://universal-blue.org)
|
||||
- [WasmEdge](https://wasmedge.org/)
|
||||
|
|
File diff suppressed because it is too large
Load Diff
68
Cargo.toml
68
Cargo.toml
|
@ -1,59 +1,59 @@
|
|||
[package]
|
||||
name = "gitvote"
|
||||
description = "GitVote server"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
license = "Apache-2.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.87"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.86"
|
||||
askama = "0.12.1"
|
||||
askama_axum = "0.4.0"
|
||||
anyhow = "1.0.98"
|
||||
askama = { version = "0.14.0", features = ["serde_json"] }
|
||||
async-channel = "2.3.1"
|
||||
async-trait = "0.1.80"
|
||||
axum = { version = "0.7.5", features = ["macros"] }
|
||||
clap = { version = "4.5.7", features = ["derive"] }
|
||||
config = "0.13.4"
|
||||
deadpool-postgres = { version = "0.14.0", features = ["serde"] }
|
||||
futures = "0.3.30"
|
||||
async-trait = "0.1.88"
|
||||
axum = { version = "0.8.4", features = ["macros"] }
|
||||
clap = { version = "4.5.40", features = ["derive"] }
|
||||
deadpool-postgres = { version = "0.14.1", features = ["serde"] }
|
||||
figment = { version = "0.10.19", features = ["yaml", "env"] }
|
||||
futures = "0.3.31"
|
||||
graphql_client = { version = "0.14.0", features = ["reqwest"] }
|
||||
hex = "0.4.3"
|
||||
hmac = "0.12.1"
|
||||
http = "0.2.12"
|
||||
humantime = "2.1.0"
|
||||
http = "1.3.1"
|
||||
humantime = "2.2.0"
|
||||
humantime-serde = "1.1.1"
|
||||
ignore = "0.4.22"
|
||||
jsonwebtoken = "9.3.0"
|
||||
lazy_static = "1.5.0"
|
||||
octocrab = "=0.33.3"
|
||||
openssl = { version = "0.10.64", features = ["vendored"] }
|
||||
postgres-openssl = "0.5.0"
|
||||
regex = "1.10.5"
|
||||
reqwest = "0.12.5"
|
||||
serde = { version = "1.0.203", features = ["derive"] }
|
||||
serde_json = "1.0.118"
|
||||
ignore = "0.4.23"
|
||||
jsonwebtoken = "9.3.1"
|
||||
octocrab = "0.44.1"
|
||||
openssl = { version = "0.10.73", features = ["vendored"] }
|
||||
postgres-openssl = "0.5.1"
|
||||
regex = "1.11.1"
|
||||
reqwest = "0.12.20"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
serde_yaml = "0.9.34"
|
||||
sha2 = "0.10.8"
|
||||
thiserror = "1.0.61"
|
||||
time = { version = "0.3.36", features = ["serde"] }
|
||||
tokio = { version = "1.38.0", features = [
|
||||
sha2 = "0.10.9"
|
||||
thiserror = "2.0.12"
|
||||
time = { version = "0.3.41", features = ["serde"] }
|
||||
tokio = { version = "1.45.1", features = [
|
||||
"macros",
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
"time",
|
||||
] }
|
||||
tokio-postgres = { version = "0.7.10", features = [
|
||||
tokio-postgres = { version = "0.7.13", features = [
|
||||
"with-uuid-1",
|
||||
"with-serde_json-1",
|
||||
"with-time-0_3",
|
||||
] }
|
||||
tower = "0.4.13"
|
||||
tower-http = { version = "0.5.2", features = ["trace"] }
|
||||
tokio-util = { version = "0.7.15", features = ["rt"] }
|
||||
tower = { version = "0.5.2", features = ["util"] }
|
||||
tower-http = { version = "0.6.6", features = ["trace"] }
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json"] }
|
||||
uuid = { version = "1.9.1", features = ["serde", "v4"] }
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] }
|
||||
uuid = { version = "1.17.0", features = ["serde", "v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
http-body = "1.0.0"
|
||||
hyper = "1.3.1"
|
||||
mockall = "0.12.1"
|
||||
http-body = "1.0.1"
|
||||
hyper = "1.6.0"
|
||||
mockall = "0.13.1"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# Build gitvote
|
||||
FROM rust:1-alpine3.20 as builder
|
||||
FROM rust:1-alpine3.22 as builder
|
||||
RUN apk --no-cache add musl-dev perl make
|
||||
WORKDIR /gitvote
|
||||
COPY src src
|
||||
|
@ -10,7 +10,7 @@ WORKDIR /gitvote/src
|
|||
RUN cargo build --release
|
||||
|
||||
# Final stage
|
||||
FROM alpine:3.20.1
|
||||
FROM alpine:3.22.0
|
||||
RUN apk --no-cache add ca-certificates && addgroup -S gitvote && adduser -S gitvote -G gitvote
|
||||
USER gitvote
|
||||
WORKDIR /home/gitvote
|
||||
|
|
|
@ -2,8 +2,8 @@ apiVersion: v2
|
|||
name: gitvote
|
||||
description: GitVote is a GitHub application that allows holding a vote on issues and pull requests
|
||||
type: application
|
||||
version: 1.3.0
|
||||
appVersion: 1.3.0
|
||||
version: 1.4.0
|
||||
appVersion: 1.4.0
|
||||
kubeVersion: ">= 1.19.0-0"
|
||||
home: https://gitvote.dev
|
||||
icon: https://raw.githubusercontent.com/cncf/gitvote/main/docs/logo/logo.png
|
||||
|
@ -25,19 +25,25 @@ annotations:
|
|||
artifacthub.io/category: skip-prediction
|
||||
artifacthub.io/changes: |
|
||||
- kind: added
|
||||
description: Support for GitHub discussions announcements
|
||||
description: Minimum wait support to close on passing
|
||||
- kind: added
|
||||
description: Webhook secret fallback for key rotation
|
||||
description: Display percentage of voters against the vote
|
||||
- kind: changed
|
||||
description: Bump Alpine to 3.20.1
|
||||
description: Migrate service config to figment
|
||||
- kind: changed
|
||||
description: Some refactoring in votes processor
|
||||
- kind: changed
|
||||
description: Bump Alpine to 3.21.0
|
||||
- kind: changed
|
||||
description: Bump Rust to 1.83
|
||||
- kind: changed
|
||||
description: Upgrade dependencies
|
||||
artifacthub.io/containsSecurityUpdates: "true"
|
||||
artifacthub.io/images: |
|
||||
- name: dbmigrator
|
||||
image: public.ecr.aws/g6m3a0y9/gitvote-dbmigrator:v1.3.0
|
||||
image: public.ecr.aws/g6m3a0y9/gitvote-dbmigrator:v1.4.0
|
||||
- name: gitvote
|
||||
image: public.ecr.aws/g6m3a0y9/gitvote:v1.3.0
|
||||
image: public.ecr.aws/g6m3a0y9/gitvote:v1.4.0
|
||||
artifacthub.io/links: |
|
||||
- name: source
|
||||
url: https://github.com/cncf/gitvote
|
||||
|
|
|
@ -8,14 +8,14 @@ stringData:
|
|||
addr: {{ .Values.gitvote.addr }}
|
||||
db:
|
||||
host: {{ default (printf "%s-postgresql.%s" .Release.Name .Release.Namespace) .Values.db.host }}
|
||||
port: {{ .Values.db.port }}
|
||||
port: {{ .Values.db.port | atoi }}
|
||||
dbname: {{ .Values.db.dbname }}
|
||||
user: {{ .Values.db.user }}
|
||||
password: {{ .Values.db.password }}
|
||||
log:
|
||||
format: {{ .Values.log.format }}
|
||||
github:
|
||||
appID: {{ .Values.gitvote.github.appID }}
|
||||
appId: {{ .Values.gitvote.github.appID }}
|
||||
appPrivateKey: {{ .Values.gitvote.github.appPrivateKey | quote }}
|
||||
webhookSecret: {{ .Values.gitvote.github.webhookSecret | quote }}
|
||||
{{- with .Values.gitvote.github.webhookSecretFallback }}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
# Build tern
|
||||
FROM golang:1.22.4-alpine3.20 AS tern
|
||||
FROM golang:1.24.4-alpine3.22 AS tern
|
||||
RUN apk --no-cache add git
|
||||
RUN go install github.com/jackc/tern@latest
|
||||
|
||||
# Build final image
|
||||
FROM alpine:3.20.1
|
||||
FROM alpine:3.22.0
|
||||
RUN addgroup -S gitvote && adduser -S gitvote -G gitvote
|
||||
USER gitvote
|
||||
WORKDIR /home/gitvote
|
||||
|
|
|
@ -136,6 +136,23 @@ profiles:
|
|||
#
|
||||
close_on_passing: false
|
||||
|
||||
# Close on passing minimum wait
|
||||
#
|
||||
# When the close on passing feature is activated, voting will conclude once
|
||||
# the pass threshold is met. However, there may be instances where it is
|
||||
# preferable to implement a minimum wait time, even if the vote would
|
||||
# already pass. This allows participants sufficient opportunity to engage
|
||||
# and reflect before the vote is automatically finalized.
|
||||
#
|
||||
# Units supported:
|
||||
#
|
||||
# - day / days
|
||||
# - week / weeks
|
||||
#
|
||||
# close_on_passing_min_wait: "1 week"
|
||||
#
|
||||
close_on_passing_min_wait: null
|
||||
|
||||
# Announcements
|
||||
#
|
||||
# GitVote can announce the results of a vote when it is closed on GitHub
|
||||
|
|
|
@ -1,10 +1,16 @@
|
|||
use crate::github::{DynGH, File, TeamSlug, UserName};
|
||||
//! This module defines some types and functionality to represent and process
|
||||
//! the `GitVote` configuration that GitHub repositories can use to enable and
|
||||
//! customize the service.
|
||||
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use ignore::gitignore::GitignoreBuilder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::github::{DynGH, File, TeamSlug, UserName};
|
||||
|
||||
/// Default configuration profile.
|
||||
const DEFAULT_PROFILE: &str = "default";
|
||||
|
||||
|
@ -15,7 +21,7 @@ const ERR_TEAMS_NOT_ALLOWED: &str = "teams in allowed voters can only be used in
|
|||
/// Type alias to represent a profile name.
|
||||
type ProfileName = String;
|
||||
|
||||
/// GitVote configuration.
|
||||
/// `GitVote` configuration.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub(crate) struct Cfg {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
@ -24,7 +30,7 @@ pub(crate) struct Cfg {
|
|||
}
|
||||
|
||||
impl Cfg {
|
||||
/// Get the GitVote configuration for the repository provided.
|
||||
/// Get the `GitVote` configuration for the repository provided.
|
||||
pub(crate) async fn get<'a>(
|
||||
gh: DynGH,
|
||||
inst_id: u64,
|
||||
|
@ -84,6 +90,8 @@ pub(crate) struct CfgProfile {
|
|||
pub periodic_status_check: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub close_on_passing: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub close_on_passing_min_wait: Option<String>,
|
||||
}
|
||||
|
||||
impl CfgProfile {
|
||||
|
@ -163,12 +171,15 @@ pub(crate) enum CfgError {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::github::MockGH;
|
||||
use crate::testutil::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::future;
|
||||
use mockall::predicate::eq;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::github::MockGH;
|
||||
use crate::testutil::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn automation_rule_matches() {
|
|
@ -0,0 +1,58 @@
|
|||
//! This module defines some types and functionality to represent and process
|
||||
//! the `GitVote` service configuration.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use deadpool_postgres::Config as Db;
|
||||
use figment::{
|
||||
providers::{Env, Format, Serialized, Yaml},
|
||||
Figment,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Server configuration.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub(crate) struct Cfg {
|
||||
pub addr: String,
|
||||
pub db: Db,
|
||||
pub github: GitHubApp,
|
||||
pub log: Log,
|
||||
}
|
||||
|
||||
impl Cfg {
|
||||
/// Create a new Cfg instance.
|
||||
pub(crate) fn new(config_file: &Path) -> Result<Self> {
|
||||
Figment::new()
|
||||
.merge(Serialized::default("addr", "127.0.0.1:9000"))
|
||||
.merge(Serialized::default("log.format", "pretty"))
|
||||
.merge(Yaml::file(config_file))
|
||||
.merge(Env::prefixed("GITVOTE_").split("_").lowercase(false))
|
||||
.extract()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
/// Logs configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
pub(crate) struct Log {
|
||||
pub format: LogFormat,
|
||||
}
|
||||
|
||||
/// Format to use in logs.
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(rename_all(deserialize = "lowercase"))]
|
||||
pub(crate) enum LogFormat {
|
||||
Json,
|
||||
Pretty,
|
||||
}
|
||||
|
||||
/// GitHub application configuration.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all(deserialize = "camelCase"))]
|
||||
pub struct GitHubApp {
|
||||
pub app_id: i64,
|
||||
pub app_private_key: String,
|
||||
pub webhook_secret: String,
|
||||
pub webhook_secret_fallback: Option<String>,
|
||||
}
|
43
src/cmd.rs
43
src/cmd.rs
|
@ -1,23 +1,28 @@
|
|||
use crate::{
|
||||
cfg::{Cfg, CfgError},
|
||||
github::*,
|
||||
};
|
||||
//! This module defines the commands supported and the logic to parse them from
|
||||
//! GitHub events.
|
||||
|
||||
use anyhow::Result;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::LazyLock;
|
||||
use tracing::error;
|
||||
|
||||
use crate::{
|
||||
cfg_repo::{Cfg, CfgError},
|
||||
github::{
|
||||
split_full_name, DynGH, Event, IssueCommentEventAction, IssueEventAction, PullRequestEventAction,
|
||||
},
|
||||
};
|
||||
|
||||
/// Available commands.
|
||||
const CMD_CREATE_VOTE: &str = "vote";
|
||||
const CMD_CANCEL_VOTE: &str = "cancel-vote";
|
||||
const CMD_CHECK_VOTE: &str = "check-vote";
|
||||
|
||||
lazy_static! {
|
||||
/// Regex used to detect commands in issues/prs comments.
|
||||
static ref CMD: Regex = Regex::new(r"(?m)^/(vote|cancel-vote|check-vote)-?([a-zA-Z0-9]*)\s*$")
|
||||
.expect("invalid CMD regexp");
|
||||
}
|
||||
/// Regex used to detect commands in issues/prs comments.
|
||||
static CMD: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?m)^/(vote|cancel-vote|check-vote)-?([a-zA-Z0-9]*)\s*$").expect("invalid CMD regexp")
|
||||
});
|
||||
|
||||
/// Represents a command to be executed, usually created from a GitHub event.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
@ -147,7 +152,7 @@ pub(crate) struct CreateVoteInput {
|
|||
}
|
||||
|
||||
impl CreateVoteInput {
|
||||
/// Create a new CreateVoteInput instance from the profile and event
|
||||
/// Create a new `CreateVoteInput` instance from the profile and event
|
||||
/// provided.
|
||||
pub(crate) fn new(profile_name: Option<&str>, event: &Event) -> Self {
|
||||
match event {
|
||||
|
@ -199,7 +204,7 @@ pub(crate) struct CancelVoteInput {
|
|||
}
|
||||
|
||||
impl CancelVoteInput {
|
||||
/// Create a new CancelVoteInput instance from the event provided.
|
||||
/// Create a new `CancelVoteInput` instance from the event provided.
|
||||
pub(crate) fn new(event: &Event) -> Self {
|
||||
match event {
|
||||
Event::Issue(event) => Self {
|
||||
|
@ -235,7 +240,7 @@ pub(crate) struct CheckVoteInput {
|
|||
}
|
||||
|
||||
impl CheckVoteInput {
|
||||
/// Create a new CheckVoteInput instance from the event provided.
|
||||
/// Create a new `CheckVoteInput` instance from the event provided.
|
||||
pub(crate) fn new(event: &Event) -> Self {
|
||||
match event {
|
||||
Event::Issue(event) => Self {
|
||||
|
@ -256,11 +261,17 @@ impl CheckVoteInput {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use std::{sync::Arc, vec};
|
||||
|
||||
use futures::future;
|
||||
use mockall::predicate::eq;
|
||||
use std::{sync::Arc, vec};
|
||||
|
||||
use crate::{
|
||||
github::{File, MockGH},
|
||||
testutil::*,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn manual_command_from_issue_event_unsupported_action() {
|
||||
|
|
32
src/db.rs
32
src/db.rs
|
@ -1,18 +1,22 @@
|
|||
use crate::{
|
||||
cfg::CfgProfile,
|
||||
cmd::{CheckVoteInput, CreateVoteInput},
|
||||
github::{self, split_full_name, DynGH},
|
||||
results::{self, Vote, VoteResults},
|
||||
};
|
||||
//! This module defines an abstraction layer over the database.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use deadpool_postgres::{Pool, Transaction};
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
use std::sync::Arc;
|
||||
use tokio_postgres::types::Json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
cfg_repo::CfgProfile,
|
||||
cmd::{CheckVoteInput, CreateVoteInput},
|
||||
github::{self, split_full_name, DynGH},
|
||||
results::{self, Vote, VoteResults},
|
||||
};
|
||||
|
||||
/// Type alias to represent a DB trait object.
|
||||
pub(crate) type DynDB = Arc<dyn DB + Send + Sync>;
|
||||
|
||||
|
@ -56,13 +60,13 @@ pub(crate) trait DB {
|
|||
async fn update_vote_last_check(&self, vote_id: Uuid) -> Result<()>;
|
||||
}
|
||||
|
||||
/// DB implementation backed by PostgreSQL.
|
||||
/// DB implementation backed by `PostgreSQL`.
|
||||
pub(crate) struct PgDB {
|
||||
pool: Pool,
|
||||
}
|
||||
|
||||
impl PgDB {
|
||||
/// Create a new PgDB instance.
|
||||
/// Create a new `PgDB` instance.
|
||||
pub(crate) fn new(pool: Pool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
@ -91,7 +95,7 @@ impl PgDB {
|
|||
async fn store_vote_results(
|
||||
tx: &Transaction<'_>,
|
||||
vote_id: Uuid,
|
||||
results: &Option<VoteResults>,
|
||||
results: Option<&VoteResults>,
|
||||
) -> Result<()> {
|
||||
tx.execute(
|
||||
"
|
||||
|
@ -154,7 +158,7 @@ impl DB for PgDB {
|
|||
};
|
||||
|
||||
// Store results in database
|
||||
PgDB::store_vote_results(&tx, vote.vote_id, &results).await?;
|
||||
PgDB::store_vote_results(&tx, vote.vote_id, results.as_ref()).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Some((vote, results)))
|
||||
|
@ -190,6 +194,12 @@ impl DB for PgDB {
|
|||
where closed = false
|
||||
and cfg ? 'close_on_passing'
|
||||
and (cfg->>'close_on_passing')::boolean = true
|
||||
and
|
||||
case
|
||||
when cfg ? 'close_on_passing_min_wait' and string_to_interval(cfg->>'close_on_passing_min_wait') is not null then
|
||||
current_timestamp > created_at + (cfg->>'close_on_passing_min_wait')::interval
|
||||
else true
|
||||
end
|
||||
",
|
||||
&[],
|
||||
)
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
use crate::cfg::CfgProfile;
|
||||
//! This module defines an abstraction layer over the GitHub API.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{bail, Context, Error, Result};
|
||||
use async_trait::async_trait;
|
||||
use axum::http::HeaderValue;
|
||||
|
@ -9,9 +12,10 @@ use mockall::automock;
|
|||
use octocrab::{models::InstallationId, Octocrab, Page};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::cfg_repo::CfgProfile;
|
||||
|
||||
/// GitHub API base url.
|
||||
const GITHUB_API_URL: &str = "https://api.github.com";
|
||||
|
||||
|
@ -56,6 +60,7 @@ pub struct CreateDiscussion;
|
|||
|
||||
/// Trait that defines some operations a GH implementation must support.
|
||||
#[async_trait]
|
||||
#[allow(clippy::ref_option_ref)]
|
||||
#[cfg_attr(test, automock)]
|
||||
pub(crate) trait GH {
|
||||
/// Add labels to the provided issue.
|
||||
|
@ -96,7 +101,7 @@ pub(crate) trait GH {
|
|||
cfg: &CfgProfile,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
org: &Option<String>,
|
||||
org: Option<&String>,
|
||||
) -> Result<Vec<UserName>>;
|
||||
|
||||
/// Get all repository collaborators.
|
||||
|
@ -169,7 +174,7 @@ pub(crate) struct GHApi {
|
|||
}
|
||||
|
||||
impl GHApi {
|
||||
/// Create a new GHApi instance.
|
||||
/// Create a new `GHApi` instance.
|
||||
pub(crate) fn new(app_client: Octocrab) -> Self {
|
||||
Self { app_client }
|
||||
}
|
||||
|
@ -186,7 +191,7 @@ impl GH for GHApi {
|
|||
issue_number: i64,
|
||||
labels: &[&str],
|
||||
) -> Result<()> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
let labels = labels.iter().map(ToString::to_string).collect::<Vec<String>>();
|
||||
client.issues(owner, repo).add_labels(issue_number as u64, &labels).await?;
|
||||
Ok(())
|
||||
|
@ -201,7 +206,7 @@ impl GH for GHApi {
|
|||
issue_number: i64,
|
||||
check_details: &CheckDetails,
|
||||
) -> Result<()> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
let pr = client.pulls(owner, repo).get(issue_number as u64).await?;
|
||||
let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/check-runs");
|
||||
let mut body = json!({
|
||||
|
@ -215,7 +220,7 @@ impl GH for GHApi {
|
|||
});
|
||||
if let Some(conclusion) = &check_details.conclusion {
|
||||
body["conclusion"] = json!(conclusion);
|
||||
};
|
||||
}
|
||||
let _: Value = client.post(url, Some(&body)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
@ -230,7 +235,7 @@ impl GH for GHApi {
|
|||
title: &str,
|
||||
body: &str,
|
||||
) -> Result<()> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
|
||||
// Fetch some repository details needed to create a discussion
|
||||
let response: graphql_client::Response<announcement_repo_query::ResponseData> = client
|
||||
|
@ -270,7 +275,7 @@ impl GH for GHApi {
|
|||
cfg: &CfgProfile,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
org: &Option<String>,
|
||||
org: Option<&String>,
|
||||
) -> Result<Vec<UserName>> {
|
||||
let mut allowed_voters: Vec<UserName> = vec![];
|
||||
|
||||
|
@ -321,7 +326,7 @@ impl GH for GHApi {
|
|||
|
||||
/// [GH::get_collaborators]
|
||||
async fn get_collaborators(&self, inst_id: u64, owner: &str, repo: &str) -> Result<Vec<UserName>> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/collaborators");
|
||||
let first_page: Page<User> = client.get(url, None::<&()>).await?;
|
||||
let collaborators = client.all_pages(first_page).await?.into_iter().map(|u| u.login).collect();
|
||||
|
@ -336,7 +341,7 @@ impl GH for GHApi {
|
|||
repo: &str,
|
||||
comment_id: i64,
|
||||
) -> Result<Vec<Reaction>> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/issues/comments/{comment_id}/reactions",);
|
||||
let first_page: Page<Reaction> = client.get(url, None::<&()>).await?;
|
||||
let reactions = client.all_pages(first_page).await?;
|
||||
|
@ -345,20 +350,19 @@ impl GH for GHApi {
|
|||
|
||||
/// [GH::get_config_file]
|
||||
async fn get_config_file(&self, inst_id: u64, owner: &str, repo: &str) -> Option<String> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
let Ok(client) = self.app_client.installation(InstallationId(inst_id)) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Try to get the config file from the repository. Otherwise try
|
||||
// getting the organization wide config file in the .github repo.
|
||||
let mut content: Option<String> = None;
|
||||
for repo in &[repo, ORG_CONFIG_REPO] {
|
||||
match client.repos(owner, *repo).get_content().path(CONFIG_FILE).send().await {
|
||||
Ok(resp) => {
|
||||
if resp.items.len() == 1 {
|
||||
content = resp.items[0].decoded_content();
|
||||
break;
|
||||
}
|
||||
if let Ok(resp) = client.repos(owner, *repo).get_content().path(CONFIG_FILE).send().await {
|
||||
if resp.items.len() == 1 {
|
||||
content = resp.items[0].decoded_content();
|
||||
break;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -367,7 +371,7 @@ impl GH for GHApi {
|
|||
|
||||
/// [GH::get_pr_files]
|
||||
async fn get_pr_files(&self, inst_id: u64, owner: &str, repo: &str, pr_number: i64) -> Result<Vec<File>> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/pulls/{pr_number}/files");
|
||||
let first_page: Page<File> = client.get(url, None::<&()>).await?;
|
||||
let files: Vec<File> = client.all_pages(first_page).await?;
|
||||
|
@ -382,7 +386,7 @@ impl GH for GHApi {
|
|||
team: &str,
|
||||
exclude_maintainers: bool,
|
||||
) -> Result<Vec<UserName>> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
let url = format!("{GITHUB_API_URL}/orgs/{org}/teams/{team}/members");
|
||||
let first_page: Page<User> = client
|
||||
.get(
|
||||
|
@ -399,7 +403,7 @@ impl GH for GHApi {
|
|||
|
||||
/// [GH::is_check_required]
|
||||
async fn is_check_required(&self, inst_id: u64, owner: &str, repo: &str, branch: &str) -> Result<bool> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/branches/{branch}");
|
||||
let branch: Branch = client.get(url, None::<&()>).await?;
|
||||
let is_check_required = if let Some(required_checks) =
|
||||
|
@ -421,7 +425,7 @@ impl GH for GHApi {
|
|||
issue_number: i64,
|
||||
body: &str,
|
||||
) -> Result<i64> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
let comment = client.issues(owner, repo).create_comment(issue_number as u64, body).await?;
|
||||
Ok(comment.id.0 as i64)
|
||||
}
|
||||
|
@ -435,14 +439,21 @@ impl GH for GHApi {
|
|||
issue_number: i64,
|
||||
label: &str,
|
||||
) -> Result<()> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
client.issues(owner, repo).remove_label(issue_number as u64, label).await?;
|
||||
Ok(())
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
match client.issues(owner, repo).remove_label(issue_number as u64, label).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(octocrab::Error::GitHub { source, backtrace: _ })
|
||||
if source.message == "Label does not exist" =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// [GH::user_is_collaborator]
|
||||
async fn user_is_collaborator(&self, inst_id: u64, owner: &str, repo: &str, user: &str) -> Result<bool> {
|
||||
let client = self.app_client.installation(InstallationId(inst_id));
|
||||
let client = self.app_client.installation(InstallationId(inst_id))?;
|
||||
let url = format!("{GITHUB_API_URL}/repos/{owner}/{repo}/collaborators/{user}",);
|
||||
let resp = client._get(url).await?;
|
||||
if resp.status() == StatusCode::NO_CONTENT {
|
||||
|
|
100
src/handlers.rs
100
src/handlers.rs
|
@ -1,21 +1,32 @@
|
|||
use crate::{cmd::Command, db::DynDB, github::*, tmpl};
|
||||
//! This module defines the handlers used to process HTTP requests to the
|
||||
//! supported endpoints.
|
||||
|
||||
use anyhow::{format_err, Error, Result};
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{FromRef, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
response::IntoResponse,
|
||||
response::{Html, IntoResponse},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use config::{Config, ConfigError};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::{error, instrument, trace};
|
||||
|
||||
use crate::{
|
||||
cfg_svc::Cfg,
|
||||
cmd::Command,
|
||||
db::DynDB,
|
||||
github::{
|
||||
split_full_name, CheckDetails, DynGH, Event, EventError, PullRequestEvent, PullRequestEventAction,
|
||||
},
|
||||
tmpl,
|
||||
};
|
||||
|
||||
/// Header representing the kind of the event received.
|
||||
const GITHUB_EVENT_HEADER: &str = "X-GitHub-Event";
|
||||
|
||||
|
@ -34,21 +45,12 @@ struct RouterState {
|
|||
|
||||
/// Setup HTTP server router.
|
||||
pub(crate) fn setup_router(
|
||||
cfg: &Arc<Config>,
|
||||
cfg: &Cfg,
|
||||
db: DynDB,
|
||||
gh: DynGH,
|
||||
cmds_tx: async_channel::Sender<Command>,
|
||||
) -> Result<Router> {
|
||||
// Setup webhook secret
|
||||
let webhook_secret = cfg.get_string("github.webhookSecret")?;
|
||||
let webhook_secret_fallback = match cfg.get_string("github.webhookSecretFallback") {
|
||||
Ok(secret) => Some(secret),
|
||||
Err(ConfigError::NotFound(_)) => None,
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
|
||||
// Setup router
|
||||
let router = Router::new()
|
||||
) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/api/events", post(event))
|
||||
.layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()))
|
||||
|
@ -56,17 +58,19 @@ pub(crate) fn setup_router(
|
|||
db,
|
||||
gh,
|
||||
cmds_tx,
|
||||
webhook_secret,
|
||||
webhook_secret_fallback,
|
||||
});
|
||||
|
||||
Ok(router)
|
||||
webhook_secret: cfg.github.webhook_secret.clone(),
|
||||
webhook_secret_fallback: cfg.github.webhook_secret_fallback.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Handler that returns the index document.
|
||||
#[allow(clippy::unused_async)]
|
||||
async fn index() -> impl IntoResponse {
|
||||
tmpl::Index {}
|
||||
let template = tmpl::Index {};
|
||||
match template.render() {
|
||||
Ok(html) => Ok(Html(html)),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler that processes webhook events from GitHub.
|
||||
|
@ -93,7 +97,7 @@ async fn event(
|
|||
.is_err()
|
||||
{
|
||||
return Err((StatusCode::BAD_REQUEST, "no valid signature found".to_string()));
|
||||
};
|
||||
}
|
||||
|
||||
// Parse event
|
||||
let event = match Event::try_from((headers.get(GITHUB_EVENT_HEADER), &body[..])) {
|
||||
|
@ -104,7 +108,7 @@ async fn event(
|
|||
Err(EventError::InvalidBody(err)) => {
|
||||
return Err((StatusCode::BAD_REQUEST, EventError::InvalidBody(err).to_string()))
|
||||
}
|
||||
Err(EventError::UnsupportedEvent) => return Ok(()),
|
||||
Err(EventError::UnsupportedEvent) => return Ok("unsupported event"),
|
||||
};
|
||||
trace!(?event, "event received");
|
||||
|
||||
|
@ -113,6 +117,7 @@ async fn event(
|
|||
Some(cmd) => {
|
||||
trace!(?cmd, "command detected");
|
||||
cmds_tx.send(cmd).await.unwrap();
|
||||
return Ok("command queued");
|
||||
}
|
||||
None => {
|
||||
if let Event::PullRequest(event) = event {
|
||||
|
@ -122,9 +127,9 @@ async fn event(
|
|||
})?;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok("no command detected")
|
||||
}
|
||||
|
||||
/// Verify that the signature provided is valid.
|
||||
|
@ -161,8 +166,8 @@ fn verify_signature(
|
|||
|
||||
/// Set a success check status to the pull request referenced in the event
|
||||
/// provided when it's created or synchronized if no vote has been created on
|
||||
/// it yet. This makes it possible to use the GitVote check in combination with
|
||||
/// branch protection.
|
||||
/// it yet. This makes it possible to use the `GitVote` check in combination
|
||||
/// with branch protection.
|
||||
async fn set_check_status(db: DynDB, gh: DynGH, event: &PullRequestEvent) -> Result<()> {
|
||||
let (owner, repo) = split_full_name(&event.repository.full_name);
|
||||
let inst_id = event.installation.id as u64;
|
||||
|
@ -191,28 +196,33 @@ async fn set_check_status(db: DynDB, gh: DynGH, event: &PullRequestEvent) -> Res
|
|||
gh.create_check_run(inst_id, owner, repo, pr, &check_details).await?;
|
||||
}
|
||||
PullRequestEventAction::Other => {}
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use crate::{cmd::CreateVoteInput, db::MockDB};
|
||||
use std::sync::Arc;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
use async_channel::Receiver;
|
||||
use axum::body::to_bytes;
|
||||
use axum::{
|
||||
body::Body,
|
||||
body::{to_bytes, Body},
|
||||
http::{header::CONTENT_TYPE, Request},
|
||||
};
|
||||
use figment::{providers::Serialized, Figment};
|
||||
use futures::future;
|
||||
use hyper::Response;
|
||||
use mockall::predicate::eq;
|
||||
use std::{fs, path::Path};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::github::MockGH;
|
||||
use crate::testutil::*;
|
||||
use crate::{cmd::CreateVoteInput, db::MockDB};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn index() {
|
||||
let (router, _) = setup_test_router();
|
||||
|
@ -427,7 +437,7 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn event_pr_without_cmd_set_check_status_failed() {
|
||||
let cfg = Arc::new(setup_test_config());
|
||||
let cfg = setup_test_config();
|
||||
let db = Arc::new(MockDB::new());
|
||||
let mut gh = MockGH::new();
|
||||
gh.expect_get_config_file()
|
||||
|
@ -440,7 +450,7 @@ mod tests {
|
|||
.returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR)))));
|
||||
let gh = Arc::new(gh);
|
||||
let (cmds_tx, cmds_rx) = async_channel::unbounded();
|
||||
let router = setup_router(&cfg, db, gh, cmds_tx).unwrap();
|
||||
let router = setup_router(&cfg, db, gh, cmds_tx);
|
||||
|
||||
let body = fs::read(Path::new(TESTDATA_PATH).join("event-pr-no-cmd.json")).unwrap();
|
||||
let response = router
|
||||
|
@ -600,15 +610,23 @@ mod tests {
|
|||
}
|
||||
|
||||
fn setup_test_router() -> (Router, Receiver<Command>) {
|
||||
let cfg = Arc::new(setup_test_config());
|
||||
let cfg = setup_test_config();
|
||||
let db = Arc::new(MockDB::new());
|
||||
let gh = Arc::new(MockGH::new());
|
||||
let (cmds_tx, cmds_rx) = async_channel::unbounded();
|
||||
(setup_router(&cfg, db, gh, cmds_tx).unwrap(), cmds_rx)
|
||||
(setup_router(&cfg, db, gh, cmds_tx), cmds_rx)
|
||||
}
|
||||
|
||||
fn setup_test_config() -> Config {
|
||||
Config::builder().set_default("github.webhookSecret", "secret").unwrap().build().unwrap()
|
||||
fn setup_test_config() -> Cfg {
|
||||
Figment::new()
|
||||
.merge(Serialized::default("addr", "127.0.0.1:9000"))
|
||||
.merge(Serialized::default("db.host", "127.0.0.1"))
|
||||
.merge(Serialized::default("log.format", "pretty"))
|
||||
.merge(Serialized::default("github.appId", 1234))
|
||||
.merge(Serialized::default("github.appPrivateKey", "key"))
|
||||
.merge(Serialized::default("github.webhookSecret", "secret"))
|
||||
.extract()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn get_body(response: Response<Body>) -> Bytes {
|
||||
|
|
67
src/main.rs
67
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::{net::TcpListener, signal, sync::broadcast};
|
||||
use tokio::{net::TcpListener, signal};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, info};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
mod cfg;
|
||||
use crate::{
|
||||
cfg_svc::{Cfg, LogFormat},
|
||||
db::PgDB,
|
||||
github::GHApi,
|
||||
};
|
||||
|
||||
mod cfg_repo;
|
||||
mod cfg_svc;
|
||||
mod cmd;
|
||||
mod db;
|
||||
mod github;
|
||||
|
@ -45,56 +45,49 @@ async fn main() -> Result<()> {
|
|||
let args = Args::parse();
|
||||
|
||||
// Setup configuration
|
||||
let cfg = Config::builder()
|
||||
.set_default("log.format", "pretty")?
|
||||
.set_default("addr", "127.0.0.1:9000")?
|
||||
.add_source(File::from(args.config))
|
||||
.build()
|
||||
.context("error setting up configuration")?;
|
||||
let cfg = Arc::new(cfg);
|
||||
let cfg = Cfg::new(&args.config).context("error setting up configuration")?;
|
||||
|
||||
// Setup logging
|
||||
if std::env::var_os("RUST_LOG").is_none() {
|
||||
std::env::set_var("RUST_LOG", "gitvote=debug");
|
||||
}
|
||||
let s = tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env());
|
||||
match cfg.get_string("log.format").as_deref() {
|
||||
Ok("json") => s.json().init(),
|
||||
_ => s.init(),
|
||||
};
|
||||
let ts = tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env());
|
||||
match cfg.log.format {
|
||||
LogFormat::Json => ts.json().init(),
|
||||
LogFormat::Pretty => ts.init(),
|
||||
}
|
||||
|
||||
// Setup database
|
||||
let mut builder = SslConnector::builder(SslMethod::tls())?;
|
||||
builder.set_verify(SslVerifyMode::NONE);
|
||||
let connector = MakeTlsConnector::new(builder.build());
|
||||
let db_cfg: DbConfig = cfg.get("db")?;
|
||||
let pool = db_cfg.create_pool(Some(Runtime::Tokio1), connector)?;
|
||||
let pool = cfg.db.create_pool(Some(Runtime::Tokio1), connector)?;
|
||||
let db = Arc::new(PgDB::new(pool));
|
||||
|
||||
// Setup GitHub client
|
||||
let app_id = cfg.get_int("github.appID")? as u64;
|
||||
let app_private_key = cfg.get_string("github.appPrivateKey")?;
|
||||
let app_id = cfg.github.app_id as u64;
|
||||
let app_private_key = cfg.github.app_private_key.clone();
|
||||
let app_private_key = jsonwebtoken::EncodingKey::from_rsa_pem(app_private_key.as_bytes())?;
|
||||
let app_client = Octocrab::builder().app(app_id.into(), app_private_key).build()?;
|
||||
let gh = Arc::new(GHApi::new(app_client));
|
||||
|
||||
// Setup and launch votes processor
|
||||
let (cmds_tx, cmds_rx) = async_channel::unbounded();
|
||||
let (stop_tx, _): (broadcast::Sender<()>, _) = broadcast::channel(1);
|
||||
let votes_processor = processor::Processor::new(db.clone(), gh.clone());
|
||||
let votes_processor_done = votes_processor.start(cmds_tx.clone(), &cmds_rx, &stop_tx);
|
||||
let cancel_token = CancellationToken::new();
|
||||
let votes_processor = processor::Processor::new(db.clone(), gh.clone(), cmds_tx.clone(), cmds_rx);
|
||||
let votes_processor_tasks = votes_processor.run(&cancel_token);
|
||||
debug!("[votes processor] started");
|
||||
|
||||
// Setup and launch HTTP server
|
||||
let router = handlers::setup_router(&cfg, db, gh, cmds_tx)?;
|
||||
let addr: SocketAddr = cfg.get_string("addr")?.parse()?;
|
||||
let router = handlers::setup_router(&cfg, db, gh, cmds_tx);
|
||||
let addr: SocketAddr = cfg.addr.parse()?;
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
info!(%addr, "gitvote service started");
|
||||
axum::serve(listener, router).with_graceful_shutdown(shutdown_signal()).await.unwrap();
|
||||
|
||||
// Ask votes processor to stop and wait for it to finish
|
||||
drop(stop_tx);
|
||||
votes_processor_done.await;
|
||||
cancel_token.cancel();
|
||||
votes_processor_tasks.await;
|
||||
debug!("[votes processor] stopped");
|
||||
info!("gitvote service stopped");
|
||||
|
||||
|
|
818
src/processor.rs
818
src/processor.rs
File diff suppressed because it is too large
Load Diff
|
@ -1,14 +1,18 @@
|
|||
use crate::{
|
||||
cfg::CfgProfile,
|
||||
github::{DynGH, UserName},
|
||||
};
|
||||
//! This module defines the logic to calculate vote results.
|
||||
|
||||
use std::{collections::HashMap, fmt};
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::HashMap, fmt};
|
||||
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||
use tokio_postgres::{types::Json, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
cfg_repo::CfgProfile,
|
||||
github::{DynGH, UserName},
|
||||
};
|
||||
|
||||
/// Supported reactions.
|
||||
pub(crate) const REACTION_IN_FAVOR: &str = "+1";
|
||||
pub(crate) const REACTION_AGAINST: &str = "-1";
|
||||
|
@ -103,6 +107,7 @@ pub(crate) struct VoteResults {
|
|||
pub pass_threshold: f64,
|
||||
pub in_favor: i64,
|
||||
pub against: i64,
|
||||
pub against_percentage: f64,
|
||||
pub abstain: i64,
|
||||
pub not_voted: i64,
|
||||
pub binding: i64,
|
||||
|
@ -132,7 +137,8 @@ pub(crate) async fn calculate<'a>(
|
|||
let reactions = gh.get_comment_reactions(inst_id, owner, repo, vote.vote_comment_id).await?;
|
||||
|
||||
// Get list of allowed voters (users with binding votes)
|
||||
let allowed_voters = gh.get_allowed_voters(inst_id, &vote.cfg, owner, repo, &vote.organization).await?;
|
||||
let allowed_voters =
|
||||
gh.get_allowed_voters(inst_id, &vote.cfg, owner, repo, vote.organization.as_ref()).await?;
|
||||
|
||||
// Track users votes
|
||||
let mut votes: HashMap<UserName, UserVote> = HashMap::new();
|
||||
|
@ -184,8 +190,11 @@ pub(crate) async fn calculate<'a>(
|
|||
}
|
||||
}
|
||||
let mut in_favor_percentage = 0.0;
|
||||
let mut against_percentage = 0.0;
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
if !allowed_voters.is_empty() {
|
||||
in_favor_percentage = in_favor as f64 / allowed_voters.len() as f64 * 100.0;
|
||||
against_percentage = against as f64 / allowed_voters.len() as f64 * 100.0;
|
||||
}
|
||||
let pending_voters: Vec<UserName> =
|
||||
allowed_voters.iter().filter(|user| !votes.contains_key(*user)).cloned().collect();
|
||||
|
@ -196,6 +205,7 @@ pub(crate) async fn calculate<'a>(
|
|||
pass_threshold: vote.cfg.pass_threshold,
|
||||
in_favor,
|
||||
against,
|
||||
against_percentage,
|
||||
abstain,
|
||||
not_voted: pending_voters.len() as i64,
|
||||
binding,
|
||||
|
@ -208,12 +218,15 @@ pub(crate) async fn calculate<'a>(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::github::{MockGH, Reaction, User};
|
||||
use crate::testutil::*;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use futures::future::{self};
|
||||
use mockall::predicate::eq;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use crate::github::{MockGH, Reaction, User};
|
||||
use crate::testutil::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vote_option_from_reaction() {
|
||||
|
@ -273,7 +286,13 @@ mod tests {
|
|||
.times(1)
|
||||
.returning(|_, _, _, _| Box::pin(future::ready(Ok($reactions))));
|
||||
gh.expect_get_allowed_voters()
|
||||
.with(eq(INST_ID), eq($cfg), eq(OWNER), eq(REPO), eq(Some(ORG.to_string())))
|
||||
.withf(|inst_id, cfg, owner, repo, org| {
|
||||
*inst_id == INST_ID
|
||||
&& *cfg == $cfg
|
||||
&& owner == OWNER
|
||||
&& repo == REPO
|
||||
&& *org == Some(ORG.to_string()).as_ref()
|
||||
})
|
||||
.times(1)
|
||||
.returning(|_, _, _, _, _| Box::pin(future::ready(Ok($allowed_voters))));
|
||||
|
||||
|
@ -321,6 +340,7 @@ mod tests {
|
|||
pass_threshold: 50.0,
|
||||
in_favor: 0,
|
||||
against: 1,
|
||||
against_percentage: 100.0,
|
||||
abstain: 0,
|
||||
not_voted: 0,
|
||||
binding: 1,
|
||||
|
@ -368,6 +388,7 @@ mod tests {
|
|||
pass_threshold: 50.0,
|
||||
in_favor: 0,
|
||||
against: 0,
|
||||
against_percentage: 0.0,
|
||||
abstain: 0,
|
||||
not_voted: 1,
|
||||
binding: 0,
|
||||
|
@ -419,6 +440,7 @@ mod tests {
|
|||
pass_threshold: 50.0,
|
||||
in_favor: 1,
|
||||
against: 1,
|
||||
against_percentage: 25.0,
|
||||
abstain: 1,
|
||||
not_voted: 1,
|
||||
binding: 3,
|
||||
|
@ -498,6 +520,7 @@ mod tests {
|
|||
pass_threshold: 75.0,
|
||||
in_favor: 3,
|
||||
against: 0,
|
||||
against_percentage: 0.0,
|
||||
abstain: 0,
|
||||
not_voted: 1,
|
||||
binding: 3,
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
//! This modules defines some test utilities.
|
||||
|
||||
use std::{collections::HashMap, fs, path::Path, time::Duration};
|
||||
|
||||
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
cfg::{AllowedVoters, Announcements, CfgProfile, DiscussionsAnnouncements},
|
||||
cfg_repo::{AllowedVoters, Announcements, CfgProfile, DiscussionsAnnouncements},
|
||||
github::*,
|
||||
results::{UserVote, Vote, VoteOption, VoteResults},
|
||||
};
|
||||
use std::{collections::HashMap, fs, path::Path, time::Duration};
|
||||
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) const BRANCH: &str = "main";
|
||||
pub(crate) const COMMENT_ID: i64 = 1234;
|
||||
|
@ -157,6 +161,7 @@ pub(crate) fn setup_test_vote_results() -> VoteResults {
|
|||
pass_threshold: 50.0,
|
||||
in_favor: 1,
|
||||
against: 0,
|
||||
against_percentage: 0.0,
|
||||
abstain: 0,
|
||||
not_voted: 0,
|
||||
binding: 1,
|
||||
|
|
27
src/tmpl.rs
27
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,7 +87,7 @@ pub(crate) struct VoteClosed<'a> {
|
|||
}
|
||||
|
||||
impl<'a> VoteClosed<'a> {
|
||||
/// Create a new VoteClosed template.
|
||||
/// Create a new `VoteClosed` template.
|
||||
pub(crate) fn new(results: &'a VoteResults) -> Self {
|
||||
Self { results }
|
||||
}
|
||||
|
@ -100,7 +103,7 @@ pub(crate) struct VoteClosedAnnouncement<'a> {
|
|||
}
|
||||
|
||||
impl<'a> VoteClosedAnnouncement<'a> {
|
||||
/// Create a new VoteClosedAnnouncement template.
|
||||
/// Create a new `VoteClosedAnnouncement` template.
|
||||
pub(crate) fn new(issue_number: i64, issue_title: &'a str, results: &'a VoteResults) -> Self {
|
||||
Self {
|
||||
issue_number,
|
||||
|
@ -125,7 +128,7 @@ pub(crate) struct VoteCreated<'a> {
|
|||
}
|
||||
|
||||
impl<'a> VoteCreated<'a> {
|
||||
/// Create a new VoteCreated template.
|
||||
/// Create a new `VoteCreated` template.
|
||||
pub(crate) fn new(input: &'a CreateVoteInput, cfg: &'a CfgProfile) -> Self {
|
||||
// Prepare teams and users allowed to vote
|
||||
let (mut teams, mut users): (&[TeamSlug], &[UserName]) = (&[], &[]);
|
||||
|
@ -166,7 +169,7 @@ pub(crate) struct VoteInProgress<'a> {
|
|||
}
|
||||
|
||||
impl<'a> VoteInProgress<'a> {
|
||||
/// Create a new VoteInProgress template.
|
||||
/// Create a new `VoteInProgress` template.
|
||||
pub(crate) fn new(user: &'a str, is_pull_request: bool) -> Self {
|
||||
Self {
|
||||
user,
|
||||
|
@ -183,7 +186,7 @@ pub(crate) struct VoteRestricted<'a> {
|
|||
}
|
||||
|
||||
impl<'a> VoteRestricted<'a> {
|
||||
/// Create a new VoteRestricted template.
|
||||
/// Create a new `VoteRestricted` template.
|
||||
pub(crate) fn new(user: &'a str) -> Self {
|
||||
Self { user }
|
||||
}
|
||||
|
@ -197,7 +200,7 @@ pub(crate) struct VoteStatus<'a> {
|
|||
}
|
||||
|
||||
impl<'a> VoteStatus<'a> {
|
||||
/// Create a new VoteStatus template.
|
||||
/// Create a new `VoteStatus` template.
|
||||
pub(crate) fn new(results: &'a VoteResults) -> Self {
|
||||
Self { results }
|
||||
}
|
||||
|
@ -213,11 +216,13 @@ mod filters {
|
|||
#[allow(clippy::trivially_copy_pass_by_ref, clippy::unnecessary_wraps)]
|
||||
pub(crate) fn non_binding(
|
||||
votes: &HashMap<UserName, UserVote>,
|
||||
_: &dyn askama::Values,
|
||||
max: &i64,
|
||||
) -> askama::Result<Vec<(UserName, UserVote)>> {
|
||||
let mut non_binding_votes: Vec<(UserName, UserVote)> =
|
||||
votes.iter().filter(|(_, v)| !v.binding).map(|(n, v)| (n.clone(), v.clone())).collect();
|
||||
non_binding_votes.sort_by(|a, b| a.1.timestamp.cmp(&b.1.timestamp));
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
Ok(non_binding_votes.into_iter().take(*max as usize).collect())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,8 +4,7 @@
|
|||
|
||||
The vote {% if results.passed %}**passed**! 🎉{% else %}**did not pass**.{% endif %}
|
||||
|
||||
`{{ "{:.2}"|format(results.in_favor_percentage) }}%` of the users with binding vote were in favor (passing threshold: `{{ results.pass_threshold }}%`).
|
||||
|
||||
`{{ "{:.2}"|format(results.in_favor_percentage) }}%` of the users with binding vote were in favor and `{{ "{:.2}"|format(results.against_percentage) }}%` were against (passing threshold: `{{ results.pass_threshold }}%`).
|
||||
### Summary
|
||||
|
||||
| In favor | Against | Abstain | Not voted |
|
||||
|
|
|
@ -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