├── .gitattributes ├── .rustfmt.toml ├── OWNERS ├── templates ├── vote-checked-recently.md ├── config-profile-not-found.md ├── config-not-found.md ├── invalid-config.md ├── no-vote-in-progress.md ├── vote-cancelled.md ├── vote-in-progress.md ├── vote-closed-announcement.md ├── vote-restricted.md ├── vote-created.md ├── vote-status.md ├── vote-closed.md ├── audit-vote-details.html └── index.html ├── .gitignore ├── charts └── gitvote │ ├── .helmignore │ ├── charts │ └── postgresql-8.2.1.tgz │ ├── templates │ ├── dbmigrator_secret.yaml │ ├── gitvote_serviceaccount.yaml │ ├── gitvote_service.yaml │ ├── gitvote_ingress.yaml │ ├── gitvote_secret.yaml │ ├── dbmigrator_job.yaml │ ├── gitvote_deployment.yaml │ └── _helpers.tpl │ ├── Chart.yaml │ ├── ci │ └── default-values.yaml │ ├── README.md │ ├── values.yaml │ └── LICENSE ├── docs ├── logo │ ├── logo.png │ └── logo.svg ├── screenshots │ ├── create-vote.png │ ├── vote-closed.png │ ├── vote-status.png │ ├── check-passed.png │ ├── vote-cancelled.png │ └── vote-created.png └── config │ └── .gitvote.yml ├── src ├── testdata │ ├── templates │ │ ├── vote-checked-recently.golden │ │ ├── no-vote-in-progress-issue.golden │ │ ├── no-vote-in-progress-pr.golden │ │ ├── config-profile-not-found.golden │ │ ├── config-not-found.golden │ │ ├── vote-cancelled-issue.golden │ │ ├── vote-cancelled-pr.golden │ │ ├── invalid-config.golden │ │ ├── vote-in-progress-issue.golden │ │ ├── vote-in-progress-pr.golden │ │ ├── vote-restricted.golden │ │ ├── vote-created-all-collaborators.golden │ │ ├── vote-closed-announcement.golden │ │ ├── vote-closed-failed.golden │ │ ├── vote-status-in-progress.golden │ │ ├── vote-created-with-teams-and-users.golden │ │ └── vote-closed-passed.golden │ ├── config-invalid.yml │ ├── config.yml │ ├── event-cmd.json │ ├── event-no-cmd.json │ ├── event-cmd-profile.json │ └── event-pr-no-cmd.json ├── graphql │ ├── announcement_repo_query.graphql │ └── create_discussion.graphql ├── cfg_svc.rs ├── main.rs ├── testutil.rs ├── cfg_repo.rs ├── db.rs ├── cmd.rs └── results.rs ├── .ct.yaml ├── database └── migrations │ ├── functions │ ├── 001_load_functions.sql │ └── util │ │ └── string_to_interval.sql │ ├── schema │ ├── 003_issue_title.sql │ ├── 002_checked_at.sql │ ├── 004_update_results.sql │ └── 001_initial.sql │ ├── Dockerfile │ └── migrate.sh ├── Dockerfile ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── chart.yml │ ├── build-images.yml │ └── release.yml ├── ADOPTERS.md ├── Cargo.toml ├── GOVERNANCE.md ├── CONTRIBUTING.md ├── README.md └── LICENSE /.gitattributes: -------------------------------------------------------------------------------- 1 | templates/* -linguist-detectable 2 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 110 2 | chain_width = 90 3 | -------------------------------------------------------------------------------- /OWNERS: -------------------------------------------------------------------------------- 1 | maintainers: 2 | - tegioz 3 | - cynthia-sg 4 | - caniszczyk 5 | -------------------------------------------------------------------------------- /templates/vote-checked-recently.md: -------------------------------------------------------------------------------- 1 | Votes can only be checked once a day. 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .DS_Store 3 | .vscode 4 | Chart.lock 5 | chart/charts 6 | -------------------------------------------------------------------------------- /charts/gitvote/.helmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .git/ 3 | .gitignore 4 | *.swp 5 | .vscode/ 6 | -------------------------------------------------------------------------------- /docs/logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cncf/gitvote/HEAD/docs/logo/logo.png -------------------------------------------------------------------------------- /src/testdata/templates/vote-checked-recently.golden: -------------------------------------------------------------------------------- 1 | Votes can only be checked once a day. -------------------------------------------------------------------------------- /docs/screenshots/create-vote.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cncf/gitvote/HEAD/docs/screenshots/create-vote.png -------------------------------------------------------------------------------- /docs/screenshots/vote-closed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cncf/gitvote/HEAD/docs/screenshots/vote-closed.png -------------------------------------------------------------------------------- /docs/screenshots/vote-status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cncf/gitvote/HEAD/docs/screenshots/vote-status.png -------------------------------------------------------------------------------- /docs/screenshots/check-passed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cncf/gitvote/HEAD/docs/screenshots/check-passed.png -------------------------------------------------------------------------------- /docs/screenshots/vote-cancelled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cncf/gitvote/HEAD/docs/screenshots/vote-cancelled.png -------------------------------------------------------------------------------- /docs/screenshots/vote-created.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cncf/gitvote/HEAD/docs/screenshots/vote-created.png -------------------------------------------------------------------------------- /src/testdata/templates/no-vote-in-progress-issue.golden: -------------------------------------------------------------------------------- 1 | There is no vote in progress to cancel in this issue @testuser. -------------------------------------------------------------------------------- /templates/config-profile-not-found.md: -------------------------------------------------------------------------------- 1 | The requested configuration profile was not found in the configuration file. 2 | -------------------------------------------------------------------------------- /src/testdata/templates/no-vote-in-progress-pr.golden: -------------------------------------------------------------------------------- 1 | There is no vote in progress to cancel in this pull request @testuser. -------------------------------------------------------------------------------- /templates/config-not-found.md: -------------------------------------------------------------------------------- 1 | Configuration file not found. Please see . 2 | -------------------------------------------------------------------------------- /charts/gitvote/charts/postgresql-8.2.1.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cncf/gitvote/HEAD/charts/gitvote/charts/postgresql-8.2.1.tgz -------------------------------------------------------------------------------- /src/testdata/config-invalid.yml: -------------------------------------------------------------------------------- 1 | profiles: 2 | default: 3 | duration: 4 | invalid: true 5 | pass_threshold: 50 6 | -------------------------------------------------------------------------------- /src/testdata/templates/config-profile-not-found.golden: -------------------------------------------------------------------------------- 1 | The requested configuration profile was not found in the configuration file. -------------------------------------------------------------------------------- /src/testdata/templates/config-not-found.golden: -------------------------------------------------------------------------------- 1 | Configuration file not found. Please see . -------------------------------------------------------------------------------- /src/testdata/templates/vote-cancelled-issue.golden: -------------------------------------------------------------------------------- 1 | ## Vote cancelled 2 | 3 | @testuser has cancelled the vote in progress in this issue. -------------------------------------------------------------------------------- /templates/invalid-config.md: -------------------------------------------------------------------------------- 1 | Something went wrong while processing the configuration file: 2 | 3 | ```text 4 | {{ reason }} 5 | ``` 6 | -------------------------------------------------------------------------------- /src/testdata/templates/vote-cancelled-pr.golden: -------------------------------------------------------------------------------- 1 | ## Vote cancelled 2 | 3 | @testuser has cancelled the vote in progress in this pull request. -------------------------------------------------------------------------------- /.ct.yaml: -------------------------------------------------------------------------------- 1 | helm-extra-args: --timeout 180s 2 | chart-repos: 3 | - bitnami=https://charts.bitnami.com/bitnami 4 | validate-maintainers: false 5 | -------------------------------------------------------------------------------- /templates/no-vote-in-progress.md: -------------------------------------------------------------------------------- 1 | There is no vote in progress to cancel in this {% if is_pull_request %}pull request{% else %}issue{% endif %} @{{ user }}. 2 | -------------------------------------------------------------------------------- /database/migrations/functions/001_load_functions.sql: -------------------------------------------------------------------------------- 1 | {{ template "util/string_to_interval.sql" }} 2 | 3 | ---- create above / drop below ---- 4 | 5 | -- Nothing to do 6 | -------------------------------------------------------------------------------- /src/testdata/templates/invalid-config.golden: -------------------------------------------------------------------------------- 1 | Something went wrong while processing the configuration file: 2 | 3 | ```text 4 | Missing required field: pass_threshold 5 | ``` -------------------------------------------------------------------------------- /templates/vote-cancelled.md: -------------------------------------------------------------------------------- 1 | ## Vote cancelled 2 | 3 | @{{ user }} has cancelled the vote in progress in this {% if is_pull_request %}pull request{% else %}issue{% endif %}. 4 | -------------------------------------------------------------------------------- /src/testdata/templates/vote-in-progress-issue.golden: -------------------------------------------------------------------------------- 1 | There is already a vote in progress in this issue @testuser. 2 | 3 | Please wait until it is closed before creating a new one. -------------------------------------------------------------------------------- /src/testdata/templates/vote-in-progress-pr.golden: -------------------------------------------------------------------------------- 1 | There is already a vote in progress in this pull request @testuser. 2 | 3 | Please wait until it is closed before creating a new one. -------------------------------------------------------------------------------- /database/migrations/schema/003_issue_title.sql: -------------------------------------------------------------------------------- 1 | alter table vote add column issue_title text; 2 | 3 | ---- create above / drop below ---- 4 | 5 | alter table vote drop column issue_title; 6 | -------------------------------------------------------------------------------- /database/migrations/schema/002_checked_at.sql: -------------------------------------------------------------------------------- 1 | alter table vote add column checked_at timestamptz; 2 | 3 | ---- create above / drop below ---- 4 | 5 | alter table vote drop column checked_at; 6 | -------------------------------------------------------------------------------- /templates/vote-in-progress.md: -------------------------------------------------------------------------------- 1 | There is already a vote in progress in this {% if is_pull_request %}pull request{% else %}issue{% endif %} @{{ user }}. 2 | 3 | Please wait until it is closed before creating a new one. 4 | -------------------------------------------------------------------------------- /templates/vote-closed-announcement.md: -------------------------------------------------------------------------------- 1 | {%- extends "vote-closed.md" -%} 2 | {% block introduction -%} 3 | The vote for "**{{ issue_title }}** (**#{{ issue_number }}**)" is now closed. 4 | {{ "" +}} 5 | {% endblock +%} 6 | 7 | {%- block title %}Vote results{% endblock -%} 8 | -------------------------------------------------------------------------------- /src/graphql/announcement_repo_query.graphql: -------------------------------------------------------------------------------- 1 | query AnnouncementRepoQuery($owner: String!, $repo: String!, $category: String!) { 2 | repository(owner: $owner, name: $repo) { 3 | id 4 | discussionCategory(slug: $category) { 5 | id 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/graphql/create_discussion.graphql: -------------------------------------------------------------------------------- 1 | mutation CreateDiscussion($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { 2 | createDiscussion(input: {repositoryId: $repositoryId, categoryId: $categoryId, title: $title, body: $body}) { 3 | discussion { 4 | id 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /database/migrations/functions/util/string_to_interval.sql: -------------------------------------------------------------------------------- 1 | -- Helper function to validate an interval. 2 | -- Source: https://stackoverflow.com/a/36777132 3 | create or replace function string_to_interval(s text) 4 | returns interval as $$ 5 | begin 6 | return s::interval; 7 | exception when others then 8 | return null; 9 | end; 10 | $$ language plpgsql strict; 11 | -------------------------------------------------------------------------------- /templates/vote-restricted.md: -------------------------------------------------------------------------------- 1 | Only repository collaborators can create a vote @{{ user }}. 2 | 3 | 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. 4 | -------------------------------------------------------------------------------- /database/migrations/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build tern 2 | FROM golang:1.25.3-alpine3.22 AS tern 3 | RUN apk --no-cache add git 4 | RUN go install github.com/jackc/tern@latest 5 | 6 | # Build final image 7 | FROM alpine:3.22.2 8 | RUN addgroup -S gitvote && adduser -S gitvote -G gitvote 9 | USER gitvote 10 | WORKDIR /home/gitvote 11 | COPY --from=tern /go/bin/tern /usr/local/bin 12 | COPY database/migrations . 13 | -------------------------------------------------------------------------------- /src/testdata/templates/vote-restricted.golden: -------------------------------------------------------------------------------- 1 | Only repository collaborators can create a vote @testuser. 2 | 3 | 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. -------------------------------------------------------------------------------- /src/testdata/config.yml: -------------------------------------------------------------------------------- 1 | automation: 2 | enabled: true 3 | rules: 4 | - patterns: 5 | - "*.md" 6 | - "*.txt" 7 | profile: default 8 | 9 | profiles: 10 | default: 11 | duration: 5m 12 | pass_threshold: 50 13 | allowed_voters: {} 14 | 15 | profile1: 16 | duration: 10m 17 | pass_threshold: 75 18 | allowed_voters: 19 | teams: 20 | - team1 21 | users: 22 | - user1 23 | - user2 24 | -------------------------------------------------------------------------------- /src/testdata/event-cmd.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "created", 3 | "comment": { 4 | "id": 1234, 5 | "body": "/vote" 6 | }, 7 | "installation": { 8 | "id": 1234 9 | }, 10 | "issue": { 11 | "id": 1234, 12 | "number": 1, 13 | "title": "Test title" 14 | }, 15 | "repository": { 16 | "full_name": "org/repo" 17 | }, 18 | "organization": { 19 | "login": "org" 20 | }, 21 | "sender": { 22 | "login": "user" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/testdata/event-no-cmd.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "created", 3 | "comment": { 4 | "id": 1234, 5 | "body": "Hi!" 6 | }, 7 | "installation": { 8 | "id": 1234 9 | }, 10 | "issue": { 11 | "id": 1234, 12 | "number": 1, 13 | "title": "Test title" 14 | }, 15 | "repository": { 16 | "full_name": "org/repo" 17 | }, 18 | "organization": { 19 | "login": "org" 20 | }, 21 | "sender": { 22 | "login": "user" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /charts/gitvote/templates/dbmigrator_secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: {{ include "chart.resourceNamePrefix" . }}dbmigrator-config 5 | type: Opaque 6 | stringData: 7 | tern.conf: |- 8 | [database] 9 | host = {{ default (printf "%s-postgresql.%s" .Release.Name .Release.Namespace) .Values.db.host }} 10 | port = {{ .Values.db.port }} 11 | database = {{ .Values.db.dbname }} 12 | user = {{ .Values.db.user }} 13 | password = {{ .Values.db.password }} 14 | sslmode = prefer 15 | -------------------------------------------------------------------------------- /src/testdata/event-cmd-profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "created", 3 | "comment": { 4 | "id": 1234, 5 | "body": "/vote-profile1" 6 | }, 7 | "installation": { 8 | "id": 1234 9 | }, 10 | "issue": { 11 | "id": 1234, 12 | "number": 1, 13 | "title": "Test title" 14 | }, 15 | "repository": { 16 | "full_name": "org/repo" 17 | }, 18 | "organization": { 19 | "login": "org" 20 | }, 21 | "sender": { 22 | "login": "user" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/testdata/event-pr-no-cmd.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "installation": { 4 | "id": 1234 5 | }, 6 | "pull_request": { 7 | "id": 1234, 8 | "number": 1, 9 | "title": "Test title", 10 | "body": "Hi!", 11 | "base": { 12 | "ref": "main" 13 | } 14 | }, 15 | "repository": { 16 | "full_name": "org/repo" 17 | }, 18 | "organization": { 19 | "login": "org" 20 | }, 21 | "sender": { 22 | "login": "user" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build gitvote 2 | FROM rust:1-alpine3.22 as builder 3 | RUN apk --no-cache add musl-dev perl make 4 | WORKDIR /gitvote 5 | COPY src src 6 | COPY templates templates 7 | COPY Cargo.lock Cargo.lock 8 | COPY Cargo.toml Cargo.toml 9 | WORKDIR /gitvote/src 10 | RUN cargo build --release 11 | 12 | # Final stage 13 | FROM alpine:3.22.2 14 | RUN apk --no-cache add ca-certificates && addgroup -S gitvote && adduser -S gitvote -G gitvote 15 | USER gitvote 16 | WORKDIR /home/gitvote 17 | COPY --from=builder /gitvote/target/release/gitvote /usr/local/bin 18 | -------------------------------------------------------------------------------- /database/migrations/schema/004_update_results.sql: -------------------------------------------------------------------------------- 1 | update vote set 2 | results = jsonb_set(results, '{against_percentage}', to_jsonb((results->>'against')::real / (results->>'allowed_voters')::real * 100)) 3 | where results is not null 4 | and results != 'null'::jsonb 5 | and not results ? 'against_percentage'; 6 | 7 | update vote set 8 | results = jsonb_set(results, '{pending_voters}', '[]'::jsonb) 9 | where results is not null 10 | and results != 'null'::jsonb 11 | and not results ? 'pending_voters'; 12 | 13 | ---- create above / drop below ---- 14 | -------------------------------------------------------------------------------- /database/migrations/schema/001_initial.sql: -------------------------------------------------------------------------------- 1 | create extension if not exists pgcrypto; 2 | 3 | create table if not exists vote ( 4 | vote_id uuid primary key default gen_random_uuid(), 5 | vote_comment_id bigint not null, 6 | created_at timestamptz default current_timestamp not null, 7 | created_by text not null, 8 | ends_at timestamptz not null, 9 | closed boolean not null default false, 10 | closed_at timestamptz, 11 | cfg jsonb not null, 12 | installation_id bigint not null, 13 | issue_id bigint not null, 14 | issue_number bigint not null, 15 | is_pull_request boolean not null, 16 | repository_full_name text not null, 17 | organization text, 18 | results jsonb 19 | ); 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | backend: 9 | patterns: 10 | - "*" 11 | update-types: 12 | - "minor" 13 | - "patch" 14 | 15 | - package-ecosystem: "github-actions" 16 | directory: "/" 17 | schedule: 18 | interval: "monthly" 19 | groups: 20 | github-actions: 21 | patterns: 22 | - "*" 23 | 24 | - package-ecosystem: "docker" 25 | directory: "/" 26 | schedule: 27 | interval: "monthly" 28 | 29 | - package-ecosystem: "docker" 30 | directory: "/database/migrations" 31 | schedule: 32 | interval: "monthly" 33 | 34 | -------------------------------------------------------------------------------- /src/testdata/templates/vote-created-all-collaborators.golden: -------------------------------------------------------------------------------- 1 | ## Vote created 2 | 3 | **@user** has called for a vote on `Test title` (#1). 4 | 5 | All repository collaborators have binding votes. 6 | 7 | Non-binding votes are also appreciated as a sign of support! 8 | 9 | ## How to vote 10 | 11 | You can cast your vote by reacting to `this` comment. The following reactions are supported: 12 | 13 | | In favor | Against | Abstain | 14 | | :------: | :-----: | :-----: | 15 | | 👍 | 👎 | 👀 | 16 | 17 | *Please note that voting for multiple options is not allowed and those votes won't be counted.* 18 | 19 | The vote will be open for `1day`. It will pass if at least `75%` of the users with binding votes vote `In favor 👍`. Once it's closed, results will be published here as a new comment. -------------------------------------------------------------------------------- /database/migrations/migrate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | schemaVersionTable=version_schema 4 | functionsVersionTable=version_functions 5 | 6 | echo "- Applying schema migrations.." 7 | cd schema 8 | tern status --config $TERN_CONF --version-table $schemaVersionTable 9 | tern migrate --config $TERN_CONF --version-table $schemaVersionTable 10 | if [ $? -ne 0 ]; then exit 1; fi 11 | echo "Done" 12 | cd .. 13 | 14 | echo "- Loading functions.." 15 | cd functions 16 | tern status --config $TERN_CONF --version-table $functionsVersionTable | grep "version: 1 of 1" 17 | if [ $? -eq 0 ]; then 18 | tern migrate --config $TERN_CONF --version-table $functionsVersionTable --destination -+1 19 | else 20 | tern migrate --config $TERN_CONF --version-table $functionsVersionTable 21 | fi 22 | if [ $? -ne 0 ]; then exit 1; fi 23 | echo "Done" 24 | -------------------------------------------------------------------------------- /charts/gitvote/templates/gitvote_serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: {{ include "chart.resourceNamePrefix" . }}gitvote 5 | --- 6 | apiVersion: rbac.authorization.k8s.io/v1 7 | kind: Role 8 | metadata: 9 | name: {{ include "chart.resourceNamePrefix" . }}job-reader 10 | rules: 11 | - apiGroups: ["batch"] 12 | resources: ["jobs"] 13 | verbs: ["get", "list", "watch"] 14 | --- 15 | apiVersion: rbac.authorization.k8s.io/v1 16 | kind: RoleBinding 17 | metadata: 18 | name: {{ include "chart.resourceNamePrefix" . }}gitvote-job-reader 19 | subjects: 20 | - kind: ServiceAccount 21 | name: {{ include "chart.resourceNamePrefix" . }}gitvote 22 | roleRef: 23 | kind: Role 24 | name: {{ include "chart.resourceNamePrefix" . }}job-reader 25 | apiGroup: rbac.authorization.k8s.io 26 | -------------------------------------------------------------------------------- /src/testdata/templates/vote-closed-announcement.golden: -------------------------------------------------------------------------------- 1 | The vote for "**Implement RFC-42** (**#123**)" is now closed. 2 | 3 | ## Vote results 4 | 5 | The vote **passed**! 🎉 6 | 7 | `66.67%` of the users with binding vote were in favor and `0.00%` were against (passing threshold: `50%`). 8 | 9 | ### Summary 10 | 11 | | In favor | Against | Abstain | Not voted | 12 | | :--------------------: | :-------------------: | :------------------: | :---------------------: | 13 | | 2 | 0 | 1 | 0 | 14 | 15 | ### Binding votes (3) 16 | 17 | | User | Vote | Timestamp | 18 | | ---- | :---: | :-------: | 19 | | @alice | In favor | 2023-01-04 10:00:00.0 +00:00:00 | 20 | | @bob | In favor | 2023-01-04 11:00:00.0 +00:00:00 | 21 | | @charlie | Abstain | 2023-01-04 12:00:00.0 +00:00:00 | 22 | -------------------------------------------------------------------------------- /charts/gitvote/templates/gitvote_service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "chart.resourceNamePrefix" . }}gitvote 5 | labels: 6 | app.kubernetes.io/component: gitvote 7 | {{- include "chart.labels" . | nindent 4 }} 8 | {{- with .Values.gitvote.service.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | spec: 13 | {{- $serviceType := default "ClusterIP" .Values.gitvote.service.type }} 14 | {{- if eq $serviceType "LoadBalancer" }} 15 | allocateLoadBalancerNodePorts: {{ .Values.gitvote.service.allocateLoadBalancerNodePorts }} 16 | {{- end }} 17 | type: {{ $serviceType }} 18 | ports: 19 | {{- toYaml .Values.gitvote.service.ports | nindent 4 }} 20 | selector: 21 | app.kubernetes.io/component: gitvote 22 | {{- include "chart.selectorLabels" . | nindent 4 }} 23 | -------------------------------------------------------------------------------- /src/testdata/templates/vote-closed-failed.golden: -------------------------------------------------------------------------------- 1 | ## Vote closed 2 | 3 | The vote **did not pass**. 4 | 5 | `40.00%` of the users with binding vote were in favor and `60.00%` were against (passing threshold: `50%`). 6 | 7 | ### Summary 8 | 9 | | In favor | Against | Abstain | Not voted | 10 | | :--------------------: | :-------------------: | :------------------: | :---------------------: | 11 | | 2 | 3 | 0 | 0 | 12 | 13 | ### Binding votes (5) 14 | 15 | | User | Vote | Timestamp | 16 | | ---- | :---: | :-------: | 17 | | @alice | Against | 2023-01-02 10:00:00.0 +00:00:00 | 18 | | @bob | In favor | 2023-01-02 11:00:00.0 +00:00:00 | 19 | | @charlie | Against | 2023-01-02 12:00:00.0 +00:00:00 | 20 | | @dave | In favor | 2023-01-02 13:00:00.0 +00:00:00 | 21 | | @eve | Against | 2023-01-02 14:00:00.0 +00:00:00 | 22 | -------------------------------------------------------------------------------- /charts/gitvote/templates/gitvote_ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.gitvote.ingress.enabled -}} 2 | apiVersion: networking.k8s.io/v1 3 | kind: Ingress 4 | metadata: 5 | name: {{ include "chart.resourceNamePrefix" . }}gitvote 6 | labels: 7 | app.kubernetes.io/component: gitvote 8 | {{- include "chart.labels" . | nindent 4 }} 9 | {{- with .Values.gitvote.ingress.annotations }} 10 | annotations: 11 | {{- toYaml . | nindent 4 }} 12 | {{- end }} 13 | spec: 14 | defaultBackend: 15 | service: 16 | name: {{ include "chart.resourceNamePrefix" . }}gitvote 17 | port: 18 | number: {{ .Values.gitvote.ingress.backendServicePort }} 19 | {{- with .Values.gitvote.ingress.rules }} 20 | rules: 21 | {{- toYaml . | nindent 4 }} 22 | {{- end }} 23 | {{- with .Values.gitvote.ingress.tls }} 24 | tls: 25 | {{- toYaml . | nindent 4 }} 26 | {{- end }} 27 | {{- end }} 28 | -------------------------------------------------------------------------------- /src/testdata/templates/vote-status-in-progress.golden: -------------------------------------------------------------------------------- 1 | ## Vote status 2 | 3 | So far `33.33%` of the users with binding vote are in favor and `0.00%` are against (passing threshold: `50%`). 4 | 5 | ### Summary 6 | 7 | | In favor | Against | Abstain | Not voted | 8 | | :--------------------: | :-------------------: | :------------------: | :---------------------: | 9 | | 1 | 0 | 1 | 1 | 10 | 11 | ### Binding votes (2) 12 | 13 | | User | Vote | Timestamp | 14 | | ---- | :---: | :-------: | 15 | | alice | In favor | 2023-01-03 10:00:00.0 +00:00:00 | 16 | | bob | Abstain | 2023-01-03 11:00:00.0 +00:00:00 | 17 | | @charlie | *Pending* | | 18 | 19 |
20 |

Non-binding votes (1)

21 | 22 | | User | Vote | Timestamp | 23 | | ---- | :---: | :-------: | 24 | | supporter | In favor | 2023-01-03 12:00:00.0 +00:00:00 | 25 |
26 | -------------------------------------------------------------------------------- /charts/gitvote/templates/gitvote_secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: {{ include "chart.resourceNamePrefix" . }}gitvote-config 5 | type: Opaque 6 | stringData: 7 | gitvote.yml: |- 8 | addr: {{ .Values.gitvote.addr }} 9 | db: 10 | host: {{ default (printf "%s-postgresql.%s" .Release.Name .Release.Namespace) .Values.db.host }} 11 | port: {{ .Values.db.port | atoi }} 12 | dbname: {{ .Values.db.dbname }} 13 | user: {{ .Values.db.user }} 14 | password: {{ .Values.db.password }} 15 | log: 16 | format: {{ .Values.log.format }} 17 | github: 18 | appId: {{ .Values.gitvote.github.appID }} 19 | appPrivateKey: {{ .Values.gitvote.github.appPrivateKey | quote }} 20 | webhookSecret: {{ .Values.gitvote.github.webhookSecret | quote }} 21 | {{- with .Values.gitvote.github.webhookSecretFallback }} 22 | webhookSecretFallback: {{ . | quote }} 23 | {{- end }} 24 | 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | merge_group: 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | linter-backend: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v5 15 | - name: Setup Rust 16 | uses: dtolnay/rust-toolchain@master 17 | with: 18 | toolchain: 1.90.0 19 | components: clippy, rustfmt 20 | - name: Run clippy 21 | run: cargo clippy --all-targets --all-features -- --deny warnings 22 | - name: Run rustfmt 23 | run: cargo fmt --all -- --check 24 | 25 | tests-backend: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v5 30 | - name: Setup Rust 31 | uses: dtolnay/rust-toolchain@master 32 | with: 33 | toolchain: 1.90.0 34 | - name: Run backend tests 35 | run: cargo test 36 | -------------------------------------------------------------------------------- /src/testdata/templates/vote-created-with-teams-and-users.golden: -------------------------------------------------------------------------------- 1 | ## Vote created 2 | 3 | **@user** has called for a vote on `Add new feature X` (#42). 4 | 5 | The members of the following teams have binding votes: 6 | | Team | 7 | | ---- | 8 | | @org/core-team | 9 | | @org/maintainers | 10 | 11 | The following users have binding votes: 12 | | User | 13 | | ---- | 14 | | @alice | 15 | | @bob | 16 | 17 | Non-binding votes are also appreciated as a sign of support! 18 | 19 | ## How to vote 20 | 21 | You can cast your vote by reacting to `this` comment. The following reactions are supported: 22 | 23 | | In favor | Against | Abstain | 24 | | :------: | :-----: | :-----: | 25 | | 👍 | 👎 | 👀 | 26 | 27 | *Please note that voting for multiple options is not allowed and those votes won't be counted.* 28 | 29 | The vote will be open for `3days`. It will pass if at least `51%` of the users with binding votes vote `In favor 👍`. Once it's closed, results will be published here as a new comment. -------------------------------------------------------------------------------- /src/testdata/templates/vote-closed-passed.golden: -------------------------------------------------------------------------------- 1 | ## Vote closed 2 | 3 | The vote **passed**! 🎉 4 | 5 | `80.00%` of the users with binding vote were in favor and `20.00%` were against (passing threshold: `50%`). 6 | 7 | ### Summary 8 | 9 | | In favor | Against | Abstain | Not voted | 10 | | :--------------------: | :-------------------: | :------------------: | :---------------------: | 11 | | 4 | 1 | 0 | 0 | 12 | 13 | ### Binding votes (5) 14 | 15 | | User | Vote | Timestamp | 16 | | ---- | :---: | :-------: | 17 | | @alice | In favor | 2023-01-01 10:00:00.0 +00:00:00 | 18 | | @bob | In favor | 2023-01-01 11:00:00.0 +00:00:00 | 19 | | @charlie | Against | 2023-01-01 12:00:00.0 +00:00:00 | 20 | | @dave | In favor | 2023-01-01 13:00:00.0 +00:00:00 | 21 | | @eve | In favor | 2023-01-01 14:00:00.0 +00:00:00 | 22 | 23 |
24 |

Non-binding votes (2)

25 | 26 | | User | Vote | Timestamp | 27 | | ---- | :---: | :-------: | 28 | | @supporter1 | In favor | 2023-01-01 15:00:00.0 +00:00:00 | 29 | | @supporter2 | In favor | 2023-01-01 16:00:00.0 +00:00:00 | 30 |
31 | -------------------------------------------------------------------------------- /ADOPTERS.md: -------------------------------------------------------------------------------- 1 | # GitVote adopters 2 | 3 | If your organization is using GitVote, please consider adding it to this list by submitting a pull request. 4 | 5 | - [A2A Protocol](https://a2a-protocol.org) 6 | - [AsyncAPI](https://github.com/asyncapi/community/blob/master/voting.md) 7 | - [CloudNativePG](https://cloudnative-pg.io) 8 | - [CNCF](https://cncf.io) 9 | - [DevRel Foundation](https://dev-rel.org/) 10 | - [Dragonfly](https://d7y.io) 11 | - [Fintech Open Source Foundation](www.finos.org) 12 | - [Hiero Ledger](https://hiero.org/) 13 | - [Jeandle](https://github.com/jeandle) 14 | - [JSON Schema](https://json-schema.org) 15 | - [K8sGateway](https://k8sgateway.io) 16 | - [KServe](https://kserve.github.io/website/latest/) 17 | - [Kuadrant](https://kuadrant.io) 18 | - [Kuma](https://kuma.io) 19 | - [Kyverno](https://kyverno.io) 20 | - [Microcks](https://microcks.io/) 21 | - [NFDI4Health](https://github.com/nfdi4health) 22 | - [Open Component Model](https://ocm.software) 23 | - [OpenGemini](https://opengemini.org) 24 | - [OpenSSF](https://openssf.org) 25 | - [ORAS](https://oras.land) 26 | - [OSCAL Compass](https://github.com/oscal-compass) 27 | - [Ratify Project](https://ratify.dev) 28 | - [ResBaz Arizona](https://researchbazaar.arizona.edu) 29 | - [TODO Group](https://todogroup.org) 30 | - [Universal Blue](https://universal-blue.org) 31 | - [WasmEdge](https://wasmedge.org/) 32 | -------------------------------------------------------------------------------- /.github/workflows/chart.yml: -------------------------------------------------------------------------------- 1 | name: Helm CI 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - "charts/**" 7 | 8 | permissions: read-all 9 | 10 | jobs: 11 | lint-and-test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v5 16 | with: 17 | fetch-depth: 0 18 | - name: Set up Helm 19 | uses: azure/setup-helm@v4 20 | with: 21 | version: v3.9.2 22 | - name: Set up Python 23 | uses: actions/setup-python@v6 24 | with: 25 | python-version: 3.8 26 | - name: Set up chart-testing 27 | uses: helm/chart-testing-action@v2.7.0 28 | - name: Run chart-testing (list-changed) 29 | id: list-changed 30 | run: | 31 | changed=$(ct --config .ct.yaml list-changed --target-branch ${{ github.event.repository.default_branch }}) 32 | if [[ -n "$changed" ]]; then 33 | echo "changed=true" >> $GITHUB_OUTPUT 34 | fi 35 | - name: Run chart-testing (lint) 36 | run: ct lint --config .ct.yaml --target-branch ${{ github.event.repository.default_branch }} 37 | - name: Create kind cluster 38 | uses: helm/kind-action@v1.12.0 39 | if: steps.list-changed.outputs.changed == 'true' 40 | - name: Run chart-testing (install) 41 | run: ct install --config .ct.yaml --target-branch ${{ github.event.repository.default_branch }} 42 | -------------------------------------------------------------------------------- /templates/vote-created.md: -------------------------------------------------------------------------------- 1 | ## Vote created 2 | 3 | **@{{ creator }}** has called for a vote on `{{ issue_title }}` (#{{ issue_number }}). 4 | 5 | {%- if !teams.is_empty() || !users.is_empty() %} 6 | {% if !teams.is_empty() ~%} 7 | The members of the following teams have binding votes: 8 | 9 | {{~ "| Team |" }} 10 | {{~ "| ---- |" }} 11 | {%- for team in teams ~%} 12 | | @{{ org }}/{{ team }} {{ "|" -}} 13 | {% endfor %} 14 | {% endif -%} 15 | 16 | {% if !users.is_empty() ~%} 17 | The following users have binding votes: 18 | 19 | {{~ "| User |" }} 20 | {{~ "| ---- |" }} 21 | {%- for user in users ~%} 22 | | @{{ user }} {{ "|" -}} 23 | {% endfor %} 24 | {% endif -%} 25 | 26 | {% else ~%} 27 | {{ " " ~}} 28 | All repository collaborators have binding votes. 29 | {% endif %} 30 | Non-binding votes are also appreciated as a sign of support! 31 | 32 | ## How to vote 33 | 34 | You can cast your vote by reacting to `this` comment. The following reactions are supported: 35 | 36 | | In favor | Against | Abstain | 37 | | :------: | :-----: | :-----: | 38 | | 👍 | 👎 | 👀 | 39 | 40 | *Please note that voting for multiple options is not allowed and those votes won't be counted.* 41 | 42 | The vote will be open for `{{ duration }}`. It will pass if at least `{{ pass_threshold }}%` of the users with binding votes vote `In favor 👍`. Once it's closed, results will be published here as a new comment. 43 | -------------------------------------------------------------------------------- /templates/vote-status.md: -------------------------------------------------------------------------------- 1 | ## Vote status 2 | 3 | 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 }}%`). 4 | 5 | ### Summary 6 | 7 | | In favor | Against | Abstain | Not voted | 8 | | :--------------------: | :-------------------: | :------------------: | :---------------------: | 9 | | {{ results.in_favor }} | {{ results.against }} | {{ results.abstain}} | {{ results.not_voted }} | 10 | 11 | ### Binding votes ({{ results.binding }}) 12 | 13 | | User | Vote | Timestamp | 14 | | ---- | :---: | :-------: | 15 | {%- for (user, vote) in results.votes ~%} 16 | {%- if vote.binding ~%} 17 | | {{ user }} | {{ vote.vote_option }} | {{ vote.timestamp }} {{ "|" -}} 18 | {% endif -%} 19 | {% endfor -%} 20 | {%- for user in results.pending_voters ~%} 21 | | @{{ user }} | *Pending* | {{ "|" -}} 22 | {%- endfor %} 23 | {% if results.non_binding > 0 ~%} 24 |
25 |

Non-binding votes ({{ results.non_binding }})

26 | 27 | {%~ let max_non_binding = 300 %} 28 | {%- if results.non_binding > max_non_binding %} 29 | (displaying only the first {{ max_non_binding }} non-binding votes) 30 | {% endif %} 31 | 32 | {{~ "| User | Vote | Timestamp |" }} 33 | {{~ "| ---- | :---: | :-------: |" }} 34 | {%- for (user, vote) in results.votes|non_binding(max_non_binding) ~%} 35 | | {{ user }} | {{ vote.vote_option }} | {{ vote.timestamp }} {{ "|" -}} 36 | {% endfor ~%} 37 |
38 | {% endif %} 39 | -------------------------------------------------------------------------------- /docs/logo/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /charts/gitvote/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: gitvote 3 | description: GitVote is a GitHub application that allows holding a vote on issues and pull requests 4 | type: application 5 | version: 1.5.1-0 6 | appVersion: 1.5.0 7 | kubeVersion: ">= 1.19.0-0" 8 | home: https://gitvote.dev 9 | icon: https://raw.githubusercontent.com/cncf/gitvote/main/docs/logo/logo.png 10 | keywords: 11 | - git 12 | - vote 13 | - gitvote 14 | maintainers: 15 | - name: Sergio 16 | email: tegioz@icloud.com 17 | - name: Cintia 18 | email: cynthiasg@icloud.com 19 | dependencies: 20 | - name: postgresql 21 | version: 18.0.15 22 | repository: https://charts.bitnami.com/bitnami 23 | condition: postgresql.enabled 24 | annotations: 25 | artifacthub.io/category: skip-prediction 26 | artifacthub.io/changes: | 27 | - kind: added 28 | description: Audit page 29 | - kind: added 30 | description: "Extra location for config: .github directory" 31 | - kind: added 32 | description: Some tests for templates 33 | - kind: changed 34 | description: Improve vote labeling 35 | - kind: changed 36 | description: Move images to ghcr.io 37 | - kind: changed 38 | description: Bump Alpine to 3.22.2 39 | - kind: changed 40 | description: Bump Rust to 1.90 41 | - kind: changed 42 | description: Upgrade dependencies 43 | artifacthub.io/containsSecurityUpdates: "true" 44 | artifacthub.io/images: | 45 | - name: dbmigrator 46 | image: ghcr.io/cncf/gitvote/dbmigrator:v1.5.0 47 | - name: gitvote 48 | image: ghcr.io/cncf/gitvote/server:v1.5.0 49 | artifacthub.io/links: | 50 | - name: source 51 | url: https://github.com/cncf/gitvote 52 | - name: support 53 | url: https://github.com/cncf/gitvote/issues 54 | -------------------------------------------------------------------------------- /charts/gitvote/templates/dbmigrator_job.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: Job 3 | metadata: 4 | {{- if .Release.IsInstall }} 5 | name: {{ include "chart.resourceNamePrefix" . }}dbmigrator-install 6 | {{- else }} 7 | name: {{ include "chart.resourceNamePrefix" . }}dbmigrator-upgrade 8 | annotations: 9 | "helm.sh/hook": pre-upgrade 10 | "helm.sh/hook-weight": "0" 11 | "helm.sh/hook-delete-policy": before-hook-creation 12 | {{- end }} 13 | spec: 14 | template: 15 | spec: 16 | {{- with .Values.dbmigrator.job.podSecurityContext }} 17 | securityContext: 18 | {{- toYaml . | nindent 8 }} 19 | {{- end }} 20 | {{- with .Values.imagePullSecrets }} 21 | imagePullSecrets: 22 | {{- toYaml . | nindent 8 }} 23 | {{- end }} 24 | restartPolicy: Never 25 | initContainers: 26 | - {{- include "chart.checkDbIsReadyInitContainer" . | nindent 10 }} 27 | containers: 28 | - name: dbmigrator 29 | image: {{ .Values.dbmigrator.job.image.repository }}:{{ .Values.imageTag | default (printf "v%s" .Chart.AppVersion) }} 30 | imagePullPolicy: {{ .Values.pullPolicy }} 31 | {{- with .Values.dbmigrator.job.containerSecurityContext }} 32 | securityContext: 33 | {{- toYaml . | nindent 12 }} 34 | {{- end }} 35 | env: 36 | - name: TERN_CONF 37 | value: {{ .Values.configDir }}/tern.conf 38 | volumeMounts: 39 | - name: dbmigrator-config 40 | mountPath: {{ .Values.configDir }} 41 | readOnly: true 42 | command: ["./migrate.sh"] 43 | volumes: 44 | - name: dbmigrator-config 45 | secret: 46 | secretName: {{ include "chart.resourceNamePrefix" . }}dbmigrator-config 47 | -------------------------------------------------------------------------------- /src/cfg_svc.rs: -------------------------------------------------------------------------------- 1 | //! This module defines some types and functionality to represent and process 2 | //! the `GitVote` service configuration. 3 | 4 | use std::path::Path; 5 | 6 | use anyhow::Result; 7 | use deadpool_postgres::Config as Db; 8 | use figment::{ 9 | Figment, 10 | providers::{Env, Format, Serialized, Yaml}, 11 | }; 12 | use serde::{Deserialize, Serialize}; 13 | 14 | /// Server configuration. 15 | #[derive(Debug, Clone, Deserialize, Serialize)] 16 | pub(crate) struct Cfg { 17 | pub addr: String, 18 | pub db: Db, 19 | pub github: GitHubApp, 20 | pub log: Log, 21 | } 22 | 23 | impl Cfg { 24 | /// Create a new Cfg instance. 25 | pub(crate) fn new(config_file: &Path) -> Result { 26 | Figment::new() 27 | .merge(Serialized::default("addr", "127.0.0.1:9000")) 28 | .merge(Serialized::default("log.format", "pretty")) 29 | .merge(Yaml::file(config_file)) 30 | .merge(Env::prefixed("GITVOTE_").split("_").lowercase(false)) 31 | .extract() 32 | .map_err(Into::into) 33 | } 34 | } 35 | 36 | /// Logs configuration. 37 | #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] 38 | pub(crate) struct Log { 39 | pub format: LogFormat, 40 | } 41 | 42 | /// Format to use in logs. 43 | #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] 44 | #[serde(rename_all(deserialize = "lowercase"))] 45 | pub(crate) enum LogFormat { 46 | Json, 47 | Pretty, 48 | } 49 | 50 | /// GitHub application configuration. 51 | #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] 52 | #[serde(rename_all(deserialize = "camelCase"))] 53 | pub struct GitHubApp { 54 | pub app_id: i64, 55 | pub app_private_key: String, 56 | pub webhook_secret: String, 57 | pub webhook_secret_fallback: Option, 58 | } 59 | -------------------------------------------------------------------------------- /templates/vote-closed.md: -------------------------------------------------------------------------------- 1 | {%- block introduction %}{%+ endblock -%} 2 | ## {% block title %}Vote closed{% endblock %} 3 | 4 | The vote {% if results.passed %}**passed**! 🎉{% else %}**did not pass**.{% endif %} 5 | 6 | `{{ "{:.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 }}%`). 7 | 8 | ### Summary 9 | 10 | | In favor | Against | Abstain | Not voted | 11 | | :--------------------: | :-------------------: | :------------------: | :---------------------: | 12 | | {{ results.in_favor }} | {{ results.against }} | {{ results.abstain}} | {{ results.not_voted }} | 13 | 14 | {%~ if !results.votes.is_empty() -%} 15 | {%- if results.binding > 0 ~%} 16 | ### Binding votes ({{ results.binding }}) 17 | {{ "" }} 18 | {{~ "| User | Vote | Timestamp |" }} 19 | {{~ "| ---- | :---: | :-------: |" }} 20 | {%- for (user, vote) in results.votes ~%} 21 | {%- if vote.binding ~%} 22 | | @{{ user }} | {{ vote.vote_option }} | {{ vote.timestamp }} {{ "|" -}} 23 | {% endif -%} 24 | {% endfor %} 25 | {% endif -%} 26 | 27 | {% if results.non_binding > 0 ~%} 28 |
29 |

Non-binding votes ({{ results.non_binding }})

30 | 31 | {%~ let max_non_binding = 300 %} 32 | {%- if results.non_binding > max_non_binding %} 33 | (displaying only the first {{ max_non_binding }} non-binding votes) 34 | {%- endif %} 35 | 36 | {{~ "| User | Vote | Timestamp |" }} 37 | {{~ "| ---- | :---: | :-------: |" }} 38 | {%- for (user, vote) in results.votes|non_binding(max_non_binding) ~%} 39 | | @{{ user }} | {{ vote.vote_option }} | {{ vote.timestamp }} {{ "|" -}} 40 | {% endfor ~%} 41 |
42 | {% endif -%} 43 | {% endif -%} 44 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gitvote" 3 | description = "GitVote server" 4 | version = "1.5.0" 5 | license = "Apache-2.0" 6 | edition = "2024" 7 | rust-version = "1.90.0" 8 | 9 | [dependencies] 10 | anyhow = "1.0.100" 11 | askama = { version = "0.14.0", features = ["serde_json"] } 12 | async-channel = "2.5.0" 13 | async-trait = "0.1.89" 14 | axum = { version = "0.8.6", features = ["macros"] } 15 | cached = { version = "0.56.0", features = ["async"] } 16 | clap = { version = "4.5.49", features = ["derive"] } 17 | deadpool-postgres = { version = "0.14.1", features = ["serde"] } 18 | figment = { version = "0.10.19", features = ["yaml", "env"] } 19 | futures = "0.3.31" 20 | graphql_client = { version = "0.14.0", features = ["reqwest"] } 21 | hex = "0.4.3" 22 | hmac = "0.12.1" 23 | http = "1.3.1" 24 | humantime = "2.3.0" 25 | humantime-serde = "1.1.1" 26 | ignore = "0.4.24" 27 | jsonwebtoken = "9.3.1" # do-not-upgrade 28 | octocrab = "0.47.0" 29 | openssl = { version = "0.10.74", features = ["vendored"] } 30 | postgres-openssl = "0.5.2" 31 | regex = "1.12.2" 32 | reqwest = "0.12.24" 33 | serde = { version = "1.0.228", features = ["derive"] } 34 | serde_json = "1.0.145" 35 | serde_yaml = "0.9.34" 36 | sha2 = "0.10.9" 37 | thiserror = "2.0.17" 38 | time = { version = "0.3.44", features = ["serde"] } 39 | tokio = { version = "1.48.0", features = [ 40 | "macros", 41 | "rt-multi-thread", 42 | "signal", 43 | "time", 44 | ] } 45 | tokio-postgres = { version = "0.7.15", features = [ 46 | "with-uuid-1", 47 | "with-serde_json-1", 48 | "with-time-0_3", 49 | ] } 50 | tokio-util = { version = "0.7.16", features = ["rt"] } 51 | tower = { version = "0.5.2", features = ["util"] } 52 | tower-http = { version = "0.6.6", features = ["trace"] } 53 | tracing = "0.1.40" 54 | tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] } 55 | uuid = { version = "1.18.1", features = ["serde", "v4"] } 56 | 57 | [dev-dependencies] 58 | http-body = "1.0.1" 59 | hyper = "1.7.0" 60 | mockall = "0.13.1" 61 | -------------------------------------------------------------------------------- /charts/gitvote/ci/default-values.yaml: -------------------------------------------------------------------------------- 1 | imageTag: latest 2 | gitvote: 3 | github: 4 | # Sample app ID for chart testing 5 | appID: 123456 6 | # Sample key for chart testing 7 | appPrivateKey: |- 8 | -----BEGIN RSA PRIVATE KEY----- 9 | MIIEowIBAAKCAQEAyJqjmieZuxZV6Uxtdf0OlFZltcC1ywrlqMLDNNpN6MBjpRYX 10 | iG6mUlTgoqDDDbb2usvPmKfdO7bynemJmsmlzS9Tk1PPJTaHTAST5avjYXqSpAMR 11 | 4xvPUt097LD35TjGH4ZH4jCOpYvgKqrrma51HDOsucqG52OFoO+J0ZeSwtb2++Jg 12 | N53Yp9uiYcEB/aafpNIktmX0ZT7/doDwk9Fz3j86CV+ctnlsObXGmZEHt5OMqREb 13 | FbleFkEckha1sjio9iGcZCFHXCHZ/+/EyLnxLM/LFri+BIqAuOuLD+l29JJM+KSJ 14 | nOC2ZkSYZxzKP4CW+4Z/kW6a3wTZ5uGsT7VhFwIDAQABAoIBADAmxkxzYvhAZUDJ 15 | wqCGrKA4mNNmvXxOUlAO5/Jg0EClJYXz5pQuEyhCDWWb9xXsrA6sa1k2OeligZwb 16 | +Za4/l5hFMuRW3CQRSufEa0YdEzqshZCUmHURBCc4IdW9zoDRbM9dTW6+BKOn7E+ 17 | M61A7gVl9fjmvzj9b47w0IEJxAWWTOGOsGgpeTp8RDA0zqkSHZ/huSvLvc3raexF 18 | h8G3fSqseXehgVHuVnRC4ROSPFMnvR3f2F2nyvuErGPSq9aQEpik5iDzQTEMEy4v 19 | Rk/D+w+M6JJ4uzra4N9e4C1pUmKhaWiHZWZ+SDlYlHNzJKyvcAT8Gtf1MAuwGbrk 20 | vMjF7RECgYEA7yfjqmNMu1CkFkXZdyspy7i5D9SHNnUmlezpjpdEgQhel6XAs0EJ 21 | 6JhcWlw4p24tyMPLb6VahS6hu+QhkBpW3VQ5MeTPAKMF8xRJyK/n0Z7Thoa//6w1 22 | 1iCrKuZ6snHW+WPkG9DJ6btX5CIBADIfId4i+OkW3qBZDx06Ix83ZL8CgYEA1ruj 23 | 78xglZ6Z6QYIZp2NoIvqU5/2o1oyGDUGvhdREEm8Ld5S+unH+KCxZHQZzko1zXAv 24 | KM14HBwT9pV2/9nbTLghKm4+gGRNjJC0P6XkUemr385svyIGImnHNbMIvEbpzaJP 25 | jm12lyqHdAFJax1oipKjVmvvQj5gMciOdqlo4akCgYBR1hqHwbcOGggsPvatWq3Q 26 | soNRMW6bafcsMoexbX3ZkZ2c0vFf5Y+YchqYKRqR4Jf2LVm9+J1DGbPqcaQyhXDY 27 | B+wScLONCjwM9BJThC4Vgv3q+M1Wlf1OKpun8Hpn+aCQcmgqRIXzX1IyFJi3Em+o 28 | zTS2bDyRLdmL3Hp6bkIsTQKBgDChSh0ykeUQiBan0Rs8LyjexvCtV3PjJ1koGSDP 29 | swIXUNCqeuxsKWd7LPFtAbMgR1MBRwzci4kCKts7OjnzIqEbSheL5Ae7r3xYARow 30 | /aY3Xz9ORn56vBzrC7xzkVTiUmzJh27gB21wqkBxUik5/cT0NJ2L0CGWcr6ThwAE 31 | mcYRAoGBAMiyvQumxuBx07ip/YXxrAL3mKcc+2uy5rYS/pDkD70WowshWu0tQhHS 32 | wH5+2oCEXWCZeeI/dXsYbQd3nudkXp8zR+D2rEGeKAlsQsG6xYW3i+8XBD0OIS6y 33 | zXhNWWxXj/VC4KEom5b/QTmnzXFQ+/TjV3Pd5gPhacH5j4dvtXj1 34 | -----END RSA PRIVATE KEY----- 35 | # Sample webhook secret for chart testing 36 | webhookSecret: "sample-secret-for-chart-testing" 37 | -------------------------------------------------------------------------------- /GOVERNANCE.md: -------------------------------------------------------------------------------- 1 | # GitVote Governance 2 | 3 | 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. 4 | 5 | ## Contributors 6 | 7 | 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. 8 | 9 | ## Maintainers 10 | 11 | Maintainers are responsible for the development and operation of the project. This includes but is not limited to: 12 | 13 | - Reviewing and merging pull requests 14 | - The operation of the GitVote service and GitHub application 15 | - Refining the projects governance 16 | - Overseeing the resolution and disclosure of security issues 17 | 18 | Changes to maintainers use the following rules: 19 | 20 | - 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). 21 | - 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. 22 | - A maintainer may step down at any time and remove themselves. 23 | - 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. 24 | 25 | ## Decision Making 26 | 27 | There are 3 ways decisions can be made for non-code related decisions. Those are: 28 | 29 | 1. [Lazy-consensus](http://communitymgt.wikia.com/wiki/Lazy_consensus) is the default method to make decisions. 30 | 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. 31 | 3. Some decisions require a super-majority of maintainer to approve. Those include: 32 | - Changes to the governance 33 | - Removing a maintainer 34 | - Licensing and intellectual property changes 35 | 36 | Changes to source code requires a maintainer to approve the changes. 37 | -------------------------------------------------------------------------------- /.github/workflows/build-images.yml: -------------------------------------------------------------------------------- 1 | name: Build images 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | env: 9 | OCI_CLI_FINGERPRINT: ${{ secrets.OCI_CLI_FINGERPRINT }} 10 | OCI_CLI_KEY_CONTENT: ${{ secrets.OCI_CLI_KEY_CONTENT }} 11 | OCI_CLI_REGION: ${{ secrets.OCI_CLI_REGION }} 12 | OCI_CLI_TENANCY: ${{ secrets.OCI_CLI_TENANCY }} 13 | OCI_CLI_USER: ${{ secrets.OCI_CLI_USER }} 14 | 15 | jobs: 16 | build-gitvote-dbmigrator-image: 17 | if: github.ref == 'refs/heads/main' 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v5 22 | - name: Login to OCI Registry 23 | id: login-ocir 24 | uses: oracle-actions/login-ocir@v1.3.0 25 | with: 26 | auth_token: ${{ secrets.OCI_AUTH_TOKEN }} 27 | - name: Get gitvote dbmigrator OCIR repository 28 | id: get-ocir-repository-dbmigrator 29 | uses: oracle-actions/get-ocir-repository@v1.3.0 30 | with: 31 | name: gitvote/dbmigrator 32 | compartment: ${{ secrets.OCI_COMPARTMENT_OCID }} 33 | - name: Build and push gitvote-dbmigrator image 34 | env: 35 | OCIR_REPOSITORY: ${{ steps.get-ocir-repository-dbmigrator.outputs.repo_path }} 36 | run: | 37 | docker build \ 38 | -f database/migrations/Dockerfile \ 39 | -t $OCIR_REPOSITORY:$GITHUB_SHA \ 40 | . 41 | docker push $OCIR_REPOSITORY:$GITHUB_SHA 42 | 43 | build-gitvote-image: 44 | if: github.ref == 'refs/heads/main' 45 | runs-on: ubuntu-latest 46 | steps: 47 | - name: Checkout code 48 | uses: actions/checkout@v5 49 | - name: Login to OCI Registry 50 | id: login-ocir 51 | uses: oracle-actions/login-ocir@v1.3.0 52 | with: 53 | auth_token: ${{ secrets.OCI_AUTH_TOKEN }} 54 | - name: Get gitvote server OCIR repository 55 | id: get-ocir-repository-server 56 | uses: oracle-actions/get-ocir-repository@v1.3.0 57 | with: 58 | name: gitvote/server 59 | compartment: ${{ secrets.OCI_COMPARTMENT_OCID }} 60 | - name: Build and push gitvote image 61 | env: 62 | OCIR_REPOSITORY: ${{ steps.get-ocir-repository-server.outputs.repo_path }} 63 | run: | 64 | docker build \ 65 | -t $OCIR_REPOSITORY:$GITHUB_SHA \ 66 | . 67 | docker push $OCIR_REPOSITORY:$GITHUB_SHA 68 | -------------------------------------------------------------------------------- /charts/gitvote/templates/gitvote_deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "chart.resourceNamePrefix" . }}gitvote 5 | labels: 6 | app.kubernetes.io/component: gitvote 7 | {{- include "chart.labels" . | nindent 4 }} 8 | spec: 9 | replicas: {{ .Values.gitvote.deploy.replicaCount }} 10 | selector: 11 | matchLabels: 12 | app.kubernetes.io/component: gitvote 13 | {{- include "chart.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | labels: 17 | app.kubernetes.io/component: gitvote 18 | {{- include "chart.selectorLabels" . | nindent 8 }} 19 | spec: 20 | {{- with .Values.gitvote.deploy.podSecurityContext }} 21 | securityContext: 22 | {{- toYaml . | nindent 8 }} 23 | {{- end }} 24 | {{- with .Values.imagePullSecrets }} 25 | imagePullSecrets: 26 | {{- toYaml . | nindent 8 }} 27 | {{- end }} 28 | {{- if .Release.IsInstall }} 29 | serviceAccountName: {{ include "chart.resourceNamePrefix" . }}gitvote 30 | {{- end }} 31 | initContainers: 32 | - {{- include "chart.checkDbIsReadyInitContainer" . | nindent 10 }} 33 | {{- if .Release.IsInstall }} 34 | - name: check-dbmigrator-run 35 | {{ $kubeVersion := include "chart.KubernetesVersion" . }} 36 | {{ $kubectlImageVersion := ternary "1.33" $kubeVersion (semverCompare ">=1.34.0-0" (printf "%s.0" $kubeVersion)) }} 37 | image: "docker.io/bitnamilegacy/kubectl:{{ $kubectlImageVersion }}" 38 | imagePullPolicy: IfNotPresent 39 | {{- with .Values.checkDbIsReadyInitContainer.securityContext }} 40 | securityContext: 41 | {{- toYaml . | nindent 12 }} 42 | {{- end }} 43 | command: ['kubectl', 'wait', '--namespace={{ .Release.Namespace }}', '--for=condition=complete', 'job/{{ include "chart.resourceNamePrefix" . }}dbmigrator-install', '--timeout=60s'] 44 | {{- end }} 45 | containers: 46 | - name: gitvote 47 | image: {{ .Values.gitvote.deploy.image.repository }}:{{ .Values.imageTag | default (printf "v%s" .Chart.AppVersion) }} 48 | imagePullPolicy: {{ .Values.pullPolicy }} 49 | {{- with .Values.gitvote.deploy.containerSecurityContext }} 50 | securityContext: 51 | {{- toYaml . | nindent 12 }} 52 | {{- end }} 53 | volumeMounts: 54 | - name: gitvote-config 55 | mountPath: {{ .Values.configDir | quote }} 56 | readOnly: true 57 | ports: 58 | - name: http 59 | containerPort: 9000 60 | protocol: TCP 61 | {{- with .Values.gitvote.deploy.readinessProbe }} 62 | readinessProbe: 63 | {{- toYaml . | nindent 12 }} 64 | {{- end }} 65 | resources: 66 | {{- toYaml .Values.gitvote.deploy.resources | nindent 12 }} 67 | command: ['gitvote', '-c', '{{ .Values.configDir }}/gitvote.yml'] 68 | volumes: 69 | - name: gitvote-config 70 | secret: 71 | secretName: {{ include "chart.resourceNamePrefix" . }}gitvote-config 72 | -------------------------------------------------------------------------------- /charts/gitvote/README.md: -------------------------------------------------------------------------------- 1 | # GitVote 2 | 3 | [GitVote](https://gitvote.dev) is a GitHub application that allows holding a vote on issues and pull requests. 4 | 5 | ## Introduction 6 | 7 | This chart bootstraps a GitVote deployment on a [Kubernetes](http://kubernetes.io) cluster using the [Helm](https://helm.sh) package manager. 8 | 9 | ## Prerequisites 10 | 11 | Before installing this chart, you need to [setup a GitHub application](https://docs.github.com/en/apps/creating-github-apps/creating-github-apps/creating-a-github-app). The application requires the following permissions [to be set](https://docs.github.com/en/apps/maintaining-github-apps/editing-a-github-apps-permissions): 12 | 13 | Repository: 14 | 15 | - **Checks**: *read/write* 16 | - **Contents**: *read* 17 | - **Discussions**: *read/write* 18 | - **Issues**: *read/write* 19 | - **Metadata**: *read* 20 | - **Pull requests**: *read/write* 21 | 22 | Organization: 23 | 24 | - **Members**: *read* 25 | 26 | In addition to those permissions, it must also be subscribed to the following events: 27 | 28 | - *Issue Comment* 29 | - *Issues* 30 | - *Pull Request* 31 | 32 | GitVote expects GitHub events to be sent to the `/api/events` endpoint. In the GitHub application, please enable `webhook` and set the target URL to your exposed endpoint (ie: ). You will need to define a random secret for the webhook (you can use the following command to do it: `openssl rand -hex 32`). Please note your webhook secret, as well as the GitHub application ID and private key, as you'll need them in the next step when installing the chart. 33 | 34 | Once your GitHub application is ready and GitVote has been deployed, you can install it in the organizations or repositories you need. 35 | 36 | ## Installing the chart 37 | 38 | Create a values file (`my-values.yaml`) that includes the configuration values required from your GitHub application: 39 | 40 | ```yaml 41 | gitvote: 42 | github: 43 | appID: 123456 # Replace with your GitHub app ID 44 | appPrivateKey: |- 45 | -----BEGIN RSA PRIVATE KEY----- 46 | ... 47 | YOUR_APP_PRIVATE_KEY 48 | ... 49 | -----END RSA PRIVATE KEY----- 50 | webhookSecret: "your-webhook-secret" 51 | webhookSecretFallback: "old-webhook-secret" # Handy for webhook secret rotation 52 | ``` 53 | 54 | To install the chart with the release name `my-gitvote` run: 55 | 56 | ```bash 57 | $ helm repo add gitvote https://cncf.github.io/gitvote/ 58 | $ helm install --values my-values.yaml my-gitvote gitvote/gitvote 59 | ``` 60 | 61 | The command above deploys GitVote on the Kubernetes cluster using the default configuration values and the GitHub application configuration provided. Please see the [chart's default values file](https://github.com/cncf/gitvote/blob/main/charts/gitvote/values.yaml) for a list of all the configurable parameters of the chart and their default values. 62 | 63 | ## Uninstalling the chart 64 | 65 | To uninstall the `my-gitvote` deployment run: 66 | 67 | ```bash 68 | $ helm uninstall my-gitvote 69 | ``` 70 | 71 | This command removes all the Kubernetes components associated with the chart and deletes the release. 72 | 73 | ## How GitVote works 74 | 75 | For more information about how GitVote works from a user's perspective please see the [repository's README file](https://github.com/cncf/gitvote#readme). 76 | -------------------------------------------------------------------------------- /charts/gitvote/values.yaml: -------------------------------------------------------------------------------- 1 | # GitVote chart default configuration values 2 | 3 | imagePullSecrets: [] 4 | imageTag: "" 5 | nameOverride: "" 6 | pullPolicy: IfNotPresent 7 | 8 | # Enable dynamic resource name prefix 9 | # 10 | # Enabling the dynamic resource name prefix ensures that the resources are named dynamically based on the Helm 11 | # installation's name. This allows multiple installations of this chart in a single Kubernetes namespace. The prefix 12 | # can be defined by using the `fullnameOverride`. 13 | dynamicResourceNamePrefixEnabled: false 14 | 15 | # Overwrites the installation's fullname generation (used for the dynamic resource name prefix) 16 | fullnameOverride: "" 17 | 18 | # Directory path where the configuration files should be mounted 19 | configDir: "/home/gitvote/.config/gitvote" 20 | 21 | # Check database readiness init container configuration 22 | checkDbIsReadyInitContainer: 23 | securityContext: {} 24 | 25 | # Database configuration 26 | db: 27 | host: "" 28 | port: "5432" 29 | dbname: gitvote 30 | password: gitvote 31 | user: gitvote 32 | 33 | # Log configuration 34 | log: 35 | # Output format [json|pretty] 36 | format: json 37 | 38 | # Database migrator configuration 39 | dbmigrator: 40 | job: 41 | containerSecurityContext: {} 42 | image: 43 | # Database migrator image repository (without the tag) 44 | repository: ghcr.io/cncf/gitvote/dbmigrator 45 | podSecurityContext: {} 46 | 47 | # GitVote service configuration 48 | gitvote: 49 | # Address to listen on 50 | addr: 0.0.0.0:9000 51 | 52 | # GitHub configuration 53 | # 54 | # For more information about the permissions and events required please see 55 | # the chart's readme file. 56 | github: 57 | # GitHub application ID 58 | appID: null 59 | # GitHub application private key path 60 | appPrivateKey: null 61 | # GitHub application webhook secret 62 | webhookSecret: null 63 | # GitHub application webhook secret fallback (handy for webhook secret rotation) 64 | webhookSecretFallback: null 65 | 66 | # Ingress configuration 67 | ingress: 68 | enabled: true 69 | annotations: 70 | kubernetes.io/ingress.class: nginx 71 | backendServicePort: 80 72 | rules: [] 73 | tls: [] 74 | 75 | # Service configuration 76 | service: 77 | allocateLoadBalancerNodePorts: true 78 | annotations: {} 79 | ports: 80 | - name: http 81 | port: 80 82 | protocol: TCP 83 | targetPort: 9000 84 | type: NodePort 85 | 86 | # Deployment configuration 87 | deploy: 88 | containerSecurityContext: {} 89 | image: 90 | repository: ghcr.io/cncf/gitvote/server 91 | podSecurityContext: {} 92 | readinessProbe: 93 | httpGet: 94 | path: / 95 | port: 9000 96 | replicaCount: 1 97 | resources: {} 98 | 99 | # PostgreSQL configuration 100 | postgresql: 101 | enabled: true 102 | auth: 103 | database: gitvote 104 | password: gitvote 105 | username: gitvote 106 | global: 107 | security: 108 | allowInsecureImages: true 109 | image: 110 | registry: docker.io 111 | repository: artifacthub/postgres 112 | tag: latest 113 | persistence: 114 | mountPath: /data 115 | primary: 116 | extraVolumes: 117 | - name: run 118 | emptyDir: {} 119 | extraVolumeMounts: 120 | - name: run 121 | mountPath: /var/run/postgresql 122 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: read-all 9 | 10 | jobs: 11 | build-and-publish-images: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | packages: write 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v5 18 | - name: Login to GitHub Container Registry 19 | uses: docker/login-action@v3 20 | with: 21 | registry: ghcr.io 22 | username: ${{ github.actor }} 23 | password: ${{ secrets.GITHUB_TOKEN }} 24 | - name: Extract tag name 25 | id: extract_tag_name 26 | run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 27 | - name: Build and push gitvote-dbmigrator image 28 | run: | 29 | docker build \ 30 | -f database/migrations/Dockerfile \ 31 | -t ghcr.io/${{ github.repository }}/dbmigrator:${{steps.extract_tag_name.outputs.tag}} \ 32 | -t ghcr.io/${{ github.repository }}/dbmigrator:latest \ 33 | . 34 | docker push --all-tags ghcr.io/${{ github.repository }}/dbmigrator 35 | - name: Build and push gitvote image 36 | run: | 37 | docker build \ 38 | -t ghcr.io/${{ github.repository }}/server:${{steps.extract_tag_name.outputs.tag}} \ 39 | -t ghcr.io/${{ github.repository }}/server:latest \ 40 | . 41 | docker push --all-tags ghcr.io/${{ github.repository }}/server 42 | 43 | package-and-publish-helm-chart: 44 | needs: 45 | - build-and-publish-images 46 | permissions: 47 | contents: write 48 | runs-on: ubuntu-latest 49 | steps: 50 | - name: Checkout 51 | uses: actions/checkout@v5 52 | with: 53 | fetch-depth: 0 54 | - name: Configure Git 55 | run: | 56 | git config user.name "$GITHUB_ACTOR" 57 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com" 58 | - name: Install Helm 59 | uses: azure/setup-helm@v4 60 | - name: Run chart-releaser 61 | run: | 62 | # From: https://github.com/metallb/metallb/blob/293f43c1f78ab1b5fa8879a76746b094bd9dd3ca/.github/workflows/publish.yaml#L134-L163 63 | # Ref: https://github.com/helm/chart-releaser-action/issues/60 64 | curl -sSLo cr.tar.gz "https://github.com/helm/chart-releaser/releases/download/v1.5.0/chart-releaser_1.5.0_linux_amd64.tar.gz" 65 | tar -xzf cr.tar.gz 66 | rm -f cr.tar.gz 67 | repo=$(basename "$GITHUB_REPOSITORY") 68 | owner=$(dirname "$GITHUB_REPOSITORY") 69 | tag="${GITHUB_REF_NAME:1}" 70 | exists=$(curl -s -H "Accept: application/vnd.github.v3+json" https://github.com/$GITHUB_REPOSITORY/releases/tag/$repo-chart-$tag -w %{http_code} -o /dev/null) 71 | if [[ $exists != "200" ]]; then 72 | echo "Creating release..." 73 | # package chart 74 | ./cr package charts/$repo 75 | # upload chart to github releases 76 | ./cr upload \ 77 | --owner "$owner" \ 78 | --git-repo "$repo" \ 79 | --release-name-template "{{ .Name }}-chart-{{ .Version }}" \ 80 | --token "${{ secrets.GITHUB_TOKEN }}" 81 | # Update index and push to github pages 82 | ./cr index \ 83 | --owner "$owner" \ 84 | --git-repo "$repo" \ 85 | --index-path index.yaml \ 86 | --release-name-template "{{ .Name }}-chart-{{ .Version }}" \ 87 | --push 88 | else 89 | echo "Release already exists" 90 | fi 91 | -------------------------------------------------------------------------------- /charts/gitvote/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "chart.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "chart.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "chart.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | 34 | {{/* 35 | Common labels 36 | */}} 37 | {{- define "chart.labels" -}} 38 | helm.sh/chart: {{ include "chart.chart" . }} 39 | {{ include "chart.selectorLabels" . }} 40 | {{- if .Chart.AppVersion }} 41 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 42 | {{- end }} 43 | app.kubernetes.io/managed-by: {{ .Release.Service }} 44 | {{- end -}} 45 | 46 | {{/* 47 | Selector labels 48 | */}} 49 | {{- define "chart.selectorLabels" -}} 50 | app.kubernetes.io/name: {{ include "chart.name" . }} 51 | app.kubernetes.io/instance: {{ .Release.Name }} 52 | {{- end -}} 53 | 54 | {{/* 55 | Kubernetes version 56 | Built-in object .Capabilities.KubeVersion.Minor can provide non-number output 57 | For example on GKE it returns "15+" instead of "15" 58 | */}} 59 | {{- define "chart.KubernetesVersion" -}} 60 | {{- $minorVersion := .Capabilities.KubeVersion.Minor | regexFind "[0-9]+" -}} 61 | {{- printf "%s.%s" .Capabilities.KubeVersion.Major $minorVersion -}} 62 | {{- end -}} 63 | 64 | {{/* 65 | Kubernetes resource name prefix 66 | Using the prefix allows deploying multiple instances of the Chart in a single Kubernetes namespace. 67 | If the dynamic resource name prefix is disabled, this template results in an empty string. 68 | As described in `chart.fullname`, the length limit for some resource names is 63. 69 | Therefore, we truncate the prefix to have a max-length of 44 to respect this limit considering the 70 | longest resource name ("dbmigrator-install" = 18 chars). 71 | */}} 72 | {{- define "chart.resourceNamePrefix" -}} 73 | {{- if .Values.dynamicResourceNamePrefixEnabled -}} 74 | {{- (include "chart.fullname" .) | trunc 43 | trimSuffix "-" | printf "%s-" -}} 75 | {{- end -}} 76 | {{- end -}} 77 | 78 | {{/* 79 | Provide an init container to verify the database is accessible 80 | */}} 81 | {{- define "chart.checkDbIsReadyInitContainer" -}} 82 | {{- $securityContext := default (dict) .Values.checkDbIsReadyInitContainer.securityContext }} 83 | name: check-db-ready 84 | {{ if .Values.postgresql.image.registry -}} 85 | image: {{ .Values.postgresql.image.registry }}/{{ .Values.postgresql.image.repository }}:{{ .Values.postgresql.image.tag }} 86 | {{- else }} 87 | image: {{ .Values.postgresql.image.repository }}:{{ .Values.postgresql.image.tag }} 88 | {{- end }} 89 | imagePullPolicy: {{ .Values.pullPolicy }} 90 | env: 91 | - name: PGHOST 92 | value: {{ default (printf "%s-postgresql.%s" .Release.Name .Release.Namespace) .Values.db.host }} 93 | - name: PGPORT 94 | value: "{{ .Values.db.port }}" 95 | - name: PGUSER 96 | value: "{{ .Values.db.user }}" 97 | {{- if $securityContext }} 98 | securityContext:{{- toYaml $securityContext | nindent 2 }} 99 | {{- else }} 100 | securityContext: {} 101 | {{- end }} 102 | command: ['sh', '-c', 'until pg_isready; do echo waiting for database; sleep 2; done;'] 103 | {{- end -}} 104 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all, clippy::pedantic)] 2 | #![allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)] 3 | 4 | use std::{net::SocketAddr, path::PathBuf, sync::Arc}; 5 | 6 | use anyhow::{Context, Result}; 7 | use clap::Parser; 8 | use deadpool_postgres::Runtime; 9 | use octocrab::Octocrab; 10 | use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode}; 11 | use postgres_openssl::MakeTlsConnector; 12 | use tokio::{net::TcpListener, signal}; 13 | use tokio_util::sync::CancellationToken; 14 | use tracing::{debug, info}; 15 | use tracing_subscriber::EnvFilter; 16 | 17 | use crate::{ 18 | cfg_svc::{Cfg, LogFormat}, 19 | db::PgDB, 20 | github::GHApi, 21 | }; 22 | 23 | mod cfg_repo; 24 | mod cfg_svc; 25 | mod cmd; 26 | mod db; 27 | mod github; 28 | mod handlers; 29 | mod processor; 30 | mod results; 31 | #[cfg(test)] 32 | mod testutil; 33 | mod tmpl; 34 | 35 | #[derive(Debug, Parser)] 36 | #[clap(author, version, about)] 37 | struct Args { 38 | /// Config file path 39 | #[clap(short, long)] 40 | config: PathBuf, 41 | } 42 | 43 | #[tokio::main] 44 | async fn main() -> Result<()> { 45 | let args = Args::parse(); 46 | 47 | // Setup configuration 48 | let cfg = Cfg::new(&args.config).context("error setting up configuration")?; 49 | 50 | // Setup logging 51 | let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("gitvote=debug")); 52 | let ts = tracing_subscriber::fmt().with_env_filter(env_filter); 53 | match cfg.log.format { 54 | LogFormat::Json => ts.json().init(), 55 | LogFormat::Pretty => ts.init(), 56 | } 57 | 58 | // Setup database 59 | let mut builder = SslConnector::builder(SslMethod::tls())?; 60 | builder.set_verify(SslVerifyMode::NONE); 61 | let connector = MakeTlsConnector::new(builder.build()); 62 | let pool = cfg.db.create_pool(Some(Runtime::Tokio1), connector)?; 63 | let db = Arc::new(PgDB::new(pool)); 64 | 65 | // Setup GitHub client 66 | let app_id = cfg.github.app_id as u64; 67 | let app_private_key = cfg.github.app_private_key.clone(); 68 | let app_private_key = jsonwebtoken::EncodingKey::from_rsa_pem(app_private_key.as_bytes())?; 69 | let app_client = Octocrab::builder().app(app_id.into(), app_private_key).build()?; 70 | let gh = Arc::new(GHApi::new(app_client)); 71 | 72 | // Setup and launch votes processor 73 | let (cmds_tx, cmds_rx) = async_channel::unbounded(); 74 | let cancel_token = CancellationToken::new(); 75 | let votes_processor = processor::Processor::new(db.clone(), gh.clone(), cmds_tx.clone(), cmds_rx); 76 | let votes_processor_tasks = votes_processor.run(&cancel_token); 77 | debug!("[votes processor] started"); 78 | 79 | // Setup and launch HTTP server 80 | let router = handlers::setup_router(&cfg, db, gh, cmds_tx); 81 | let addr: SocketAddr = cfg.addr.parse()?; 82 | let listener = TcpListener::bind(addr).await?; 83 | info!(%addr, "gitvote service started"); 84 | axum::serve(listener, router).with_graceful_shutdown(shutdown_signal()).await.unwrap(); 85 | 86 | // Ask votes processor to stop and wait for it to finish 87 | cancel_token.cancel(); 88 | votes_processor_tasks.await; 89 | debug!("[votes processor] stopped"); 90 | info!("gitvote service stopped"); 91 | 92 | Ok(()) 93 | } 94 | 95 | /// Return a future that will complete when the program is asked to stop via a 96 | /// ctrl+c or terminate signal. 97 | async fn shutdown_signal() { 98 | // Setup signal handlers 99 | let ctrl_c = async { 100 | signal::ctrl_c().await.expect("failed to install ctrl+c signal handler"); 101 | }; 102 | 103 | #[cfg(unix)] 104 | let terminate = async { 105 | signal::unix::signal(signal::unix::SignalKind::terminate()) 106 | .expect("failed to install terminate signal handler") 107 | .recv() 108 | .await; 109 | }; 110 | 111 | #[cfg(not(unix))] 112 | let terminate = std::future::pending::<()>(); 113 | 114 | // Wait for any of the signals 115 | tokio::select! { 116 | () = ctrl_c => {}, 117 | () = terminate => {}, 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | The GitVote project accepts contributions via [GitHub pull requests](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests). This document outlines the process to help get your contribution accepted. 4 | 5 | ## Issues and discussions 6 | 7 | Feature requests, bug reports, and support requests all occur through GitHub issues and discussions. If you would like to file an issue, view existing issues, or comment on an issue please engage with issues at . You can create new discussions, view existing ones and comment on them at . 8 | 9 | ## Pull Requests 10 | 11 | All changes to the source code and documentation are made through [GitHub pull requests](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests). If you would like to make a change to the source, documentation, or other component in the repository please open a pull request with the change. 12 | 13 | If you are unsure if the change will be welcome you may want to file an issue first. The issue can detail the change and you can get feedback from the maintainers prior to starting to make the change. 14 | 15 | You can find the existing pull requests at . 16 | 17 | ## Developer Certificate of Origin 18 | 19 | The GitVote project uses a [Developers Certificate of Origin (DCO)](https://developercertificate.org/) to sign-off that you have the right to contribute the code being contributed. The full text of the DCO reads: 20 | 21 | ```text 22 | Developer Certificate of Origin 23 | Version 1.1 24 | 25 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 26 | 1 Letterman Drive 27 | Suite D4700 28 | San Francisco, CA, 94129 29 | 30 | Everyone is permitted to copy and distribute verbatim copies of this 31 | license document, but changing it is not allowed. 32 | 33 | 34 | Developer's Certificate of Origin 1.1 35 | 36 | By making a contribution to this project, I certify that: 37 | 38 | (a) The contribution was created in whole or in part by me and I 39 | have the right to submit it under the open source license 40 | indicated in the file; or 41 | 42 | (b) The contribution is based upon previous work that, to the best 43 | of my knowledge, is covered under an appropriate open source 44 | license and I have the right under that license to submit that 45 | work with modifications, whether created in whole or in part 46 | by me, under the same open source license (unless I am 47 | permitted to submit under a different license), as indicated 48 | in the file; or 49 | 50 | (c) The contribution was provided directly to me by some other 51 | person who certified (a), (b) or (c) and I have not modified 52 | it. 53 | 54 | (d) I understand and agree that this project and the contribution 55 | are public and that a record of the contribution (including all 56 | personal information I submit with it, including my sign-off) is 57 | maintained indefinitely and may be redistributed consistent with 58 | this project or the open source license(s) involved. 59 | ``` 60 | 61 | Every commit needs to have signoff added to it with a message like: 62 | 63 | ```text 64 | Signed-off-by: Joe Smith 65 | ``` 66 | 67 | Git makes doing this fairly straight forward. First, please use your real name (sorry, no pseudonyms or anonymous contributions). 68 | 69 | If you set your `user.name` and `user.email` in your git configuration, you can sign your commit automatically with `git commit -s` or `git commit --signoff`. 70 | 71 | Signed commits in the git log will look something like: 72 | 73 | ```text 74 | Author: Joe Smith 75 | Date: Thu Feb 2 11:41:15 2018 -0800 76 | 77 | Update README 78 | 79 | Signed-off-by: Joe Smith 80 | ``` 81 | 82 | Notice how the `Author` and `Signed-off-by` lines match. If they do not match the PR will be rejected by the automated DCO check. 83 | 84 | If more than one person contributed to a commit than there can be more than one `Signed-off-by` line where each line is a signoff from a different person who contributed to the commit. 85 | -------------------------------------------------------------------------------- /templates/audit-vote-details.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |

Vote details

4 |
5 | 29 |
30 |
Created by
31 |
@{{ vote.created_by }}
32 |
33 |
34 |
Created
35 |
{{ vote.created_at }}
36 |
37 |
38 |
Closed
39 |
40 | {% if let Some(closed_at) = vote.closed_at %} 41 | {{ closed_at }} 42 | {% endif -%} 43 |
44 |
45 |
46 |
47 | 48 |
49 |

Results

50 |
51 |
52 | In favor: {{ "{:.0}"|format(results.in_favor_percentage) }}% 53 | {% if results.passed %} 54 | Passed 55 | {% else %} 56 | Failed 57 | {% endif %} 58 | Passing threshold: {{ results.pass_threshold }}% 59 |
60 | 67 |
68 |
69 |
70 |

Summary

71 |
72 |
73 |
In favor
74 |
{{ results.in_favor }}
75 |
76 |
77 |
Against
78 |
{{ results.against }}
79 |
80 |
81 |
Abstain
82 |
{{ results.abstain }}
83 |
84 |
85 |
Not voted
86 |
{{ results.not_voted }}
87 |
88 |
89 |
90 |
91 |

Binding votes

92 |
93 | 94 | 129 | 130 |
131 |
132 | 133 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitVote 2 | 3 | [![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/gitvote)](https://artifacthub.io/packages/helm/gitvote/gitvote) 4 | 5 | **GitVote** is a GitHub application that allows holding a vote on *issues* and *pull requests*. 6 | 7 | ## Usage 8 | 9 | The first step is to install the [GitVote GitHub application](https://github.com/apps/git-vote) in the organization or repositories you'd like. Alternatively, you can deploy your own instance of the GitVote service by using [the Helm chart provided](https://artifacthub.io/packages/helm/gitvote/gitvote) (some organizations may prefer this option for private repositories). 10 | 11 | Once the application has been installed we can proceed with its configuration. 12 | 13 | ### Configuration 14 | 15 | To create votes, you'll first need to add a [.gitvote.yml](https://github.com/cncf/gitvote/blob/main/docs/config/.gitvote.yml) configuration file. GitVote will look for it in the following locations (in order of precedence): 16 | 17 | - At the root of the repository where the vote was created 18 | - In the `.github` directory of the repository where the vote was created 19 | - At the root of the `.github` repository, for organization wide configuration 20 | 21 | > [!IMPORTANT] 22 | > 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. 23 | 24 | For more information about the configuration file format please see the [reference documentation](https://github.com/cncf/gitvote/blob/main/docs/config/.gitvote.yml). 25 | 26 | ### Creating votes 27 | 28 | Votes can be created by calling the `/vote` command on an *issue* or *pull request*. This can be done by: 29 | 30 | - adding the **/vote** command to the *issue* or *pull request* body at creation time 31 | - adding a new comment to an existing *issue* or *pull request* with the **/vote** command in it 32 | 33 | The command **must** be on a line by itself. Please note that GitVote only detects commands in issues or pull requests bodies when they are opened, or when comments are created, *not when any of them are edited*. 34 | 35 | ![create-vote](docs/screenshots/create-vote.png) 36 | 37 | Alternatively, if you have setup multiple configuration profiles, you can also start votes using any of them with the command `/vote-PROFILE`. 38 | 39 | > [!NOTE] 40 | > 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. 41 | 42 | 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. 43 | 44 | ![vote-created](docs/screenshots/vote-created.png) 45 | 46 | #### Automation 47 | 48 | GitVote allows votes to be created automatically on pull requests when any of the files affected matches certain predefined patterns. For more information about how to set it up, please see the automation section in the [reference documentation](https://github.com/cncf/gitvote/blob/main/docs/config/.gitvote.yml). 49 | 50 | ### Voting 51 | 52 | Users can cast their votes by reacting to the `git-vote` bot comment where the vote was created (screenshot above). 53 | 54 | It is possible to vote `in favor`, `against` or to `abstain`, and each of these options can be selected with the following reactions: 55 | 56 | | In favor | Against | Abstain | 57 | | :------: | :-----: | :-----: | 58 | | 👍 | 👎 | 👀 | 59 | 60 | Only votes from users with a binding vote as defined in the configuration file will be counted. 61 | 62 | > [!WARNING] 63 | > Voting multiple options is not allowed and those votes won't be counted. 64 | 65 | ### Checking votes 66 | 67 | It is possible to check the status of a vote in progress by calling the `/check-vote` command: 68 | 69 | ![vote-status](docs/screenshots/vote-status.png) 70 | 71 | > [!NOTE] 72 | > This command can only be called once a day per vote (additional calls will be ignored). 73 | 74 | ### Closing votes 75 | 76 | Once the vote time is up, the vote will be automatically closed and the results will be published in a new comment. 77 | 78 | ![vote-closed](docs/screenshots/vote-closed.png) 79 | 80 | ### Cancelling votes 81 | 82 | It is possible to cancel a vote in progress by calling the `/cancel-vote` command: 83 | 84 | ![vote-cancelled](docs/screenshots/vote-cancelled.png) 85 | 86 | ### Checks in pull requests 87 | 88 | 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. 89 | 90 | ![check-passed](docs/screenshots/check-passed.png) 91 | 92 | ### Announcements 93 | 94 | 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. 95 | 96 | Announcements can be configured per vote profile, by adding the `announcements` configuration section to the `.gitvote.yml` file. 97 | 98 | ```yaml 99 | announcements: 100 | discussions: 101 | category: announcements # Category slug (i.e. spaces are replaced by hyphens) 102 | ``` 103 | 104 | > [!NOTE] 105 | > 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. 106 | 107 | ### Audit 108 | 109 | The audit page publishes the history of votes and some participation stats for a repository. It is disabled by default; to enable it, add the `audit` section with `enabled: true` to your `.gitvote.yml` (see the reference configuration in [`docs/config/.gitvote.yml`](docs/config/.gitvote.yml) for more details). 110 | 111 | Once the audit feature is on, GitVote serves the page at `https://gitvote.dev/audit/OWNER/REPO`. The page is public, so anyone can review the voting log. 112 | 113 | > [!NOTE] 114 | > It can take up to 15 minutes after enabling the option for the audit page to become available. 115 | 116 | ## Adopters 117 | 118 | Please see [ADOPTERS.md](./ADOPTERS.md) for more details. 119 | 120 | ## Contributing 121 | 122 | Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for more details. 123 | 124 | ## Code of Conduct 125 | 126 | This project follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md). 127 | 128 | ## License 129 | 130 | GitVote is an Open Source project licensed under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). 131 | -------------------------------------------------------------------------------- /src/testutil.rs: -------------------------------------------------------------------------------- 1 | //! This modules defines some test utilities. 2 | 3 | use std::{collections::BTreeMap, fs, path::Path, sync::Arc, time::Duration}; 4 | 5 | use time::{OffsetDateTime, format_description::well_known::Rfc3339}; 6 | use uuid::Uuid; 7 | 8 | use crate::{ 9 | cfg_repo::{AllowedVoters, Announcements, CfgProfile, DiscussionsAnnouncements}, 10 | github::*, 11 | results::{UserVote, Vote, VoteOption, VoteResults, calculate}, 12 | }; 13 | 14 | pub(crate) const BRANCH: &str = "main"; 15 | pub(crate) const COMMENT_ID: i64 = 1234; 16 | pub(crate) const ERROR: &str = "fake error"; 17 | pub(crate) const INST_ID: u64 = 1234; 18 | pub(crate) const ISSUE_ID: i64 = 1234; 19 | pub(crate) const ISSUE_NUM: i64 = 1; 20 | pub(crate) const ORG: &str = "org"; 21 | pub(crate) const OWNER: &str = "owner"; 22 | pub(crate) const OWNER_IS_ORG: bool = true; 23 | pub(crate) const PROFILE_NAME: &str = "profile1"; 24 | pub(crate) const REPO: &str = "repo"; 25 | pub(crate) const REPOFN: &str = "org/repo"; 26 | pub(crate) const TESTDATA_PATH: &str = "src/testdata"; 27 | pub(crate) const TITLE: &str = "Test title"; 28 | pub(crate) const DISCUSSIONS_CATEGORY: &str = "announcements"; 29 | pub(crate) const USER: &str = "user"; 30 | pub(crate) const USER1: &str = "user1"; 31 | pub(crate) const USER2: &str = "user2"; 32 | pub(crate) const USER3: &str = "user3"; 33 | pub(crate) const USER4: &str = "user4"; 34 | pub(crate) const USER5: &str = "user5"; 35 | pub(crate) const TEAM1: &str = "team1"; 36 | pub(crate) const VOTE_ID: &str = "00000000-0000-0000-0000-000000000001"; 37 | pub(crate) const TIMESTAMP: &str = "2022-11-30T10:00:00Z"; 38 | 39 | pub(crate) fn get_test_invalid_config() -> String { 40 | fs::read_to_string(Path::new(TESTDATA_PATH).join("config-invalid.yml")).unwrap() 41 | } 42 | 43 | pub(crate) fn get_test_valid_config() -> String { 44 | fs::read_to_string(Path::new(TESTDATA_PATH).join("config.yml")).unwrap() 45 | } 46 | 47 | pub(crate) fn setup_test_issue_event() -> IssueEvent { 48 | IssueEvent { 49 | action: IssueEventAction::Other, 50 | installation: Installation { id: INST_ID as i64 }, 51 | issue: Issue { 52 | id: ISSUE_ID, 53 | number: ISSUE_NUM, 54 | title: TITLE.to_string(), 55 | body: None, 56 | pull_request: None, 57 | }, 58 | repository: Repository { 59 | full_name: REPOFN.to_string(), 60 | }, 61 | organization: Some(Organization { 62 | login: ORG.to_string(), 63 | }), 64 | sender: User { 65 | login: USER.to_string(), 66 | }, 67 | } 68 | } 69 | 70 | pub(crate) fn setup_test_issue_comment_event() -> IssueCommentEvent { 71 | IssueCommentEvent { 72 | action: IssueCommentEventAction::Other, 73 | comment: Comment { 74 | id: COMMENT_ID, 75 | body: None, 76 | }, 77 | installation: Installation { id: INST_ID as i64 }, 78 | issue: Issue { 79 | id: ISSUE_ID, 80 | number: ISSUE_NUM, 81 | title: TITLE.to_string(), 82 | body: None, 83 | pull_request: None, 84 | }, 85 | repository: Repository { 86 | full_name: REPOFN.to_string(), 87 | }, 88 | organization: Some(Organization { 89 | login: ORG.to_string(), 90 | }), 91 | sender: User { 92 | login: USER.to_string(), 93 | }, 94 | } 95 | } 96 | 97 | pub(crate) fn setup_test_pr_event() -> PullRequestEvent { 98 | PullRequestEvent { 99 | action: PullRequestEventAction::Other, 100 | installation: Installation { id: INST_ID as i64 }, 101 | pull_request: PullRequest { 102 | id: ISSUE_ID, 103 | number: ISSUE_NUM, 104 | title: TITLE.to_string(), 105 | body: None, 106 | base: PullRequestBase { 107 | reference: BRANCH.to_string(), 108 | }, 109 | }, 110 | repository: Repository { 111 | full_name: REPOFN.to_string(), 112 | }, 113 | organization: Some(Organization { 114 | login: ORG.to_string(), 115 | }), 116 | sender: User { 117 | login: USER.to_string(), 118 | }, 119 | } 120 | } 121 | 122 | pub(crate) fn setup_test_vote() -> Vote { 123 | Vote { 124 | vote_id: Uuid::parse_str(VOTE_ID).unwrap(), 125 | vote_comment_id: COMMENT_ID, 126 | created_at: OffsetDateTime::now_utc(), 127 | created_by: USER.to_string(), 128 | ends_at: OffsetDateTime::now_utc(), 129 | closed: false, 130 | closed_at: None, 131 | checked_at: None, 132 | cfg: CfgProfile { 133 | duration: Duration::from_secs(300), 134 | pass_threshold: 50.0, 135 | allowed_voters: Some(AllowedVoters { 136 | users: Some(vec![USER1.to_string()]), 137 | ..Default::default() 138 | }), 139 | announcements: Some(Announcements { 140 | discussions: Some(DiscussionsAnnouncements { 141 | category: DISCUSSIONS_CATEGORY.to_string(), 142 | }), 143 | }), 144 | ..Default::default() 145 | }, 146 | installation_id: INST_ID as i64, 147 | issue_id: ISSUE_ID, 148 | issue_number: ISSUE_NUM, 149 | issue_title: Some(TITLE.to_string()), 150 | is_pull_request: false, 151 | repository_full_name: REPOFN.to_string(), 152 | organization: Some(ORG.to_string()), 153 | results: None, 154 | } 155 | } 156 | 157 | pub(crate) fn setup_test_vote_results() -> VoteResults { 158 | VoteResults { 159 | passed: true, 160 | in_favor_percentage: 100.0, 161 | pass_threshold: 50.0, 162 | in_favor: 1, 163 | against: 0, 164 | against_percentage: 0.0, 165 | abstain: 0, 166 | not_voted: 0, 167 | binding: 1, 168 | non_binding: 0, 169 | votes: BTreeMap::from([( 170 | USER1.to_string(), 171 | UserVote { 172 | vote_option: VoteOption::InFavor, 173 | timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), 174 | binding: true, 175 | }, 176 | )]), 177 | allowed_voters: 1, 178 | pending_voters: vec![], 179 | } 180 | } 181 | 182 | pub(crate) fn setup_test_vote_with_calculated_results( 183 | created_at: &str, 184 | allowed_voters: Vec, 185 | reactions: Vec, 186 | ) -> Vote { 187 | // Setup GitHub mock 188 | let mut gh = MockGH::new(); 189 | gh.expect_get_comment_reactions().return_once(move |_, _, _, _| { 190 | Box::pin(async move { Ok::, anyhow::Error>(reactions) }) 191 | }); 192 | gh.expect_get_allowed_voters().return_once(move |_, _, _, _, _| { 193 | Box::pin(async move { Ok::, anyhow::Error>(allowed_voters) }) 194 | }); 195 | 196 | // Setup vote 197 | let mut vote = setup_test_vote(); 198 | let created_at_ts = OffsetDateTime::parse(created_at, &Rfc3339).expect("valid created_at timestamp"); 199 | vote.created_at = created_at_ts; 200 | 201 | // Calculate results and attach to vote 202 | let runtime = tokio::runtime::Runtime::new().expect("runtime creation should succeed"); 203 | let results = runtime 204 | .block_on(calculate(Arc::new(gh), OWNER, REPO, &vote)) 205 | .expect("vote calculation should succeed"); 206 | vote.results = Some(results); 207 | 208 | vote 209 | } 210 | -------------------------------------------------------------------------------- /docs/config/.gitvote.yml: -------------------------------------------------------------------------------- 1 | # GitVote configuration file 2 | # 3 | # GitVote will look for it in the following locations (in order of precedence): 4 | # 5 | # - At the root of the repository where the vote was created 6 | # - At the root of the .github repository, for organization wide configuration 7 | # 8 | 9 | # Audit (optional) 10 | # 11 | # The audit page lists all the votes recorded for the repository. It can be 12 | # useful to provide transparency on the voting history of a repository. By 13 | # default, the audit page is disabled. To enable it, the audit section must be 14 | # included in the configuration file, with the `enabled` field set to true. 15 | # 16 | # Please note that this page is publicly accessible, so anyone will be able 17 | # to see all votes recorded for the repository. 18 | # 19 | # Once you have enabled it, the audit page will be available at: 20 | # 21 | # https://gitvote.com/audit/OWNER/REPO 22 | # 23 | # (it can take up to 15 minutes for the audit page to be available after 24 | # enabling it) 25 | # 26 | # audit: 27 | # enabled: true 28 | # 29 | audit: 30 | enabled: false 31 | 32 | # Automation (optional) 33 | # 34 | # Create votes automatically on PRs when any of the files affected by the PR 35 | # match any of the patterns provided. Patterns must follow the gitignore 36 | # format (https://git-scm.com/docs/gitignore#_pattern_format). 37 | # 38 | # Each automation rule must include a list of patterns and the profile to use 39 | # when creating the vote. This allows creating votes automatically using the 40 | # desired configuration based on the patterns matched. Rules are processed in 41 | # the order provided, and the first match wins. 42 | # 43 | # automation: 44 | # enabled: true 45 | # rules: 46 | # - patterns: 47 | # - "README.md" 48 | # - "*.txt" 49 | # profile: default 50 | # 51 | automation: 52 | enabled: false 53 | rules: 54 | - patterns: [] 55 | profile: profile1 56 | 57 | # Configuration profiles (required) 58 | # 59 | # A configuration profile defines some properties of a vote, like its duration, 60 | # the pass threshold or the users who have a binding vote. It's possible to 61 | # define multiple configuration profiles, each with a different set of settings. 62 | # 63 | profiles: 64 | # Default configuration profile 65 | # 66 | # This profile will be used with votes created with the /vote command 67 | default: 68 | # Voting duration (required) 69 | # 70 | # How long the vote will be open 71 | # 72 | # Units supported (can be combined as in 1hour 30mins): 73 | # 74 | # minutes | minute | mins | min | m 75 | # hours | hour | hrs | hrs | h 76 | # days | day | d 77 | # weeks | week | w 78 | # 79 | duration: 5m 80 | 81 | # Pass threshold (required) 82 | # 83 | # Percentage of votes in favor required to pass the vote 84 | # 85 | # The percentage is calculated based on the number of votes in favor and the 86 | # number of allowed voters (see allowed_voters field below for more details). 87 | pass_threshold: 50 88 | 89 | # Allowed voters (optional) 90 | # 91 | # List of GitHub teams and users who have binding votes 92 | # 93 | # If no teams or users are provided, all repository collaborators will be 94 | # allowed to vote. For organization-owned repositories, the list of 95 | # collaborators includes outside collaborators, organization members that 96 | # are direct collaborators, organization members with access through team 97 | # memberships, organization members with access through default organization 98 | # permissions, and organization owners. 99 | # 100 | # By default, teams' members with the maintainer role are allowed to vote 101 | # as well. By using the `exclude_team_maintainers` option, it's possible to 102 | # modify this behavior so that only teams' members with the member role are 103 | # considered allowed voters. Please note that this option only applies to 104 | # the teams explicitly listed in `allowed_voters/teams`. 105 | # 106 | # Teams names must be provided without the organization prefix. 107 | # 108 | # allowed_voters: 109 | # teams: 110 | # - team1 111 | # users: 112 | # - cynthia-sg 113 | # - tegioz 114 | # exclude_team_maintainers: false 115 | # 116 | allowed_voters: 117 | teams: [] 118 | users: [] 119 | 120 | # Periodic status check 121 | #  122 | # GitVote allows checking the status of a vote in progress manually by 123 | # calling the /check-vote command. The periodic status check option makes 124 | # it possible to automate the execution of status checks periodically. The 125 | # vote status will be published to the corresponding issue or pull request, 126 | # the same way as if the /check-vote command would have been called 127 | # manually. 128 | # 129 | # When this option is enabled, while the vote is open, a status check will 130 | # be run automatically using the frequency configured. Please note that the 131 | # hard limit of one status check per day still applies, so if the command 132 | # has been called manually the automatic periodic run may be delayed. 133 | # Automatic status checks won't be run if the vote will be closed within 134 | # the next hour. 135 | # 136 | # Units supported: 137 | # 138 | # - day / days 139 | # - week / weeks 140 | # 141 | # As an example, using a value of "5 days" would mean that 5 days after the 142 | # vote was created, and every 5 days after that, an automatic status check 143 | # will be run. 144 | # 145 | # periodic_status_check: "5 days" 146 | # 147 | periodic_status_check: null 148 | 149 | # Close on passing 150 | #  151 | # By default, votes remain open for the configured duration. Sometimes, 152 | # specially on votes that stay open for a long time, it may be preferable 153 | # to close a vote automatically once the passing threshold has been met. 154 | # The close on passing feature makes this possible. Open votes where this 155 | # feature has been enabled will be checked once daily and, if GitVote 156 | # detects that the vote has passed, it will automatically close it. 157 | #  158 | # close_on_passing: true 159 | # 160 | close_on_passing: false 161 | 162 | # Close on passing minimum wait 163 | #  164 | # When the close on passing feature is activated, voting will conclude once 165 | # the pass threshold is met. However, there may be instances where it is 166 | # preferable to implement a minimum wait time, even if the vote would 167 | # already pass. This allows participants sufficient opportunity to engage 168 | # and reflect before the vote is automatically finalized. 169 | # 170 | # Units supported: 171 | # 172 | # - day / days 173 | # - week / weeks  174 | # 175 | # close_on_passing_min_wait: "1 week" 176 | # 177 | close_on_passing_min_wait: null 178 | 179 | # Announcements 180 | # 181 | # GitVote can announce the results of a vote when it is closed on GitHub 182 | # discussions. This feature won't be enabled if this configuration section 183 | # is not provided. The slug of the category where the announcement will be 184 | # posted to must be specified (i.e. announcements). 185 | # 186 | # announcements: 187 | # discussions: 188 | # category: announcements 189 | # 190 | announcements: 191 | discussions: 192 | category: announcements 193 | 194 | # Additional configuration profiles 195 | # 196 | # In addition to the default configuration profile, it is possible to add more 197 | # to easily create votes with different settings. To create a vote that uses a 198 | # different profile you can use the command /vote-PROFILE. In the case below, 199 | # the command would be /vote-profile1 200 | # 201 | # Please note that each profile must contain all required fields. The default 202 | # profile is used when using the /vote command, but its values are not used as 203 | # default values when they are not provided on other profiles. 204 | # 205 | profile1: 206 | duration: 1m 207 | pass_threshold: 75 208 | allowed_voters: 209 | teams: 210 | - team1 211 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /charts/gitvote/LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | gitvote 9 | 98 | 99 | 100 |
101 |
102 |
103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 |
131 | 132 |
133 | is a GitHub application that allows holding 134 |
135 | a vote on issues and pull requests 136 |
137 | 138 | 139 |
140 |
141 | 142 | 143 | 144 |
145 | GitHub 146 |
147 |
148 |
149 |
150 | 151 | 152 | -------------------------------------------------------------------------------- /src/cfg_repo.rs: -------------------------------------------------------------------------------- 1 | //! This module defines some types and functionality to represent and process 2 | //! the `GitVote` configuration that GitHub repositories can use to enable and 3 | //! customize the service. 4 | 5 | use std::{collections::HashMap, time::Duration}; 6 | 7 | use anyhow::{Result, bail}; 8 | use ignore::gitignore::GitignoreBuilder; 9 | use serde::{Deserialize, Serialize}; 10 | use thiserror::Error; 11 | 12 | use crate::github::{DynGH, File, TeamSlug, UserName}; 13 | 14 | /// Default configuration profile. 15 | const DEFAULT_PROFILE: &str = "default"; 16 | 17 | /// Error message used when teams are listed in the allowed voters section on a 18 | /// repository that does not belong to an organization. 19 | const ERR_TEAMS_NOT_ALLOWED: &str = "teams in allowed voters can only be used in organizations"; 20 | 21 | /// Type alias to represent a profile name. 22 | type ProfileName = String; 23 | 24 | /// `GitVote` configuration. 25 | #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] 26 | pub(crate) struct Cfg { 27 | pub profiles: HashMap, 28 | 29 | #[serde(skip_serializing_if = "Option::is_none")] 30 | pub audit: Option, 31 | #[serde(skip_serializing_if = "Option::is_none")] 32 | pub automation: Option, 33 | } 34 | 35 | impl Cfg { 36 | /// Get the `GitVote` configuration for the repository provided. 37 | pub(crate) async fn get<'a>( 38 | gh: DynGH, 39 | inst_id: u64, 40 | owner: &'a str, 41 | repo: &'a str, 42 | ) -> Result { 43 | match gh.get_config_file(inst_id, owner, repo).await { 44 | Some(content) => { 45 | let cfg: Cfg = 46 | serde_yaml::from_str(&content).map_err(|e| CfgError::InvalidConfig(e.to_string()))?; 47 | Ok(cfg) 48 | } 49 | None => Err(CfgError::ConfigNotFound), 50 | } 51 | } 52 | } 53 | 54 | /// Audit configuration. 55 | #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] 56 | pub(crate) struct Audit { 57 | pub enabled: bool, 58 | } 59 | 60 | /// Automation configuration. 61 | #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] 62 | pub(crate) struct Automation { 63 | pub enabled: bool, 64 | pub rules: Vec, 65 | } 66 | 67 | /// Automation rule. 68 | #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] 69 | pub(crate) struct AutomationRule { 70 | pub patterns: Vec, 71 | pub profile: ProfileName, 72 | } 73 | 74 | impl AutomationRule { 75 | /// Check if any of the files provided matches any of the rule patterns. 76 | /// Patterns must follow the gitignore format. 77 | pub(crate) fn matches(&self, files: &[File]) -> Result { 78 | let mut builder = GitignoreBuilder::new("/"); 79 | for pattern in &self.patterns { 80 | builder.add_line(None, pattern)?; 81 | } 82 | let checker = builder.build()?; 83 | let matches = files.iter().any(|file| checker.matched(&file.filename, false).is_ignore()); 84 | Ok(matches) 85 | } 86 | } 87 | 88 | /// Vote configuration profile. 89 | #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] 90 | pub(crate) struct CfgProfile { 91 | #[serde(with = "humantime_serde")] 92 | pub duration: Duration, 93 | pub pass_threshold: f64, 94 | #[serde(skip_serializing_if = "Option::is_none")] 95 | pub allowed_voters: Option, 96 | #[serde(skip_serializing_if = "Option::is_none")] 97 | pub announcements: Option, 98 | #[serde(skip_serializing_if = "Option::is_none")] 99 | pub periodic_status_check: Option, 100 | #[serde(skip_serializing_if = "Option::is_none")] 101 | pub close_on_passing: Option, 102 | #[serde(skip_serializing_if = "Option::is_none")] 103 | pub close_on_passing_min_wait: Option, 104 | } 105 | 106 | impl CfgProfile { 107 | /// Get the vote configuration profile requested from the config file in 108 | /// the repository if available. 109 | pub(crate) async fn get<'a>( 110 | gh: DynGH, 111 | inst_id: u64, 112 | owner: &'a str, 113 | is_org: bool, 114 | repo: &'a str, 115 | profile_name: Option, 116 | ) -> Result { 117 | let mut cfg = Cfg::get(gh, inst_id, owner, repo).await?; 118 | let profile_name = profile_name.unwrap_or_else(|| DEFAULT_PROFILE.to_string()); 119 | match cfg.profiles.remove(&profile_name) { 120 | Some(profile) => match profile.validate(is_org) { 121 | Ok(()) => Ok(profile), 122 | Err(err) => Err(CfgError::InvalidConfig(err.to_string())), 123 | }, 124 | None => Err(CfgError::ProfileNotFound), 125 | } 126 | } 127 | 128 | /// Check if the configuration profile is valid. 129 | fn validate(&self, is_org: bool) -> Result<()> { 130 | // Only repositories that belong to some organization can use teams in 131 | // the allowed voters configuration section. 132 | if !is_org 133 | && let Some(teams) = 134 | self.allowed_voters.as_ref().and_then(|allowed_voters| allowed_voters.teams.as_ref()) 135 | && !teams.is_empty() 136 | { 137 | bail!(ERR_TEAMS_NOT_ALLOWED); 138 | } 139 | 140 | Ok(()) 141 | } 142 | } 143 | 144 | /// Represents the teams and users allowed to vote. 145 | #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] 146 | pub(crate) struct AllowedVoters { 147 | #[serde(skip_serializing_if = "Option::is_none")] 148 | pub teams: Option>, 149 | #[serde(skip_serializing_if = "Option::is_none")] 150 | pub users: Option>, 151 | #[serde(skip_serializing_if = "Option::is_none")] 152 | pub exclude_team_maintainers: Option, 153 | } 154 | 155 | /// Announcements configuration. 156 | #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] 157 | pub(crate) struct Announcements { 158 | #[serde(skip_serializing_if = "Option::is_none")] 159 | pub discussions: Option, 160 | } 161 | 162 | /// GitHub discussions announcements configuration. 163 | #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] 164 | pub(crate) struct DiscussionsAnnouncements { 165 | pub category: String, 166 | } 167 | 168 | /// Errors that may occur while getting the configuration profile. 169 | #[derive(Debug, Error, Clone, PartialEq, Serialize, Deserialize)] 170 | pub(crate) enum CfgError { 171 | #[error("config not found")] 172 | ConfigNotFound, 173 | #[error("invalid config: {0}")] 174 | InvalidConfig(String), 175 | #[error("profile not found")] 176 | ProfileNotFound, 177 | } 178 | 179 | #[cfg(test)] 180 | mod tests { 181 | use std::sync::Arc; 182 | 183 | use futures::future; 184 | use mockall::predicate::eq; 185 | 186 | use crate::github::MockGH; 187 | use crate::testutil::*; 188 | 189 | use super::*; 190 | 191 | #[test] 192 | fn automation_rule_matches() { 193 | let rule = AutomationRule { 194 | patterns: vec!["*.md".to_string(), "file.txt".to_string()], 195 | profile: "default".to_string(), 196 | }; 197 | assert!( 198 | rule.matches(&[File { 199 | filename: "README.md".to_string() 200 | }]) 201 | .unwrap() 202 | ); 203 | assert!( 204 | rule.matches(&[File { 205 | filename: "path/file.txt".to_string() 206 | }]) 207 | .unwrap() 208 | ); 209 | } 210 | 211 | #[test] 212 | fn automation_rule_does_not_match() { 213 | let rule = AutomationRule { 214 | patterns: vec!["path/image.svg".to_string()], 215 | profile: "default".to_string(), 216 | }; 217 | assert!( 218 | !rule 219 | .matches(&[File { 220 | filename: "README.md".to_string() 221 | }]) 222 | .unwrap() 223 | ); 224 | assert!( 225 | !rule 226 | .matches(&[File { 227 | filename: "image.svg".to_string() 228 | }]) 229 | .unwrap() 230 | ); 231 | } 232 | 233 | #[tokio::test] 234 | async fn get_cfg_profile_config_not_found() { 235 | let mut gh = MockGH::new(); 236 | gh.expect_get_config_file() 237 | .with(eq(INST_ID), eq(OWNER), eq(REPO)) 238 | .times(1) 239 | .returning(|_, _, _| Box::pin(future::ready(None))); 240 | let gh = Arc::new(gh); 241 | 242 | assert_eq!( 243 | CfgProfile::get(gh, INST_ID, OWNER, OWNER_IS_ORG, REPO, None).await.unwrap_err(), 244 | CfgError::ConfigNotFound 245 | ); 246 | } 247 | 248 | #[tokio::test] 249 | async fn get_cfg_profile_invalid_config_invalid_yaml() { 250 | let mut gh = MockGH::new(); 251 | gh.expect_get_config_file() 252 | .with(eq(INST_ID), eq(OWNER), eq(REPO)) 253 | .times(1) 254 | .returning(|_, _, _| Box::pin(future::ready(Some(get_test_invalid_config())))); 255 | let gh = Arc::new(gh); 256 | 257 | assert!(matches!( 258 | CfgProfile::get( 259 | gh, 260 | INST_ID, 261 | OWNER, 262 | OWNER_IS_ORG, 263 | REPO, 264 | Some(PROFILE_NAME.to_string()) 265 | ) 266 | .await 267 | .unwrap_err(), 268 | CfgError::InvalidConfig(_) 269 | )); 270 | } 271 | 272 | #[tokio::test] 273 | async fn get_cfg_profile_invalid_config_teams_owner_not_org() { 274 | let mut gh = MockGH::new(); 275 | gh.expect_get_config_file() 276 | .with(eq(INST_ID), eq(OWNER), eq(REPO)) 277 | .times(1) 278 | .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); 279 | let gh = Arc::new(gh); 280 | 281 | assert_eq!( 282 | CfgProfile::get( 283 | gh, 284 | INST_ID, 285 | OWNER, 286 | !OWNER_IS_ORG, 287 | REPO, 288 | Some(PROFILE_NAME.to_string()) 289 | ) 290 | .await 291 | .unwrap_err(), 292 | CfgError::InvalidConfig(ERR_TEAMS_NOT_ALLOWED.to_string()) 293 | ); 294 | } 295 | 296 | #[tokio::test] 297 | async fn get_cfg_profile_profile_not_found() { 298 | let mut gh = MockGH::new(); 299 | gh.expect_get_config_file() 300 | .with(eq(INST_ID), eq(OWNER), eq(REPO)) 301 | .times(1) 302 | .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); 303 | let gh = Arc::new(gh); 304 | 305 | assert_eq!( 306 | CfgProfile::get( 307 | gh, 308 | INST_ID, 309 | OWNER, 310 | OWNER_IS_ORG, 311 | REPO, 312 | Some("profile9".to_string()) 313 | ) 314 | .await 315 | .unwrap_err(), 316 | CfgError::ProfileNotFound 317 | ); 318 | } 319 | 320 | #[tokio::test] 321 | async fn get_cfg_profile_default() { 322 | let mut gh = MockGH::new(); 323 | gh.expect_get_config_file() 324 | .with(eq(INST_ID), eq(OWNER), eq(REPO)) 325 | .times(1) 326 | .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); 327 | let gh = Arc::new(gh); 328 | 329 | assert_eq!( 330 | CfgProfile::get(gh, INST_ID, OWNER, OWNER_IS_ORG, REPO, None).await.unwrap(), 331 | CfgProfile { 332 | duration: Duration::from_secs(300), 333 | pass_threshold: 50.0, 334 | allowed_voters: Some(AllowedVoters::default()), 335 | ..Default::default() 336 | } 337 | ); 338 | } 339 | 340 | #[tokio::test] 341 | async fn get_cfg_profile_profile1() { 342 | let mut gh = MockGH::new(); 343 | gh.expect_get_config_file() 344 | .with(eq(INST_ID), eq(OWNER), eq(REPO)) 345 | .times(1) 346 | .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); 347 | let gh = Arc::new(gh); 348 | 349 | assert_eq!( 350 | CfgProfile::get( 351 | gh, 352 | INST_ID, 353 | OWNER, 354 | OWNER_IS_ORG, 355 | REPO, 356 | Some(PROFILE_NAME.to_string()) 357 | ) 358 | .await 359 | .unwrap(), 360 | CfgProfile { 361 | duration: Duration::from_secs(600), 362 | pass_threshold: 75.0, 363 | allowed_voters: Some(AllowedVoters { 364 | teams: Some(vec![TEAM1.to_string()]), 365 | users: Some(vec![USER1.to_string(), USER2.to_string()]), 366 | ..Default::default() 367 | }), 368 | ..Default::default() 369 | } 370 | ); 371 | } 372 | } 373 | -------------------------------------------------------------------------------- /src/db.rs: -------------------------------------------------------------------------------- 1 | //! This module defines an abstraction layer over the database. 2 | 3 | use std::sync::Arc; 4 | 5 | use anyhow::Result; 6 | use async_trait::async_trait; 7 | use deadpool_postgres::{Pool, Transaction}; 8 | #[cfg(test)] 9 | use mockall::automock; 10 | use tokio_postgres::types::Json; 11 | use uuid::Uuid; 12 | 13 | use crate::{ 14 | cfg_repo::CfgProfile, 15 | cmd::{CheckVoteInput, CreateVoteInput}, 16 | github::{self, DynGH, split_full_name}, 17 | results::{self, Vote, VoteResults}, 18 | }; 19 | 20 | /// Type alias to represent a DB trait object. 21 | pub(crate) type DynDB = Arc; 22 | 23 | /// Trait that defines some operations a DB implementation must support. 24 | #[async_trait] 25 | #[cfg_attr(test, automock)] 26 | pub(crate) trait DB { 27 | /// Cancel open vote (if exists) in the issue/pr provided. 28 | async fn cancel_vote(&self, repository_full_name: &str, issue_number: i64) -> Result>; 29 | 30 | /// Close any pending finished vote. 31 | async fn close_finished_vote(&self, gh: DynGH) -> Result)>>; 32 | 33 | /// Get open vote (if available) in the issue/pr provided. 34 | async fn get_open_vote(&self, repository_full_name: &str, issue_number: i64) -> Result>; 35 | 36 | /// Get open votes that have close on passing enabled. 37 | async fn get_open_votes_with_close_on_passing(&self) -> Result>; 38 | 39 | /// Get pending status checks. 40 | async fn get_pending_status_checks(&self) -> Result>; 41 | 42 | /// Get vote by id. 43 | async fn get_vote(&self, vote_id: Uuid) -> Result>; 44 | 45 | /// Check if the issue/pr provided has a vote. 46 | async fn has_vote(&self, repository_full_name: &str, issue_number: i64) -> Result; 47 | 48 | /// Check if the issue/pr provided already has a vote open. 49 | async fn has_vote_open(&self, repository_full_name: &str, issue_number: i64) -> Result; 50 | 51 | /// List all votes stored for the provided repository. 52 | async fn list_votes(&self, repository_full_name: &str) -> Result>; 53 | 54 | /// Store the vote provided in the database. 55 | async fn store_vote( 56 | &self, 57 | vote_comment_id: i64, 58 | input: &CreateVoteInput, 59 | cfg: &CfgProfile, 60 | ) -> Result; 61 | 62 | /// Update vote's ending timestamp. 63 | async fn update_vote_ends_at(&self, vote_id: Uuid) -> Result<()>; 64 | 65 | /// Update vote's last check ts. 66 | async fn update_vote_last_check(&self, vote_id: Uuid) -> Result<()>; 67 | } 68 | 69 | /// DB implementation backed by `PostgreSQL`. 70 | pub(crate) struct PgDB { 71 | pool: Pool, 72 | } 73 | 74 | impl PgDB { 75 | /// Create a new `PgDB` instance. 76 | pub(crate) fn new(pool: Pool) -> Self { 77 | Self { pool } 78 | } 79 | 80 | /// Get any pending finished vote. 81 | async fn get_pending_finished_vote(tx: &Transaction<'_>) -> Result> { 82 | let vote = tx 83 | .query_opt( 84 | " 85 | select * 86 | from vote 87 | where current_timestamp > ends_at 88 | and closed = false 89 | order by random() 90 | for update of vote skip locked 91 | limit 1 92 | ", 93 | &[], 94 | ) 95 | .await? 96 | .map(|row| Vote::from(&row)); 97 | Ok(vote) 98 | } 99 | 100 | /// Store the vote results provided in the database. 101 | async fn store_vote_results( 102 | tx: &Transaction<'_>, 103 | vote_id: Uuid, 104 | results: Option<&VoteResults>, 105 | ) -> Result<()> { 106 | tx.execute( 107 | " 108 | update vote set 109 | closed = true, 110 | closed_at = current_timestamp, 111 | results = $1::jsonb 112 | where vote_id = $2::uuid; 113 | ", 114 | &[&Json(&results), &vote_id], 115 | ) 116 | .await?; 117 | Ok(()) 118 | } 119 | } 120 | 121 | #[async_trait] 122 | impl DB for PgDB { 123 | /// [`DB::cancel_vote`] 124 | async fn cancel_vote(&self, repository_full_name: &str, issue_number: i64) -> Result> { 125 | let db = self.pool.get().await?; 126 | let cancelled_vote_id = db 127 | .query_opt( 128 | " 129 | delete from vote 130 | where repository_full_name = $1::text 131 | and issue_number = $2::bigint 132 | and closed = false 133 | returning vote_id 134 | ", 135 | &[&repository_full_name, &issue_number], 136 | ) 137 | .await? 138 | .and_then(|row| row.get("vote_id")); 139 | Ok(cancelled_vote_id) 140 | } 141 | 142 | /// [`DB::close_finished_vote`] 143 | async fn close_finished_vote(&self, gh: DynGH) -> Result)>> { 144 | // Get pending finished vote (if any) from database 145 | let mut db = self.pool.get().await?; 146 | let tx = db.transaction().await?; 147 | let Some(vote) = PgDB::get_pending_finished_vote(&tx).await? else { 148 | return Ok(None); 149 | }; 150 | 151 | // Calculate results 152 | let (owner, repo) = split_full_name(&vote.repository_full_name); 153 | let results = match results::calculate(gh, owner, repo, &vote).await { 154 | Ok(results) => Some(results), 155 | Err(err) => { 156 | if github::is_not_found_error(&err) { 157 | // Vote comment was deleted. We still want to proceed and 158 | // close the vote so that we don't try again to close it. 159 | None 160 | } else { 161 | return Err(err); 162 | } 163 | } 164 | }; 165 | 166 | // Store results in database 167 | PgDB::store_vote_results(&tx, vote.vote_id, results.as_ref()).await?; 168 | tx.commit().await?; 169 | 170 | Ok(Some((vote, results))) 171 | } 172 | 173 | /// [`DB::get_open_vote`] 174 | async fn get_open_vote(&self, repository_full_name: &str, issue_number: i64) -> Result> { 175 | let db = self.pool.get().await?; 176 | let vote = db 177 | .query_opt( 178 | " 179 | select * 180 | from vote 181 | where repository_full_name = $1::text 182 | and issue_number = $2::bigint 183 | and closed = false 184 | ", 185 | &[&repository_full_name, &issue_number], 186 | ) 187 | .await? 188 | .map(|row| Vote::from(&row)); 189 | Ok(vote) 190 | } 191 | 192 | /// [`DB::get_open_votes_with_close_on_passing`] 193 | async fn get_open_votes_with_close_on_passing(&self) -> Result> { 194 | let db = self.pool.get().await?; 195 | let votes = db 196 | .query( 197 | " 198 | select * 199 | from vote 200 | where closed = false 201 | and cfg ? 'close_on_passing' 202 | and (cfg->>'close_on_passing')::boolean = true 203 | and 204 | case 205 | when cfg ? 'close_on_passing_min_wait' and string_to_interval(cfg->>'close_on_passing_min_wait') is not null then 206 | current_timestamp > created_at + (cfg->>'close_on_passing_min_wait')::interval 207 | else true 208 | end 209 | ", 210 | &[], 211 | ) 212 | .await? 213 | .iter() 214 | .map(Vote::from) 215 | .collect(); 216 | Ok(votes) 217 | } 218 | 219 | /// [`DB::get_pending_status_checks`] 220 | async fn get_pending_status_checks(&self) -> Result> { 221 | let db = self.pool.get().await?; 222 | let inputs = db 223 | .query( 224 | " 225 | select repository_full_name, issue_number 226 | from vote 227 | where closed = false 228 | and cfg ? 'periodic_status_check' 229 | and string_to_interval(cfg->>'periodic_status_check') is not null 230 | and (cfg->>'periodic_status_check')::interval >= '1 day'::interval 231 | and current_timestamp > created_at + (cfg->>'periodic_status_check')::interval 232 | and 233 | case when checked_at is not null then 234 | current_timestamp > checked_at + (cfg->>'periodic_status_check')::interval 235 | else true end 236 | and ends_at > current_timestamp + '1 hour'::interval 237 | ", 238 | &[], 239 | ) 240 | .await? 241 | .iter() 242 | .map(|row| CheckVoteInput { 243 | repository_full_name: row.get("repository_full_name"), 244 | issue_number: row.get("issue_number"), 245 | }) 246 | .collect(); 247 | Ok(inputs) 248 | } 249 | 250 | /// [`DB::get_vote`] 251 | async fn get_vote(&self, vote_id: Uuid) -> Result> { 252 | let db = self.pool.get().await?; 253 | let vote = db 254 | .query_opt("select * from vote where vote_id = $1::uuid", &[&vote_id]) 255 | .await? 256 | .map(|row| Vote::from(&row)); 257 | Ok(vote) 258 | } 259 | 260 | /// [`DB::has_vote`] 261 | async fn has_vote(&self, repository_full_name: &str, issue_number: i64) -> Result { 262 | let db = self.pool.get().await?; 263 | let has_vote = db 264 | .query_one( 265 | " 266 | select exists ( 267 | select 1 from vote 268 | where repository_full_name = $1::text 269 | and issue_number = $2::bigint 270 | ) 271 | ", 272 | &[&repository_full_name, &issue_number], 273 | ) 274 | .await? 275 | .get(0); 276 | Ok(has_vote) 277 | } 278 | 279 | /// [`DB::has_vote_open`] 280 | async fn has_vote_open(&self, repository_full_name: &str, issue_number: i64) -> Result { 281 | let db = self.pool.get().await?; 282 | let has_vote_open = db 283 | .query_one( 284 | " 285 | select exists ( 286 | select 1 from vote 287 | where repository_full_name = $1::text 288 | and issue_number = $2::bigint 289 | and closed = false 290 | ) 291 | ", 292 | &[&repository_full_name, &issue_number], 293 | ) 294 | .await? 295 | .get(0); 296 | Ok(has_vote_open) 297 | } 298 | 299 | /// [`DB::list_votes`] 300 | async fn list_votes(&self, repository_full_name: &str) -> Result> { 301 | let db = self.pool.get().await?; 302 | let votes = db 303 | .query( 304 | " 305 | select * 306 | from vote 307 | where repository_full_name = $1::text 308 | order by created_at desc 309 | ", 310 | &[&repository_full_name], 311 | ) 312 | .await? 313 | .iter() 314 | .map(Vote::from) 315 | .collect(); 316 | Ok(votes) 317 | } 318 | 319 | /// [`DB::store_vote`] 320 | async fn store_vote( 321 | &self, 322 | vote_comment_id: i64, 323 | input: &CreateVoteInput, 324 | cfg: &CfgProfile, 325 | ) -> Result { 326 | let db = self.pool.get().await?; 327 | let vote_id = db 328 | .query_one( 329 | " 330 | insert into vote ( 331 | vote_comment_id, 332 | ends_at, 333 | cfg, 334 | created_by, 335 | installation_id, 336 | issue_id, 337 | issue_number, 338 | issue_title, 339 | is_pull_request, 340 | repository_full_name, 341 | organization 342 | 343 | ) values ( 344 | $1::bigint, 345 | current_timestamp + ($2::bigint || ' seconds')::interval, 346 | $3::jsonb, 347 | $4::text, 348 | $5::bigint, 349 | $6::bigint, 350 | $7::bigint, 351 | $8::text, 352 | $9::boolean, 353 | $10::text, 354 | $11::text 355 | ) 356 | returning vote_id 357 | ", 358 | &[ 359 | &vote_comment_id, 360 | &(cfg.duration.as_secs() as i64), 361 | &Json(&cfg), 362 | &input.created_by, 363 | &input.installation_id, 364 | &input.issue_id, 365 | &input.issue_number, 366 | &input.issue_title, 367 | &input.is_pull_request, 368 | &input.repository_full_name, 369 | &input.organization, 370 | ], 371 | ) 372 | .await? 373 | .get("vote_id"); 374 | Ok(vote_id) 375 | } 376 | 377 | /// [`DB::update_vote_ends_at`] 378 | async fn update_vote_ends_at(&self, vote_id: Uuid) -> Result<()> { 379 | let db = self.pool.get().await?; 380 | db.execute( 381 | " 382 | update vote set 383 | ends_at = current_timestamp 384 | where vote_id = $1::uuid; 385 | ", 386 | &[&vote_id], 387 | ) 388 | .await?; 389 | Ok(()) 390 | } 391 | 392 | /// [`DB::update_vote_last_check`] 393 | async fn update_vote_last_check(&self, vote_id: Uuid) -> Result<()> { 394 | let db = self.pool.get().await?; 395 | db.execute( 396 | " 397 | update vote set 398 | checked_at = current_timestamp 399 | where vote_id = $1::uuid; 400 | ", 401 | &[&vote_id], 402 | ) 403 | .await?; 404 | Ok(()) 405 | } 406 | } 407 | -------------------------------------------------------------------------------- /src/cmd.rs: -------------------------------------------------------------------------------- 1 | //! This module defines the commands supported and the logic to parse them from 2 | //! GitHub events. 3 | 4 | use anyhow::Result; 5 | use regex::Regex; 6 | use serde::{Deserialize, Serialize}; 7 | use std::sync::LazyLock; 8 | use tracing::error; 9 | 10 | use crate::{ 11 | cfg_repo::{Cfg, CfgError}, 12 | github::{ 13 | DynGH, Event, IssueCommentEventAction, IssueEventAction, PullRequestEventAction, split_full_name, 14 | }, 15 | }; 16 | 17 | /// Available commands. 18 | const CMD_CREATE_VOTE: &str = "vote"; 19 | const CMD_CANCEL_VOTE: &str = "cancel-vote"; 20 | const CMD_CHECK_VOTE: &str = "check-vote"; 21 | 22 | /// Regex used to detect commands in issues/prs comments. 23 | static CMD: LazyLock = LazyLock::new(|| { 24 | Regex::new(r"(?m)^/(vote|cancel-vote|check-vote)-?([a-zA-Z0-9]*)\s*$").expect("invalid CMD regexp") 25 | }); 26 | 27 | /// Represents a command to be executed, usually created from a GitHub event. 28 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 29 | #[allow(clippy::enum_variant_names)] 30 | pub(crate) enum Command { 31 | CreateVote(CreateVoteInput), 32 | CancelVote(CancelVoteInput), 33 | CheckVote(CheckVoteInput), 34 | } 35 | 36 | impl Command { 37 | /// Try to create a new command from an event. 38 | /// 39 | /// A command can be created from an event in two different ways: 40 | /// 41 | /// 1. A manual command is found in the event (e.g. `/vote` on comment). 42 | /// 2. The event triggers the creation of an automatic command (e.g. a new 43 | /// PR is created and one of the affected files matches a predefined 44 | /// pattern). 45 | /// 46 | /// Manual commands have preference, and if one is found, automatic ones 47 | /// won't be processed. 48 | /// 49 | pub(crate) async fn from_event(gh: DynGH, event: &Event) -> Option { 50 | if let Some(cmd) = Command::from_event_manual(event) { 51 | Some(cmd) 52 | } else { 53 | match Command::from_event_automatic(gh, event).await { 54 | Ok(cmd) => cmd, 55 | Err(err) => { 56 | error!(?err, ?event, "error processing automatic command"); 57 | None 58 | } 59 | } 60 | } 61 | } 62 | 63 | /// Get manual command from event, if available. 64 | fn from_event_manual(event: &Event) -> Option { 65 | // Get the content where we'll try to extract the command from 66 | let content = match event { 67 | Event::Issue(event) if event.action == IssueEventAction::Opened => &event.issue.body, 68 | Event::IssueComment(event) if event.action == IssueCommentEventAction::Created => { 69 | &event.comment.body 70 | } 71 | Event::PullRequest(event) if event.action == PullRequestEventAction::Opened => { 72 | &event.pull_request.body 73 | } 74 | _ => return None, 75 | }; 76 | 77 | // Create a new command from the content (if possible) 78 | if let Some(content) = content 79 | && let Some(captures) = CMD.captures(content) 80 | { 81 | let cmd = captures.get(1)?.as_str(); 82 | let profile = match captures.get(2)?.as_str() { 83 | "" => None, 84 | profile => Some(profile), 85 | }; 86 | match cmd { 87 | CMD_CREATE_VOTE => { 88 | return Some(Command::CreateVote(CreateVoteInput::new(profile, event))); 89 | } 90 | CMD_CANCEL_VOTE => return Some(Command::CancelVote(CancelVoteInput::new(event))), 91 | CMD_CHECK_VOTE => return Some(Command::CheckVote(CheckVoteInput::new(event))), 92 | _ => return None, 93 | } 94 | } 95 | 96 | None 97 | } 98 | 99 | /// Create automatic command from event, if applicable. 100 | async fn from_event_automatic(gh: DynGH, event: &Event) -> Result> { 101 | match event { 102 | // Pull request opened 103 | Event::PullRequest(event) if event.action == PullRequestEventAction::Opened => { 104 | // Get configuration 105 | let inst_id = event.installation.id as u64; 106 | let (owner, repo) = split_full_name(&event.repository.full_name); 107 | let cfg = match Cfg::get(gh.clone(), inst_id, owner, repo).await { 108 | Err(CfgError::ConfigNotFound) => return Ok(None), 109 | Err(err) => return Err(err.into()), 110 | Ok(cfg) => cfg, 111 | }; 112 | 113 | // Process automation if enabled 114 | if let Some(automation) = cfg.automation { 115 | if !automation.enabled || automation.rules.is_empty() { 116 | return Ok(None); 117 | } 118 | 119 | // Check if any of the PR files matches the automation rules 120 | let pr_number = event.pull_request.number; 121 | let pr_files = gh.get_pr_files(inst_id, owner, repo, pr_number).await?; 122 | for rule in automation.rules { 123 | if rule.matches(pr_files.as_slice())? { 124 | let cmd = Command::CreateVote(CreateVoteInput::new( 125 | Some(&rule.profile), 126 | &Event::PullRequest(event.clone()), 127 | )); 128 | return Ok(Some(cmd)); 129 | } 130 | } 131 | } 132 | 133 | Ok(None) 134 | } 135 | _ => Ok(None), 136 | } 137 | } 138 | } 139 | 140 | /// Information required to create a new vote. 141 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 142 | pub(crate) struct CreateVoteInput { 143 | pub profile_name: Option, 144 | pub created_by: String, 145 | pub installation_id: i64, 146 | pub issue_id: i64, 147 | pub issue_number: i64, 148 | pub issue_title: String, 149 | pub is_pull_request: bool, 150 | pub repository_full_name: String, 151 | pub organization: Option, 152 | } 153 | 154 | impl CreateVoteInput { 155 | /// Create a new `CreateVoteInput` instance from the profile and event 156 | /// provided. 157 | pub(crate) fn new(profile_name: Option<&str>, event: &Event) -> Self { 158 | match event { 159 | Event::Issue(event) => Self { 160 | profile_name: profile_name.map(ToString::to_string), 161 | created_by: event.sender.login.clone(), 162 | installation_id: event.installation.id, 163 | issue_id: event.issue.id, 164 | issue_number: event.issue.number, 165 | issue_title: event.issue.title.clone(), 166 | is_pull_request: event.issue.pull_request.is_some(), 167 | repository_full_name: event.repository.full_name.clone(), 168 | organization: event.organization.as_ref().map(|o| o.login.clone()), 169 | }, 170 | Event::IssueComment(event) => Self { 171 | profile_name: profile_name.map(ToString::to_string), 172 | created_by: event.sender.login.clone(), 173 | installation_id: event.installation.id, 174 | issue_id: event.issue.id, 175 | issue_number: event.issue.number, 176 | issue_title: event.issue.title.clone(), 177 | is_pull_request: event.issue.pull_request.is_some(), 178 | repository_full_name: event.repository.full_name.clone(), 179 | organization: event.organization.as_ref().map(|o| o.login.clone()), 180 | }, 181 | Event::PullRequest(event) => Self { 182 | profile_name: profile_name.map(ToString::to_string), 183 | created_by: event.sender.login.clone(), 184 | installation_id: event.installation.id, 185 | issue_id: event.pull_request.id, 186 | issue_number: event.pull_request.number, 187 | issue_title: event.pull_request.title.clone(), 188 | is_pull_request: true, 189 | repository_full_name: event.repository.full_name.clone(), 190 | organization: event.organization.as_ref().map(|o| o.login.clone()), 191 | }, 192 | } 193 | } 194 | } 195 | 196 | /// Information required to cancel an open vote. 197 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 198 | pub(crate) struct CancelVoteInput { 199 | pub cancelled_by: String, 200 | pub installation_id: i64, 201 | pub issue_number: i64, 202 | pub is_pull_request: bool, 203 | pub repository_full_name: String, 204 | } 205 | 206 | impl CancelVoteInput { 207 | /// Create a new `CancelVoteInput` instance from the event provided. 208 | pub(crate) fn new(event: &Event) -> Self { 209 | match event { 210 | Event::Issue(event) => Self { 211 | cancelled_by: event.sender.login.clone(), 212 | installation_id: event.installation.id, 213 | issue_number: event.issue.number, 214 | is_pull_request: event.issue.pull_request.is_some(), 215 | repository_full_name: event.repository.full_name.clone(), 216 | }, 217 | Event::IssueComment(event) => Self { 218 | cancelled_by: event.sender.login.clone(), 219 | installation_id: event.installation.id, 220 | issue_number: event.issue.number, 221 | is_pull_request: event.issue.pull_request.is_some(), 222 | repository_full_name: event.repository.full_name.clone(), 223 | }, 224 | Event::PullRequest(event) => Self { 225 | cancelled_by: event.sender.login.clone(), 226 | installation_id: event.installation.id, 227 | issue_number: event.pull_request.number, 228 | is_pull_request: true, 229 | repository_full_name: event.repository.full_name.clone(), 230 | }, 231 | } 232 | } 233 | } 234 | 235 | /// Information required to check the status of an open vote. 236 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 237 | pub(crate) struct CheckVoteInput { 238 | pub issue_number: i64, 239 | pub repository_full_name: String, 240 | } 241 | 242 | impl CheckVoteInput { 243 | /// Create a new `CheckVoteInput` instance from the event provided. 244 | pub(crate) fn new(event: &Event) -> Self { 245 | match event { 246 | Event::Issue(event) => Self { 247 | issue_number: event.issue.number, 248 | repository_full_name: event.repository.full_name.clone(), 249 | }, 250 | Event::IssueComment(event) => Self { 251 | issue_number: event.issue.number, 252 | repository_full_name: event.repository.full_name.clone(), 253 | }, 254 | Event::PullRequest(event) => Self { 255 | issue_number: event.pull_request.number, 256 | repository_full_name: event.repository.full_name.clone(), 257 | }, 258 | } 259 | } 260 | } 261 | 262 | #[cfg(test)] 263 | mod tests { 264 | use std::{sync::Arc, vec}; 265 | 266 | use futures::future; 267 | use mockall::predicate::eq; 268 | 269 | use crate::{ 270 | github::{File, MockGH}, 271 | testutil::*, 272 | }; 273 | 274 | use super::*; 275 | 276 | #[test] 277 | fn manual_command_from_issue_event_unsupported_action() { 278 | let mut event = setup_test_issue_event(); 279 | event.action = IssueEventAction::Other; 280 | event.issue.body = Some(format!("/{CMD_CREATE_VOTE}")); 281 | let event = Event::Issue(event); 282 | 283 | assert_eq!(Command::from_event_manual(&event), None); 284 | } 285 | 286 | #[test] 287 | fn manual_command_from_issue_event_no_cmd() { 288 | let mut event = setup_test_issue_event(); 289 | event.action = IssueEventAction::Opened; 290 | event.issue.body = Some("Hi!".to_string()); 291 | let event = Event::Issue(event); 292 | 293 | assert_eq!(Command::from_event_manual(&event), None); 294 | } 295 | 296 | #[test] 297 | fn manual_command_from_issue_event_create_vote_cmd_default_profile() { 298 | let mut event = setup_test_issue_event(); 299 | event.action = IssueEventAction::Opened; 300 | event.issue.body = Some(format!("/{CMD_CREATE_VOTE}")); 301 | let event = Event::Issue(event); 302 | 303 | assert_eq!( 304 | Command::from_event_manual(&event), 305 | Some(Command::CreateVote(CreateVoteInput::new(None, &event))) 306 | ); 307 | } 308 | 309 | #[test] 310 | fn manual_command_from_issue_event_create_vote_cmd_profile1() { 311 | let mut event = setup_test_issue_event(); 312 | event.action = IssueEventAction::Opened; 313 | event.issue.body = Some(format!("/{CMD_CREATE_VOTE}-{PROFILE_NAME}")); 314 | let event = Event::Issue(event); 315 | 316 | assert_eq!( 317 | Command::from_event_manual(&event), 318 | Some(Command::CreateVote(CreateVoteInput::new( 319 | Some("profile1"), 320 | &event 321 | ))) 322 | ); 323 | } 324 | 325 | #[test] 326 | fn manual_command_from_issue_comment_event_unsupported_action() { 327 | let mut event = setup_test_issue_comment_event(); 328 | event.action = IssueCommentEventAction::Other; 329 | event.issue.body = Some(CMD_CREATE_VOTE.to_string()); 330 | let event = Event::IssueComment(event); 331 | 332 | assert_eq!(Command::from_event_manual(&event), None); 333 | } 334 | 335 | #[test] 336 | fn manual_command_from_issue_comment_event_create_vote_cmd_default_profile() { 337 | let mut event = setup_test_issue_comment_event(); 338 | event.action = IssueCommentEventAction::Created; 339 | event.comment.body = Some(format!("/{CMD_CREATE_VOTE}")); 340 | let event = Event::IssueComment(event); 341 | 342 | assert_eq!( 343 | Command::from_event_manual(&event), 344 | Some(Command::CreateVote(CreateVoteInput::new(None, &event))) 345 | ); 346 | } 347 | 348 | #[test] 349 | fn manual_command_from_issue_comment_event_cancel_vote_cmd() { 350 | let mut event = setup_test_issue_comment_event(); 351 | event.action = IssueCommentEventAction::Created; 352 | event.comment.body = Some(format!("/{CMD_CANCEL_VOTE}")); 353 | let event = Event::IssueComment(event); 354 | 355 | assert_eq!( 356 | Command::from_event_manual(&event), 357 | Some(Command::CancelVote(CancelVoteInput::new(&event))) 358 | ); 359 | } 360 | 361 | #[test] 362 | fn manual_command_from_pr_event_unsupported_action() { 363 | let mut event = setup_test_pr_event(); 364 | event.action = PullRequestEventAction::Other; 365 | event.pull_request.body = Some(CMD_CREATE_VOTE.to_string()); 366 | let event = Event::PullRequest(event); 367 | 368 | assert_eq!(Command::from_event_manual(&event), None); 369 | } 370 | 371 | #[test] 372 | fn manual_command_from_pr_event_create_vote_cmd_default_profile() { 373 | let mut event = setup_test_pr_event(); 374 | event.action = PullRequestEventAction::Opened; 375 | event.pull_request.body = Some(format!("/{CMD_CREATE_VOTE}")); 376 | let event = Event::PullRequest(event); 377 | 378 | assert_eq!( 379 | Command::from_event_manual(&event), 380 | Some(Command::CreateVote(CreateVoteInput::new(None, &event))) 381 | ); 382 | } 383 | 384 | #[tokio::test] 385 | async fn automatic_command_from_pr_event() { 386 | let mut gh = MockGH::new(); 387 | gh.expect_get_config_file() 388 | .with(eq(INST_ID), eq(ORG), eq(REPO)) 389 | .times(1) 390 | .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); 391 | gh.expect_get_pr_files() 392 | .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) 393 | .times(1) 394 | .returning(|_, _, _, _| { 395 | Box::pin(future::ready(Ok(vec![File { 396 | filename: "README.md".to_string(), 397 | }]))) 398 | }); 399 | let gh = Arc::new(gh); 400 | 401 | let mut event = setup_test_pr_event(); 402 | event.action = PullRequestEventAction::Opened; 403 | let event = Event::PullRequest(event); 404 | 405 | assert_eq!( 406 | Command::from_event_automatic(gh, &event.clone()).await.unwrap(), 407 | Some(Command::CreateVote(CreateVoteInput::new(Some("default"), &event))) 408 | ); 409 | } 410 | } 411 | -------------------------------------------------------------------------------- /src/results.rs: -------------------------------------------------------------------------------- 1 | //! This module defines the logic to calculate vote results. 2 | 3 | use std::{collections::BTreeMap, fmt}; 4 | 5 | use anyhow::{Result, bail}; 6 | use serde::{Deserialize, Serialize}; 7 | use time::{OffsetDateTime, format_description::well_known::Rfc3339}; 8 | use tokio_postgres::{Row, types::Json}; 9 | use uuid::Uuid; 10 | 11 | use crate::{ 12 | cfg_repo::CfgProfile, 13 | github::{DynGH, UserName}, 14 | }; 15 | 16 | /// Supported reactions. 17 | pub(crate) const REACTION_IN_FAVOR: &str = "+1"; 18 | pub(crate) const REACTION_AGAINST: &str = "-1"; 19 | pub(crate) const REACTION_ABSTAIN: &str = "eyes"; 20 | 21 | /// Vote information. 22 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 23 | #[allow(clippy::struct_field_names)] 24 | pub(crate) struct Vote { 25 | pub vote_id: Uuid, 26 | pub vote_comment_id: i64, 27 | pub created_at: OffsetDateTime, 28 | pub created_by: String, 29 | pub ends_at: OffsetDateTime, 30 | pub closed: bool, 31 | pub closed_at: Option, 32 | pub checked_at: Option, 33 | pub cfg: CfgProfile, 34 | pub installation_id: i64, 35 | pub issue_id: i64, 36 | pub issue_number: i64, 37 | pub issue_title: Option, 38 | pub is_pull_request: bool, 39 | pub repository_full_name: String, 40 | pub organization: Option, 41 | pub results: Option, 42 | } 43 | 44 | impl From<&Row> for Vote { 45 | fn from(row: &Row) -> Self { 46 | let Json(cfg): Json = row.get("cfg"); 47 | let results: Option> = row.get("results"); 48 | Self { 49 | vote_id: row.get("vote_id"), 50 | vote_comment_id: row.get("vote_comment_id"), 51 | created_at: row.get("created_at"), 52 | created_by: row.get("created_by"), 53 | ends_at: row.get("ends_at"), 54 | closed: row.get("closed"), 55 | closed_at: row.get("closed_at"), 56 | checked_at: row.get("checked_at"), 57 | cfg, 58 | installation_id: row.get("installation_id"), 59 | issue_id: row.get("issue_id"), 60 | issue_number: row.get("issue_number"), 61 | issue_title: row.get("issue_title"), 62 | is_pull_request: row.get("is_pull_request"), 63 | repository_full_name: row.get("repository_full_name"), 64 | organization: row.get("organization"), 65 | results: results.map(|Json(results)| results), 66 | } 67 | } 68 | } 69 | 70 | /// Vote options. 71 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 72 | pub(crate) enum VoteOption { 73 | InFavor, 74 | Against, 75 | Abstain, 76 | } 77 | 78 | impl VoteOption { 79 | /// Create a new vote option from a reaction string. 80 | fn from_reaction(reaction: &str) -> Result { 81 | let vote_option = match reaction { 82 | REACTION_IN_FAVOR => Self::InFavor, 83 | REACTION_AGAINST => Self::Against, 84 | REACTION_ABSTAIN => Self::Abstain, 85 | _ => bail!("reaction not supported"), 86 | }; 87 | Ok(vote_option) 88 | } 89 | } 90 | 91 | impl fmt::Display for VoteOption { 92 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 93 | let s = match self { 94 | Self::InFavor => "In favor", 95 | Self::Against => "Against", 96 | Self::Abstain => "Abstain", 97 | }; 98 | write!(f, "{s}") 99 | } 100 | } 101 | 102 | /// Vote results information. 103 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 104 | pub(crate) struct VoteResults { 105 | pub passed: bool, 106 | pub in_favor_percentage: f64, 107 | pub pass_threshold: f64, 108 | pub in_favor: i64, 109 | pub against: i64, 110 | pub against_percentage: f64, 111 | pub abstain: i64, 112 | pub not_voted: i64, 113 | pub binding: i64, 114 | pub non_binding: i64, 115 | pub allowed_voters: i64, 116 | pub votes: BTreeMap, 117 | pub pending_voters: Vec, 118 | } 119 | 120 | /// User's vote details. 121 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 122 | pub(crate) struct UserVote { 123 | pub vote_option: VoteOption, 124 | pub timestamp: OffsetDateTime, 125 | pub binding: bool, 126 | } 127 | 128 | /// Calculate vote results. 129 | pub(crate) async fn calculate<'a>( 130 | gh: DynGH, 131 | owner: &'a str, 132 | repo: &'a str, 133 | vote: &'a Vote, 134 | ) -> Result { 135 | // Get vote comment reactions (aka votes) 136 | let inst_id = vote.installation_id as u64; 137 | let reactions = gh.get_comment_reactions(inst_id, owner, repo, vote.vote_comment_id).await?; 138 | 139 | // Get list of allowed voters (users with binding votes) 140 | let allowed_voters = 141 | gh.get_allowed_voters(inst_id, &vote.cfg, owner, repo, vote.organization.as_ref()).await?; 142 | 143 | // Track users votes 144 | let mut votes: BTreeMap = BTreeMap::new(); 145 | let mut multiple_options_voters: Vec = Vec::new(); 146 | for reaction in reactions { 147 | // Get vote option from reaction 148 | let username: UserName = reaction.user.login; 149 | let Ok(vote_option) = VoteOption::from_reaction(reaction.content.as_str()) else { 150 | continue; 151 | }; 152 | 153 | // Do not count votes of users voting for multiple options 154 | if multiple_options_voters.contains(&username) { 155 | continue; 156 | } 157 | if votes.contains_key(&username) { 158 | // User has already voted (multiple options voter), we have to 159 | // remove their vote as we can't know which one to pick 160 | multiple_options_voters.push(username.clone()); 161 | votes.remove(&username); 162 | continue; 163 | } 164 | 165 | // Track vote 166 | let binding = allowed_voters.contains(&username); 167 | votes.insert( 168 | username, 169 | UserVote { 170 | vote_option, 171 | timestamp: OffsetDateTime::parse(reaction.created_at.as_str(), &Rfc3339) 172 | .expect("created_at timestamp to be valid"), 173 | binding, 174 | }, 175 | ); 176 | } 177 | 178 | // Prepare results and return them 179 | let (mut in_favor, mut against, mut abstain, mut binding, mut non_binding) = (0, 0, 0, 0, 0); 180 | for user_vote in votes.values() { 181 | if user_vote.binding { 182 | match user_vote.vote_option { 183 | VoteOption::InFavor => in_favor += 1, 184 | VoteOption::Against => against += 1, 185 | VoteOption::Abstain => abstain += 1, 186 | } 187 | binding += 1; 188 | } else { 189 | non_binding += 1; 190 | } 191 | } 192 | let mut in_favor_percentage = 0.0; 193 | let mut against_percentage = 0.0; 194 | #[allow(clippy::cast_precision_loss)] 195 | if !allowed_voters.is_empty() { 196 | in_favor_percentage = in_favor as f64 / allowed_voters.len() as f64 * 100.0; 197 | against_percentage = against as f64 / allowed_voters.len() as f64 * 100.0; 198 | } 199 | let pending_voters: Vec = 200 | allowed_voters.iter().filter(|user| !votes.contains_key(*user)).cloned().collect(); 201 | 202 | Ok(VoteResults { 203 | passed: in_favor_percentage >= vote.cfg.pass_threshold, 204 | in_favor_percentage, 205 | pass_threshold: vote.cfg.pass_threshold, 206 | in_favor, 207 | against, 208 | against_percentage, 209 | abstain, 210 | not_voted: pending_voters.len() as i64, 211 | binding, 212 | non_binding, 213 | allowed_voters: allowed_voters.len() as i64, 214 | votes, 215 | pending_voters, 216 | }) 217 | } 218 | 219 | #[cfg(test)] 220 | mod tests { 221 | use std::{sync::Arc, time::Duration}; 222 | 223 | use futures::future::{self}; 224 | use mockall::predicate::eq; 225 | 226 | use crate::github::{MockGH, Reaction, User}; 227 | use crate::testutil::*; 228 | 229 | use super::*; 230 | 231 | #[test] 232 | fn vote_option_from_reaction() { 233 | assert_eq!( 234 | VoteOption::from_reaction(REACTION_IN_FAVOR).unwrap(), 235 | VoteOption::InFavor 236 | ); 237 | assert_eq!( 238 | VoteOption::from_reaction(REACTION_AGAINST).unwrap(), 239 | VoteOption::Against 240 | ); 241 | assert_eq!( 242 | VoteOption::from_reaction(REACTION_ABSTAIN).unwrap(), 243 | VoteOption::Abstain 244 | ); 245 | assert!(VoteOption::from_reaction("unsupported").is_err()); 246 | } 247 | 248 | macro_rules! test_calculate { 249 | ($( 250 | $func:ident: 251 | { 252 | cfg: $cfg:expr, 253 | reactions: $reactions:expr, 254 | allowed_voters: $allowed_voters:expr, 255 | expected_results: $expected_results:expr 256 | } 257 | ,)*) => { 258 | $( 259 | #[tokio::test] 260 | async fn $func() { 261 | // Prepare test data 262 | let vote = Vote { 263 | vote_id: Uuid::parse_str(VOTE_ID).unwrap(), 264 | vote_comment_id: COMMENT_ID, 265 | created_at: OffsetDateTime::now_utc(), 266 | created_by: USER.to_string(), 267 | ends_at: OffsetDateTime::now_utc(), 268 | closed: false, 269 | closed_at: None, 270 | checked_at: None, 271 | cfg: $cfg.clone(), 272 | installation_id: INST_ID as i64, 273 | issue_id: ISSUE_ID, 274 | issue_number: ISSUE_NUM, 275 | issue_title: Some(TITLE.to_string()), 276 | is_pull_request: false, 277 | repository_full_name: REPOFN.to_string(), 278 | organization: Some(ORG.to_string()), 279 | results: None, 280 | }; 281 | 282 | // Setup mocks and expectations 283 | let mut gh = MockGH::new(); 284 | gh.expect_get_comment_reactions() 285 | .with(eq(INST_ID), eq(OWNER), eq(REPO), eq(COMMENT_ID)) 286 | .times(1) 287 | .returning(|_, _, _, _| Box::pin(future::ready(Ok($reactions)))); 288 | gh.expect_get_allowed_voters() 289 | .withf(|inst_id, cfg, owner, repo, org| { 290 | *inst_id == INST_ID 291 | && *cfg == $cfg 292 | && owner == OWNER 293 | && repo == REPO 294 | && *org == Some(ORG.to_string()).as_ref() 295 | }) 296 | .times(1) 297 | .returning(|_, _, _, _, _| Box::pin(future::ready(Ok($allowed_voters)))); 298 | 299 | // Calculate vote results and check we get what we expect 300 | let results = calculate(Arc::new(gh), OWNER, REPO, &vote) 301 | .await 302 | .unwrap(); 303 | assert_eq!(results, $expected_results); 304 | } 305 | )* 306 | } 307 | } 308 | 309 | test_calculate!( 310 | calculate_unsupported_reactions_are_ignored: 311 | { 312 | cfg: CfgProfile { 313 | duration: Duration::from_secs(1), 314 | pass_threshold: 50.0, 315 | ..Default::default() 316 | }, 317 | reactions: vec![ 318 | Reaction { 319 | user: User { login: USER1.to_string() }, 320 | content: "unsupported".to_string(), 321 | created_at: TIMESTAMP.to_string(), 322 | }, 323 | Reaction { 324 | user: User { login: USER1.to_string() }, 325 | content: REACTION_AGAINST.to_string(), 326 | created_at: TIMESTAMP.to_string(), 327 | }, 328 | Reaction { 329 | user: User { login: USER1.to_string() }, 330 | content: "unsupported".to_string(), 331 | created_at: TIMESTAMP.to_string(), 332 | } 333 | ], 334 | allowed_voters: vec![ 335 | USER1.to_string() 336 | ], 337 | expected_results: VoteResults { 338 | passed: false, 339 | in_favor_percentage: 0.0, 340 | pass_threshold: 50.0, 341 | in_favor: 0, 342 | against: 1, 343 | against_percentage: 100.0, 344 | abstain: 0, 345 | not_voted: 0, 346 | binding: 1, 347 | non_binding: 0, 348 | votes: BTreeMap::from([ 349 | ( 350 | USER1.to_string(), 351 | UserVote { 352 | vote_option: VoteOption::Against, 353 | timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), 354 | binding: true, 355 | }, 356 | ) 357 | ]), 358 | allowed_voters: 1, 359 | pending_voters: vec![], 360 | } 361 | }, 362 | 363 | calculate_do_not_count_votes_from_multiple_options_voters: 364 | { 365 | cfg: CfgProfile { 366 | duration: Duration::from_secs(1), 367 | pass_threshold: 50.0, 368 | ..Default::default() 369 | }, 370 | reactions: vec![ 371 | Reaction { 372 | user: User { login: USER1.to_string() }, 373 | content: REACTION_AGAINST.to_string(), 374 | created_at: TIMESTAMP.to_string(), 375 | }, 376 | Reaction { 377 | user: User { login: USER1.to_string() }, 378 | content: REACTION_ABSTAIN.to_string(), 379 | created_at: TIMESTAMP.to_string(), 380 | } 381 | ], 382 | allowed_voters: vec![ 383 | USER1.to_string() 384 | ], 385 | expected_results: VoteResults { 386 | passed: false, 387 | in_favor_percentage: 0.0, 388 | pass_threshold: 50.0, 389 | in_favor: 0, 390 | against: 0, 391 | against_percentage: 0.0, 392 | abstain: 0, 393 | not_voted: 1, 394 | binding: 0, 395 | non_binding: 0, 396 | votes: BTreeMap::new(), 397 | allowed_voters: 1, 398 | pending_voters: vec![USER1.to_string()], 399 | } 400 | }, 401 | 402 | calculate_votes_are_counted_correctly: 403 | { 404 | cfg: CfgProfile { 405 | duration: Duration::from_secs(1), 406 | pass_threshold: 50.0, 407 | ..Default::default() 408 | }, 409 | reactions: vec![ 410 | Reaction { 411 | user: User { login: USER1.to_string() }, 412 | content: REACTION_IN_FAVOR.to_string(), 413 | created_at: TIMESTAMP.to_string(), 414 | }, 415 | Reaction { 416 | user: User { login: USER2.to_string() }, 417 | content: REACTION_AGAINST.to_string(), 418 | created_at: TIMESTAMP.to_string(), 419 | }, 420 | Reaction { 421 | user: User { login: USER3.to_string() }, 422 | content: REACTION_ABSTAIN.to_string(), 423 | created_at: TIMESTAMP.to_string(), 424 | }, 425 | Reaction { 426 | user: User { login: USER5.to_string() }, 427 | content: REACTION_IN_FAVOR.to_string(), 428 | created_at: TIMESTAMP.to_string(), 429 | } 430 | ], 431 | allowed_voters: vec![ 432 | USER1.to_string(), 433 | USER2.to_string(), 434 | USER3.to_string(), 435 | USER4.to_string() 436 | ], 437 | expected_results: VoteResults { 438 | passed: false, 439 | in_favor_percentage: 25.0, 440 | pass_threshold: 50.0, 441 | in_favor: 1, 442 | against: 1, 443 | against_percentage: 25.0, 444 | abstain: 1, 445 | not_voted: 1, 446 | binding: 3, 447 | non_binding: 1, 448 | votes: BTreeMap::from([ 449 | ( 450 | USER1.to_string(), 451 | UserVote { 452 | vote_option: VoteOption::InFavor, 453 | timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), 454 | binding: true, 455 | }, 456 | ), 457 | ( 458 | USER2.to_string(), 459 | UserVote { 460 | vote_option: VoteOption::Against, 461 | timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), 462 | binding: true, 463 | }, 464 | ), 465 | ( 466 | USER3.to_string(), 467 | UserVote { 468 | vote_option: VoteOption::Abstain, 469 | timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), 470 | binding: true, 471 | }, 472 | ), 473 | ( 474 | USER5.to_string(), 475 | UserVote { 476 | vote_option: VoteOption::InFavor, 477 | timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), 478 | binding: false, 479 | }, 480 | ), 481 | ]), 482 | allowed_voters: 4, 483 | pending_voters: vec![USER4.to_string()], 484 | } 485 | }, 486 | 487 | calculate_vote_passes_when_in_favor_percentage_reaches_pass_threshold: 488 | { 489 | cfg: CfgProfile { 490 | duration: Duration::from_secs(1), 491 | pass_threshold: 75.0, 492 | ..Default::default() 493 | }, 494 | reactions: vec![ 495 | Reaction { 496 | user: User { login: USER1.to_string() }, 497 | content: REACTION_IN_FAVOR.to_string(), 498 | created_at: TIMESTAMP.to_string(), 499 | }, 500 | Reaction { 501 | user: User { login: USER2.to_string() }, 502 | content: REACTION_IN_FAVOR.to_string(), 503 | created_at: TIMESTAMP.to_string(), 504 | }, 505 | Reaction { 506 | user: User { login: USER3.to_string() }, 507 | content: REACTION_IN_FAVOR.to_string(), 508 | created_at: TIMESTAMP.to_string(), 509 | } 510 | ], 511 | allowed_voters: vec![ 512 | USER1.to_string(), 513 | USER2.to_string(), 514 | USER3.to_string(), 515 | USER4.to_string() 516 | ], 517 | expected_results: VoteResults { 518 | passed: true, 519 | in_favor_percentage: 75.0, 520 | pass_threshold: 75.0, 521 | in_favor: 3, 522 | against: 0, 523 | against_percentage: 0.0, 524 | abstain: 0, 525 | not_voted: 1, 526 | binding: 3, 527 | non_binding: 0, 528 | votes: BTreeMap::from([ 529 | ( 530 | USER1.to_string(), 531 | UserVote { 532 | vote_option: VoteOption::InFavor, 533 | timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), 534 | binding: true, 535 | }, 536 | ), 537 | ( 538 | USER2.to_string(), 539 | UserVote { 540 | vote_option: VoteOption::InFavor, 541 | timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), 542 | binding: true, 543 | }, 544 | ), 545 | ( 546 | USER3.to_string(), 547 | UserVote { 548 | vote_option: VoteOption::InFavor, 549 | timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), 550 | binding: true, 551 | }, 552 | ), 553 | ]), 554 | allowed_voters: 4, 555 | pending_voters: vec![USER4.to_string()], 556 | } 557 | }, 558 | ); 559 | } 560 | --------------------------------------------------------------------------------