From 6901663d079950246f12a949484eb02e1f9ae8aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Casta=C3=B1o=20Arteaga?= Date: Fri, 6 Nov 2026 09:28:56 +0100 Subject: [PATCH 1/2] Fix some issues and add more tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sergio Castaño Arteaga --- .github/workflows/ci.yml | 81 + Cargo.lock | 1 + Cargo.toml | 2 + README.md | 30 +- charts/gitvote/Chart.yaml | 2 +- charts/gitvote/ci/default-values.yaml | 5 +- charts/gitvote/templates/_helpers.tpl | 56 +- .../gitvote/templates/dbmigrator_secret.yaml | 1 + charts/gitvote/templates/gitvote_secret.yaml | 17 +- charts/gitvote/values.yaml | 2 +- database/migrations/Dockerfile | 2 +- .../functions/001_load_functions.sql | 2 + .../functions/util/string_to_interval.sql | 10 +- database/migrations/schema/005_vote_state.sql | 77 + .../functions/util/string_to_interval.sql | 82 + database/tests/migrations/005_vote_state.sql | 390 +++ .../migrations/005_vote_state_cancelled.sql | 182 ++ .../migrations/005_vote_state_duplicates.sql | 95 + database/tests/schema/schema.sql | 490 ++++ docs/config/.gitvote.yml | 23 +- src/cfg_repo.rs | 191 +- src/cfg_repo/tests.rs | 831 +++++- src/cfg_svc.rs | 84 +- src/cfg_svc/tests.rs | 78 +- src/cmd.rs | 507 +++- src/cmd/tests.rs | 877 ++++++- src/db.rs | 827 ++++-- src/db/tests.rs | 2026 ++++++++++++++ src/github.rs | 1185 ++++++--- src/github/tests.rs | 1088 +------- src/github/tests/check_runs.rs | 298 +++ src/github/tests/collaborators.rs | 618 +++++ src/github/tests/comment_reactions.rs | 176 ++ src/github/tests/comments.rs | 389 +++ src/github/tests/config_file.rs | 263 ++ src/github/tests/discussions.rs | 543 ++++ src/github/tests/event.rs | 128 + src/github/tests/helpers.rs | 141 + src/github/tests/installations.rs | 59 + src/github/tests/labels.rs | 135 + src/github/tests/module_functions.rs | 61 + src/github/tests/pull_requests.rs | 93 + src/github/tests/requests.rs | 155 ++ .../announcement_discussions_query.graphql | 20 + src/handlers.rs | 275 +- src/handlers/tests.rs | 1481 ++++++++--- src/main.rs | 63 +- src/processor.rs | 1359 +++++++--- src/processor/tests.rs | 2317 +---------------- src/processor/tests/cancel_vote.rs | 421 +++ src/processor/tests/check_vote.rs | 226 ++ src/processor/tests/close_finished_vote.rs | 2076 +++++++++++++++ src/processor/tests/close_vote.rs | 180 ++ src/processor/tests/commands_handler.rs | 220 ++ src/processor/tests/create_vote.rs | 1350 ++++++++++ src/processor/tests/db_contracts.rs | 1430 ++++++++++ src/processor/tests/helpers.rs | 528 ++++ src/processor/tests/module_functions.rs | 32 + src/processor/tests/process_command.rs | 445 ++++ src/processor/tests/status_checker.rs | 145 ++ src/processor/tests/votes_auto_closer.rs | 290 +++ src/processor/tests/votes_closer.rs | 241 ++ src/processor/tests/votes_processor.rs | 113 + src/results.rs | 279 +- src/results/tests.rs | 668 +++-- src/testdata/event-cmd-profile.json | 6 +- src/testdata/event-cmd.json | 6 +- src/testdata/event-no-cmd.json | 6 +- src/testdata/event-pr-no-cmd.json | 4 +- ...vote-details-closed-without-results.golden | 46 + src/testdata/templates/audit.golden | 2 +- .../automation-failed-details.golden | 9 + .../templates/automation-failed.golden | 3 + src/testdata/templates/command-failed.golden | 1 + .../templates/vote-cancelled-issue.golden | 4 +- .../templates/vote-cancelled-pr.golden | 4 +- .../templates/vote-closed-announcement.golden | 3 +- .../templates/vote-closed-failed.golden | 3 +- .../templates/vote-closed-no-votes.golden | 2 + .../vote-closed-non-binding-only.golden | 3 +- .../vote-closed-non-binding-truncated.golden | 6 +- .../templates/vote-closed-passed.golden | 3 +- .../vote-status-non-binding-truncated.golden | 4 +- src/testutil.rs | 959 +++++-- src/tmpl.rs | 280 +- src/tmpl/tests.rs | 814 ++++-- templates/audit-vote-details.html | 97 +- templates/audit-vote-results.html | 86 + templates/audit.html | 7 +- templates/automation-failed.md | 7 + templates/command-failed.md | 1 + templates/vote-cancelled.md | 2 + templates/vote-closed.md | 4 +- templates/vote-created.md | 2 +- templates/vote-status.md | 5 +- tests/chart_config.rs | 467 ++++ tests/common/mod.rs | 165 ++ tests/startup.rs | 76 + 98 files changed, 23694 insertions(+), 5855 deletions(-) create mode 100644 database/migrations/schema/005_vote_state.sql create mode 100644 database/tests/functions/util/string_to_interval.sql create mode 100644 database/tests/migrations/005_vote_state.sql create mode 100644 database/tests/migrations/005_vote_state_cancelled.sql create mode 100644 database/tests/migrations/005_vote_state_duplicates.sql create mode 100644 database/tests/schema/schema.sql create mode 100644 src/db/tests.rs create mode 100644 src/github/tests/check_runs.rs create mode 100644 src/github/tests/collaborators.rs create mode 100644 src/github/tests/comment_reactions.rs create mode 100644 src/github/tests/comments.rs create mode 100644 src/github/tests/config_file.rs create mode 100644 src/github/tests/discussions.rs create mode 100644 src/github/tests/event.rs create mode 100644 src/github/tests/helpers.rs create mode 100644 src/github/tests/installations.rs create mode 100644 src/github/tests/labels.rs create mode 100644 src/github/tests/module_functions.rs create mode 100644 src/github/tests/pull_requests.rs create mode 100644 src/github/tests/requests.rs create mode 100644 src/graphql/announcement_discussions_query.graphql create mode 100644 src/processor/tests/cancel_vote.rs create mode 100644 src/processor/tests/check_vote.rs create mode 100644 src/processor/tests/close_finished_vote.rs create mode 100644 src/processor/tests/close_vote.rs create mode 100644 src/processor/tests/commands_handler.rs create mode 100644 src/processor/tests/create_vote.rs create mode 100644 src/processor/tests/db_contracts.rs create mode 100644 src/processor/tests/helpers.rs create mode 100644 src/processor/tests/module_functions.rs create mode 100644 src/processor/tests/process_command.rs create mode 100644 src/processor/tests/status_checker.rs create mode 100644 src/processor/tests/votes_auto_closer.rs create mode 100644 src/processor/tests/votes_closer.rs create mode 100644 src/processor/tests/votes_processor.rs create mode 100644 src/testdata/templates/audit-vote-details-closed-without-results.golden create mode 100644 src/testdata/templates/automation-failed-details.golden create mode 100644 src/testdata/templates/automation-failed.golden create mode 100644 src/testdata/templates/command-failed.golden create mode 100644 templates/audit-vote-results.html create mode 100644 templates/automation-failed.md create mode 100644 templates/command-failed.md create mode 100644 tests/chart_config.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/startup.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index beba475..b6d729b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,3 +33,84 @@ jobs: toolchain: 1.99.1 - name: Run backend tests run: cargo test + + tests-chart-config: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + - name: Setup Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.99.1 + - name: Setup Helm + uses: azure/setup-helm@v4 + with: + version: v3.9.2 + - name: Check the configuration rendered by the chart + run: cargo test --test chart_config -- --ignored + + tests-database: + runs-on: ubuntu-latest + env: + GITVOTE_TEST_DB_URL: postgres://postgres:postgres@127.0.0.1:5432/gitvote_tests + PGDATABASE: gitvote_tests + PGHOST: 127.0.0.1 + PGPASSWORD: postgres + PGUSER: postgres + services: + postgres: + image: postgres:17 + env: + POSTGRES_DB: gitvote_tests + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Checkout code + uses: actions/checkout@v5 + - name: Setup Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.99.1 + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: stable + cache: false + - name: Install tern + run: go install github.com/jackc/tern/v2@v2.4.3 + - name: Install pgTAP in the PostgreSQL service + run: | + docker exec ${{ job.services.postgres.id }} sh -c \ + "apt-get update && apt-get install -y postgresql-17-pgtap" + - name: Install pg_prove + run: | + sudo apt-get update + sudo apt-get install -y libtap-parser-sourcehandler-pgtap-perl + - name: Apply database migrations + working-directory: database/migrations + run: | + cat > "$RUNNER_TEMP/tern.conf" < [!NOTE] > Only repositories collaborators can create votes. For organization-owned repositories, the list of collaborators includes outside collaborators, organization members that are direct collaborators, organization members with access through team memberships, organization members with access through default organization permissions, and organization owners. Shortly after the comment with the `/vote` command is posted, the vote will be created and the bot will post a new comment to the corresponding issue or pull request with the vote instructions. +The `duration` of a vote is set in the configuration profile used. It must be at least 1 second, and it cannot be greater than 36500 days. Otherwise no vote is created and the bot posts a comment saying the configuration is invalid. + ![vote-created](docs/screenshots/vote-created.png) #### Automation 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). +> [!IMPORTANT] +> Automatic votes are subject to the same rule as the ones created with the `/vote` command: the user who opens the pull request must be a repository collaborator. When a pull request from any other user matches an automation rule, no vote is created and GitVote posts a comment saying so. In that case, a collaborator can start the vote by calling the `/vote` command. + +If the automation rules cannot be evaluated when a pull request is opened (for example, because the configuration file is invalid), or the vote they request cannot be created because of an unexpected failure, no vote is created. GitVote reports a failed check and posts a comment asking maintainers to call the `/vote` command if a vote is needed. + ### Voting Users can cast their votes by reacting to the `git-vote` bot comment where the vote was created (screenshot above). @@ -64,7 +71,7 @@ Only votes from users with a binding vote as defined in the configuration file w ### Checking votes -It is possible to check the status of a vote in progress by calling the `/check-vote` command: +It is possible to check the status of a vote in progress by calling the `/check-vote` command (this command does not accept a suffix): ![vote-status](docs/screenshots/vote-status.png) @@ -77,18 +84,33 @@ Once the vote time is up, the vote will be automatically closed and the results ![vote-closed](docs/screenshots/vote-closed.png) +If the results cannot be calculated when the vote time is up, for example because GitHub is not available, GitVote keeps trying until it can, waiting up to 6 hours between attempts. The vote is closed once its results are calculated. If publishing them fails (the comment, check, labels or announcement), GitVote retries the parts missing for about 20 hours, and leaves them as they are after that. The results comment and announcement, as well as the cancellation comment, include a hidden marker that identifies the vote, so that a retry finds them instead of posting them again when a previous attempt did post them. + +Reactions added after the end of the vote are ignored, even if the vote takes a while to be closed. When the vote is closed early because of the `close_on_passing` feature, its end is the moment it was closed. + +> [!NOTE] +> GitHub does not keep track of removed reactions, so removing a reaction after the end of the vote cannot be detected. If that happens before the results are calculated, that reaction will not be counted. + ### Cancelling votes -It is possible to cancel a vote in progress by calling the `/cancel-vote` command: +It is possible to cancel a vote in progress by calling the `/cancel-vote` command (this command does not accept a suffix): ![vote-cancelled](docs/screenshots/vote-cancelled.png) +The vote is cancelled as soon as the command is processed, and a new vote can be created right away. If the cancellation cannot be published on GitHub at that moment, GitVote retries publishing it for about 20 hours. When a newer vote exists, only the comment of the cancellation is posted, so that the check and labels of the newer vote are left alone. + +If the cancellation still cannot be published after those 20 hours, it is abandoned, and the parts not published yet are left as they are: the cancellation comment may be missing, the `gitvote/open` label may still be on the issue or pull request, and a pull request may keep the in-progress check of the cancelled vote. To clean them up, start a new vote with `/vote` and cancel it with `/cancel-vote` once GitHub is available again, or remove the label by hand. When the check is required and the pull request has no other vote, pushing a new commit also reports a new check on it. + ### Checks in pull requests -When a vote on a pull request is closed, GitVote will add a check to the head commit with its result. If the vote passes, the result of the check will be *success*, whereas if it doesn't pass, it'll be *failure*. When used in combination with `protected branch`, this feature can be used to *require* a vote in favor before a pull request can be merged. +When a vote on a pull request is closed, GitVote will add a check with its result to the commit at the head of the pull request at that moment. Commits pushed after the vote is closed do not get that check, even if publishing it has to be retried. The exception is when the head of the pull request cannot be read as the vote closes: the check is then added to the head at the time it is published, which may be a later commit. 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. + +The result of the check will also be *failure* if the vote closes without results, which happens when its comment cannot be found. The check will ask to run `/vote` again to start a new vote. ![check-passed](docs/screenshots/check-passed.png) +When the check is required in the target branch and a pull request has no vote, GitVote reports it on each push. The check passes unless an automation rule matches the pull request or the rules cannot be evaluated, in which case it fails. No vote is started on a push, so a vote has to be created with the `/vote` command. This also applies to pull requests where an automatic vote was cancelled. + ### Announcements GitVote is able to post announcements on GitHub discussions. When this feature is enabled, a new discussion will be created with the results of the vote once it is closed. diff --git a/charts/gitvote/Chart.yaml b/charts/gitvote/Chart.yaml index f01a737..6f473f6 100644 --- a/charts/gitvote/Chart.yaml +++ b/charts/gitvote/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: gitvote description: GitVote is a GitHub application that allows holding a vote on issues and pull requests type: application -version: 1.5.1-0 +version: 1.5.1-1 appVersion: 1.5.0 kubeVersion: ">= 1.19.0-0" home: https://gitvote.dev diff --git a/charts/gitvote/ci/default-values.yaml b/charts/gitvote/ci/default-values.yaml index 1a03725..674fec3 100644 --- a/charts/gitvote/ci/default-values.yaml +++ b/charts/gitvote/ci/default-values.yaml @@ -1,8 +1,9 @@ +# ct install runs the last published images, so it only checks the chart mechanics imageTag: latest gitvote: github: # Sample app ID for chart testing - appID: 123456 + appID: 1234567 # Sample key for chart testing appPrivateKey: |- -----BEGIN RSA PRIVATE KEY----- @@ -34,3 +35,5 @@ gitvote: -----END RSA PRIVATE KEY----- # Sample webhook secret for chart testing webhookSecret: "sample-secret-for-chart-testing" + # Sample webhook secret fallback for chart testing + webhookSecretFallback: "sample-fallback-for-chart-testing" diff --git a/charts/gitvote/templates/_helpers.tpl b/charts/gitvote/templates/_helpers.tpl index e32a4cc..251d47b 100644 --- a/charts/gitvote/templates/_helpers.tpl +++ b/charts/gitvote/templates/_helpers.tpl @@ -51,6 +51,31 @@ app.kubernetes.io/name: {{ include "chart.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end -}} +{{/* +Return the GitHub App ID in decimal, failing on any value that is not a valid App ID. +Unquoted numbers in a values file are read as floats, so whole floats are accepted and +printed in decimal instead of in scientific notation. They must be below 2^53, as floats +cannot hold every whole number past it, so the value read may differ from the one +written: larger IDs must be quoted. Strings are accepted only in decimal without leading +zeros, as helm would read 0123 as 83, and within the int64 range the service uses. An +unquoted 0123 in a values file reaches the chart as 83 already, so it cannot be refused. +*/}} +{{- define "chart.githubAppID" -}} +{{- $appID := .Values.gitvote.github.appID -}} +{{- $text := "" -}} +{{- if kindIs "int64" $appID -}} +{{- $text = toString $appID -}} +{{- else if and (kindIs "float64" $appID) (lt $appID 9007199254740992.0) (eq (float64 (int64 $appID)) $appID) -}} +{{- $text = toString (int64 $appID) -}} +{{- else if kindIs "string" $appID -}} +{{- $text = $appID -}} +{{- end -}} +{{- if not (and (regexMatch "^[1-9][0-9]*$" $text) (eq (toString (int64 $text)) $text)) -}} +{{- fail "gitvote.github.appID must be the ID of the GitHub App, a positive whole number written without leading zeros" -}} +{{- end -}} +{{- $text -}} +{{- end -}} + {{/* Kubernetes version Built-in object .Capabilities.KubeVersion.Minor can provide non-number output @@ -75,6 +100,31 @@ longest resource name ("dbmigrator-install" = 18 chars). {{- end -}} {{- end -}} +{{/* +Fail on values that must be strings but are not, as helm would print a different value. +Unquoted numbers in a values file are read as floats and printed in scientific notation +from 7 digits, and YAML words like yes or off are read as booleans and printed as true or +false. Other floats are refused too, as the value written cannot be told from them: 0123 +and 1.50 reach the chart as 83 and 1.5. Numbers from --set are integers and are printed +unchanged, but helm stores them with the release and reads them back as floats, so they +are refused on upgrades using --reuse-values until set again as strings. +*/}} +{{- define "chart.validateStrings" -}} +{{- $github := .Values.gitvote.github -}} +{{- $values := dict + "db.dbname" .Values.db.dbname + "db.password" .Values.db.password + "db.user" .Values.db.user + "gitvote.github.webhookSecret" $github.webhookSecret + "gitvote.github.webhookSecretFallback" $github.webhookSecretFallback +-}} +{{- range $name, $value := $values -}} +{{- if not (or (kindIs "string" $value) (kindIs "int64" $value) (kindIs "invalid" $value)) -}} +{{- fail (printf "%s must be a string, helm corrupts numbers and booleans: quote it in the values file or pass it with --set-string" $name) -}} +{{- end -}} +{{- end -}} +{{- end -}} + {{/* Provide an init container to verify the database is accessible */}} @@ -89,11 +139,11 @@ image: {{ .Values.postgresql.image.repository }}:{{ .Values.postgresql.image.tag imagePullPolicy: {{ .Values.pullPolicy }} env: - name: PGHOST - value: {{ default (printf "%s-postgresql.%s" .Release.Name .Release.Namespace) .Values.db.host }} + value: {{ default (printf "%s-postgresql.%s" .Release.Name .Release.Namespace) .Values.db.host | quote }} - name: PGPORT - value: "{{ .Values.db.port }}" + value: {{ .Values.db.port | int | quote }} - name: PGUSER - value: "{{ .Values.db.user }}" + value: {{ .Values.db.user | quote }} {{- if $securityContext }} securityContext:{{- toYaml $securityContext | nindent 2 }} {{- else }} diff --git a/charts/gitvote/templates/dbmigrator_secret.yaml b/charts/gitvote/templates/dbmigrator_secret.yaml index edc1e3a..f17e682 100644 --- a/charts/gitvote/templates/dbmigrator_secret.yaml +++ b/charts/gitvote/templates/dbmigrator_secret.yaml @@ -1,3 +1,4 @@ +{{- include "chart.validateStrings" . -}} apiVersion: v1 kind: Secret metadata: diff --git a/charts/gitvote/templates/gitvote_secret.yaml b/charts/gitvote/templates/gitvote_secret.yaml index 17a759d..0e3e5b7 100644 --- a/charts/gitvote/templates/gitvote_secret.yaml +++ b/charts/gitvote/templates/gitvote_secret.yaml @@ -1,3 +1,4 @@ +{{- include "chart.validateStrings" . -}} apiVersion: v1 kind: Secret metadata: @@ -5,17 +6,17 @@ metadata: type: Opaque stringData: gitvote.yml: |- - addr: {{ .Values.gitvote.addr }} + addr: {{ .Values.gitvote.addr | quote }} db: - host: {{ default (printf "%s-postgresql.%s" .Release.Name .Release.Namespace) .Values.db.host }} - port: {{ .Values.db.port | atoi }} - dbname: {{ .Values.db.dbname }} - user: {{ .Values.db.user }} - password: {{ .Values.db.password }} + host: {{ default (printf "%s-postgresql.%s" .Release.Name .Release.Namespace) .Values.db.host | quote }} + port: {{ .Values.db.port | int }} + dbname: {{ .Values.db.dbname | quote }} + user: {{ .Values.db.user | quote }} + password: {{ .Values.db.password | quote }} log: - format: {{ .Values.log.format }} + format: {{ .Values.log.format | quote }} github: - appId: {{ .Values.gitvote.github.appID }} + appId: {{ include "chart.githubAppID" . }} appPrivateKey: {{ .Values.gitvote.github.appPrivateKey | quote }} webhookSecret: {{ .Values.gitvote.github.webhookSecret | quote }} {{- with .Values.gitvote.github.webhookSecretFallback }} diff --git a/charts/gitvote/values.yaml b/charts/gitvote/values.yaml index b843131..7a831a6 100644 --- a/charts/gitvote/values.yaml +++ b/charts/gitvote/values.yaml @@ -56,7 +56,7 @@ gitvote: github: # GitHub application ID appID: null - # GitHub application private key path + # GitHub application private key (PEM content, not a file path) appPrivateKey: null # GitHub application webhook secret webhookSecret: null diff --git a/database/migrations/Dockerfile b/database/migrations/Dockerfile index bdf82b0..a2d867d 100644 --- a/database/migrations/Dockerfile +++ b/database/migrations/Dockerfile @@ -1,7 +1,7 @@ # Build tern FROM golang:1.27.3-alpine3.24 AS tern RUN apk --no-cache add git -RUN go install github.com/jackc/tern@latest +RUN go install github.com/jackc/tern/v2@v2.4.3 # Build final image FROM alpine:3.24.4 diff --git a/database/migrations/functions/001_load_functions.sql b/database/migrations/functions/001_load_functions.sql index 7f81366..98e2f81 100644 --- a/database/migrations/functions/001_load_functions.sql +++ b/database/migrations/functions/001_load_functions.sql @@ -1,3 +1,5 @@ +-- Loads all the database functions. + {{ template "util/string_to_interval.sql" }} ---- create above / drop below ---- diff --git a/database/migrations/functions/util/string_to_interval.sql b/database/migrations/functions/util/string_to_interval.sql index 2589166..9a095b0 100644 --- a/database/migrations/functions/util/string_to_interval.sql +++ b/database/migrations/functions/util/string_to_interval.sql @@ -1,10 +1,12 @@ --- Helper function to validate an interval. --- Source: https://stackoverflow.com/a/36777132 +-- Converts a string to an interval, returning null when it is not a valid interval. create or replace function string_to_interval(s text) returns interval as $$ begin + -- Convert the string into an interval return s::interval; -exception when others then - return null; +exception + -- Report any string that cannot be converted as a missing interval + when others then + return null; end; $$ language plpgsql strict; diff --git a/database/migrations/schema/005_vote_state.sql b/database/migrations/schema/005_vote_state.sql new file mode 100644 index 0000000..e470232 --- /dev/null +++ b/database/migrations/schema/005_vote_state.sql @@ -0,0 +1,77 @@ +-- Tracks closing and cancellation side effects and allows only one open vote per issue +-- or pull request. + +-- A vote without results is stored as SQL null, never as JSON null +update vote +set results = null +where results = 'null'::jsonb; + +-- close_pending is set when a vote is closed and cleared once all its side effects are +-- done, so its default marks every vote closed before this migration as complete. Being +-- opt-in also covers the votes closed by a previous version while it is being replaced. +-- close_head_sha is the head of the pull request when the vote was closed, which is the +-- commit that gets the closing check run. It is null in votes on issues, as well as when +-- the head could not be read when closing the vote. cancelled_at and cancelled_by are +-- set when a vote is cancelled, which also closes it and leaves its side effects +-- pending: the vote is deleted once its cancellation has been published, or once +-- publishing it runs out of attempts and it is abandoned. +alter table vote + add column close_attempts integer not null default 0, + add column close_pending boolean not null default false, + + add column cancelled_at timestamptz, + add column cancelled_by text, + add column close_announcement_posted_at timestamptz, + add column close_check_run_created_at timestamptz, + add column close_comment_posted_at timestamptz, + add column close_head_sha text, + add column close_labels_updated_at timestamptz, + add column close_next_attempt_at timestamptz, + + add constraint vote_cancelled_by_chk check ( + (cancelled_at is null) = (cancelled_by is null) + ), + add constraint vote_cancelled_closed_chk check (cancelled_at is null or closed), + add constraint vote_close_attempts_chk check (close_attempts >= 0), + add constraint vote_close_pending_chk check (not close_pending or closed); + +-- If this statement fails with a unique violation, some issue or pull request has more +-- than one open vote, and the whole migration is rolled back. Duplicates are not +-- resolved automatically as that needs a decision about which vote to keep. They can be +-- listed with the query below. To resolve them, delete all but one of the rows of each +-- issue or pull request, and the vote comments of the deleted rows, then migrate again. +-- +-- select repository_full_name, issue_number, array_agg(vote_id order by created_at) +-- from vote +-- where closed = false +-- group by repository_full_name, issue_number +-- having count(*) > 1; +create unique index vote_open_issue_idx on vote (repository_full_name, issue_number) +where closed = false; + +---- create above / drop below ---- + +drop index vote_open_issue_idx; + +-- The previous schema has no cancelled votes, as they were deleted when cancelled. The +-- ones kept here until their cancellation is published or abandoned would otherwise be +-- left as closed votes without results once these columns are dropped. +delete from vote +where cancelled_at is not null; + +alter table vote + drop constraint vote_cancelled_by_chk, + drop constraint vote_cancelled_closed_chk, + drop constraint vote_close_attempts_chk, + drop constraint vote_close_pending_chk, + + drop column cancelled_at, + drop column cancelled_by, + drop column close_announcement_posted_at, + drop column close_attempts, + drop column close_check_run_created_at, + drop column close_comment_posted_at, + drop column close_head_sha, + drop column close_labels_updated_at, + drop column close_next_attempt_at, + drop column close_pending; diff --git a/database/tests/functions/util/string_to_interval.sql b/database/tests/functions/util/string_to_interval.sql new file mode 100644 index 0000000..a5461a3 --- /dev/null +++ b/database/tests/functions/util/string_to_interval.sql @@ -0,0 +1,82 @@ +-- Tests converting a string to an interval. + +-- ============================================================================ +-- SETUP +-- ============================================================================ + +begin; +select plan(9); + +-- ============================================================================ +-- TESTS +-- ============================================================================ + +-- Should convert a negative interval +select is( + string_to_interval('-1 day'), + '-1 day'::interval, + 'Should convert a negative interval' +); + +-- Should convert a number of days +select is( + string_to_interval('5 days'), + '5 days'::interval, + 'Should convert a number of days' +); + +-- Should convert a number of hours +select is( + string_to_interval('24 hours'), + '24 hours'::interval, + 'Should convert a number of hours' +); + +-- Should convert a number of weeks +select is( + string_to_interval('1 week'), + '7 days'::interval, + 'Should convert a number of weeks' +); + +-- Should convert a plain number as seconds +select is( + string_to_interval('5'), + '5 seconds'::interval, + 'Should convert a plain number as seconds' +); + +-- Should return null for a misspelled unit +select is( + string_to_interval('5 dais'), + null::interval, + 'Should return null for a misspelled unit' +); + +-- Should return null for a null string +select is( + string_to_interval(null), + null::interval, + 'Should return null for a null string' +); + +-- Should return null for a value out of range +select is( + string_to_interval('178956971 years'), + null::interval, + 'Should return null for a value out of range' +); + +-- Should return null for an empty string +select is( + string_to_interval(''), + null::interval, + 'Should return null for an empty string' +); + +-- ============================================================================ +-- CLEANUP +-- ============================================================================ + +select * from finish(); +rollback; diff --git a/database/tests/migrations/005_vote_state.sql b/database/tests/migrations/005_vote_state.sql new file mode 100644 index 0000000..3bfc5a5 --- /dev/null +++ b/database/tests/migrations/005_vote_state.sql @@ -0,0 +1,390 @@ +-- Tests applying and reverting the vote state migration on existing votes. + +-- ============================================================================ +-- SETUP +-- ============================================================================ + +begin; +select plan(15); + +-- ============================================================================ +-- VARIABLES +-- ============================================================================ + +\set closedDuplicateVoteID1 '00000000-0000-0000-0000-000000000001' +\set closedDuplicateVoteID2 '00000000-0000-0000-0000-000000000002' +\set closedVoteID '00000000-0000-0000-0000-000000000003' +\set jsonNullVoteID '00000000-0000-0000-0000-000000000004' +\set openVoteID '00000000-0000-0000-0000-000000000005' +\set sqlNullVoteID '00000000-0000-0000-0000-000000000006' + +-- Statements of the migration, read from the repository root +\set down `sed '1,/^---- create above/d' database/migrations/schema/005_vote_state.sql` +\set up `sed '/^---- create above/,$d' database/migrations/schema/005_vote_state.sql` + +-- ============================================================================ +-- SEED DATA +-- ============================================================================ + +-- Return to the schema the migration is applied on +:down + +-- First closed vote in an issue that also has an open vote +insert into vote ( + vote_id, + cfg, + closed, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id, + + closed_at, + results +) values ( + :'closedDuplicateVoteID1', + '{}', + true, + 'user', + '2024-01-01T00:00:00Z', + 1, + false, + 1, + 1, + 'org/repo', + 1, + + '2024-01-01T00:00:00Z', + '{"passed": true}' +); + +-- Second closed vote in an issue that also has an open vote +insert into vote ( + vote_id, + cfg, + closed, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id, + + closed_at, + results +) values ( + :'closedDuplicateVoteID2', + '{}', + true, + 'user', + '2024-02-01T00:00:00Z', + 1, + false, + 1, + 1, + 'org/repo', + 2, + + '2024-02-01T00:00:00Z', + '{"passed": false}' +); + +-- Closed vote with results +insert into vote ( + vote_id, + cfg, + closed, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id, + + closed_at, + results +) values ( + :'closedVoteID', + '{}', + true, + 'user', + '2024-03-01T00:00:00Z', + 1, + true, + 2, + 2, + 'org/repo', + 3, + + '2024-03-01T00:00:00Z', + '{"passed": true}' +); + +-- Closed vote with results stored as JSON null +insert into vote ( + vote_id, + cfg, + closed, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id, + + closed_at, + results +) values ( + :'jsonNullVoteID', + '{}', + true, + 'user', + '2024-04-01T00:00:00Z', + 1, + false, + 3, + 3, + 'org/repo', + 4, + + '2024-04-01T00:00:00Z', + 'null' +); + +-- Open vote in the issue with closed votes +insert into vote ( + vote_id, + cfg, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id +) values ( + :'openVoteID', + '{}', + 'user', + '2024-05-01T00:00:00Z', + 1, + false, + 1, + 1, + 'org/repo', + 5 +); + +-- Closed vote with results stored as SQL null +insert into vote ( + vote_id, + cfg, + closed, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id, + + closed_at +) values ( + :'sqlNullVoteID', + '{}', + true, + 'user', + '2024-06-01T00:00:00Z', + 1, + false, + 4, + 4, + 'org/repo', + 6, + + '2024-06-01T00:00:00Z' +); + +-- ============================================================================ +-- TESTS +-- ============================================================================ + +-- Apply the migration and check the migrated votes; the apply then revert lifecycle +-- order is intentional and scenarios are alphabetized within each phase + +-- Should apply the migration on existing votes +select lives_ok(:'up', 'Should apply the migration on existing votes'); + +-- Should convert JSON null results to SQL null +select is( + ( + select results + from vote + where vote_id = :'jsonNullVoteID' + ), + null::jsonb, + 'Should convert JSON null results to SQL null' +); + +-- Should create the open issue index +select has_index( + 'public', + 'vote', + 'vote_open_issue_idx', + 'Should create the open issue index' +); + +-- Should keep all the existing votes +select is( + ( + select count(*) + from vote + ), + 6::bigint, + 'Should keep all the existing votes' +); + +-- Should keep closed votes results +select is( + ( + select results + from vote + where vote_id = :'closedVoteID' + ), + '{"passed": true}'::jsonb, + 'Should keep closed votes results' +); + +-- Should keep SQL null results +select is( + ( + select results + from vote + where vote_id = :'sqlNullVoteID' + ), + null::jsonb, + 'Should keep SQL null results' +); + +-- Should keep the open vote open +select is( + ( + select closed + from vote + where vote_id = :'openVoteID' + ), + false, + 'Should keep the open vote open' +); + +-- Should leave closed votes as complete so that they are never reprocessed +select is( + ( + select count(*) + from vote + where closed and close_pending + ), + 0::bigint, + 'Should leave closed votes as complete so that they are never reprocessed' +); + +-- Should leave existing votes not cancelled +select is( + ( + select count(*) + from vote + where cancelled_at is not null + or cancelled_by is not null + ), + 0::bigint, + 'Should leave existing votes not cancelled' +); + +-- Should leave existing votes without a close head commit +select is( + ( + select count(*) + from vote + where close_head_sha is not null + ), + 0::bigint, + 'Should leave existing votes without a close head commit' +); + +-- Should leave no retries scheduled for existing votes +select is( + ( + select count(*) + from vote + where close_attempts <> 0 + or close_next_attempt_at is not null + ), + 0::bigint, + 'Should leave no retries scheduled for existing votes' +); + +-- Revert the migration and check the reverted schema + +-- Should revert the migration +select lives_ok(:'down', 'Should revert the migration'); + +-- Should keep all the existing votes after reverting +select is( + ( + select count(*) + from vote + ), + 6::bigint, + 'Should keep all the existing votes after reverting' +); + +-- Should remove the columns added by the migration +select is( + ( + select count(*) + from information_schema.columns + where table_schema = 'public' + and table_name = 'vote' + and column_name in ( + 'cancelled_at', + 'cancelled_by', + 'close_announcement_posted_at', + 'close_attempts', + 'close_check_run_created_at', + 'close_comment_posted_at', + 'close_head_sha', + 'close_labels_updated_at', + 'close_next_attempt_at', + 'close_pending' + ) + ), + 0::bigint, + 'Should remove the columns added by the migration' +); + +-- Should remove the open issue index +select hasnt_index( + 'public', + 'vote', + 'vote_open_issue_idx', + 'Should remove the open issue index' +); + +-- ============================================================================ +-- CLEANUP +-- ============================================================================ + +select * from finish(); +rollback; diff --git a/database/tests/migrations/005_vote_state_cancelled.sql b/database/tests/migrations/005_vote_state_cancelled.sql new file mode 100644 index 0000000..0050756 --- /dev/null +++ b/database/tests/migrations/005_vote_state_cancelled.sql @@ -0,0 +1,182 @@ +-- Tests reverting the vote state migration when some votes are cancelled. + +-- ============================================================================ +-- SETUP +-- ============================================================================ + +begin; +select plan(2); + +-- ============================================================================ +-- VARIABLES +-- ============================================================================ + +\set cancelledVoteID '00000000-0000-0000-0000-000000000001' +\set closedVoteID '00000000-0000-0000-0000-000000000002' +\set openVoteID '00000000-0000-0000-0000-000000000003' +\set pendingVoteID '00000000-0000-0000-0000-000000000004' + +-- Statements of the migration, read from the repository root +\set down `sed '1,/^---- create above/d' database/migrations/schema/005_vote_state.sql` + +-- ============================================================================ +-- SEED DATA +-- ============================================================================ + +-- Cancelled vote whose cancellation is not published yet +insert into vote ( + vote_id, + cfg, + close_pending, + closed, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id, + + cancelled_at, + cancelled_by, + closed_at +) values ( + :'cancelledVoteID', + '{}', + true, + true, + 'user', + '2024-01-01T00:00:00Z', + 1, + true, + 1, + 1, + 'org/repo', + 1, + + '2024-01-01T00:00:00Z', + 'user', + '2024-01-01T00:00:00Z' +); + +-- Closed vote with results in the same pull request +insert into vote ( + vote_id, + cfg, + closed, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id, + + closed_at, + results +) values ( + :'closedVoteID', + '{}', + true, + 'user', + '2024-02-01T00:00:00Z', + 1, + true, + 1, + 1, + 'org/repo', + 2, + + '2024-02-01T00:00:00Z', + '{"passed": true}' +); + +-- Open vote in the same pull request +insert into vote ( + vote_id, + cfg, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id +) values ( + :'openVoteID', + '{}', + 'user', + '2024-03-01T00:00:00Z', + 1, + true, + 1, + 1, + 'org/repo', + 3 +); + +-- Closed vote without results whose side effects are pending in the same pull request +insert into vote ( + vote_id, + cfg, + close_pending, + closed, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id, + + closed_at +) values ( + :'pendingVoteID', + '{}', + true, + true, + 'user', + '2024-02-15T00:00:00Z', + 1, + true, + 1, + 1, + 'org/repo', + 4, + + '2024-02-15T00:00:00Z' +); + +-- ============================================================================ +-- TESTS +-- ============================================================================ + +-- Should revert the migration with cancelled votes +select lives_ok(:'down', 'Should revert the migration with cancelled votes'); + +-- Should delete only the cancelled votes +select results_eq( + $$ + select vote_id + from vote + order by vote_id + $$, + format( + $$values (%L::uuid), (%L::uuid), (%L::uuid)$$, + :'closedVoteID', + :'openVoteID', + :'pendingVoteID' + ), + 'Should delete only the cancelled votes' +); + +-- ============================================================================ +-- CLEANUP +-- ============================================================================ + +select * from finish(); +rollback; diff --git a/database/tests/migrations/005_vote_state_duplicates.sql b/database/tests/migrations/005_vote_state_duplicates.sql new file mode 100644 index 0000000..3f628f8 --- /dev/null +++ b/database/tests/migrations/005_vote_state_duplicates.sql @@ -0,0 +1,95 @@ +-- Tests applying the vote state migration when an issue has more than one open vote. + +-- ============================================================================ +-- SETUP +-- ============================================================================ + +begin; +select plan(1); + +-- ============================================================================ +-- VARIABLES +-- ============================================================================ + +\set firstOpenVoteID '00000000-0000-0000-0000-000000000001' +\set secondOpenVoteID '00000000-0000-0000-0000-000000000002' + +-- Statements of the migration, read from the repository root +\set down `sed '1,/^---- create above/d' database/migrations/schema/005_vote_state.sql` +\set up `sed '/^---- create above/,$d' database/migrations/schema/005_vote_state.sql` + +-- ============================================================================ +-- SEED DATA +-- ============================================================================ + +-- Return to the schema the migration is applied on +:down + +-- First open vote in the same issue +insert into vote ( + vote_id, + cfg, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id +) values ( + :'firstOpenVoteID', + '{}', + 'user', + '2024-01-01T00:00:00Z', + 1, + false, + 1, + 1, + 'org/repo', + 1 +); + +-- Second open vote in the same issue +insert into vote ( + vote_id, + cfg, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id +) values ( + :'secondOpenVoteID', + '{}', + 'user', + '2024-01-01T00:00:00Z', + 1, + false, + 1, + 1, + 'org/repo', + 2 +); + +-- ============================================================================ +-- TESTS +-- ============================================================================ + +-- Should fail when an issue has more than one open vote +select throws_ok( + :'up', + '23505', + 'could not create unique index "vote_open_issue_idx"', + 'Should fail when an issue has more than one open vote' +); + +-- ============================================================================ +-- CLEANUP +-- ============================================================================ + +select * from finish(); +rollback; diff --git a/database/tests/schema/schema.sql b/database/tests/schema/schema.sql new file mode 100644 index 0000000..828c358 --- /dev/null +++ b/database/tests/schema/schema.sql @@ -0,0 +1,490 @@ +-- Tests the database schema. + +-- ============================================================================ +-- SETUP +-- ============================================================================ + +begin; +select plan(19); + +-- ============================================================================ +-- VARIABLES +-- ============================================================================ + +\set closedVoteID '00000000-0000-0000-0000-000000000001' +\set openVoteID '00000000-0000-0000-0000-000000000002' + +-- ============================================================================ +-- SEED DATA +-- ============================================================================ + +-- Closed vote in the issue shared with the open vote +insert into vote ( + vote_id, + cfg, + closed, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id +) values ( + :'closedVoteID', + '{}', + true, + 'user', + current_timestamp, + 1, + false, + 1, + 1, + 'org/repo', + 1 +); + +-- Open vote in the issue shared with the closed vote +insert into vote ( + vote_id, + cfg, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id +) values ( + :'openVoteID', + '{}', + 'user', + current_timestamp, + 1, + false, + 1, + 1, + 'org/repo', + 2 +); + +-- ============================================================================ +-- TESTS +-- ============================================================================ + +-- Test: extensions +select has_extension('pgcrypto'); + +-- Test: functions +select has_function('public', 'string_to_interval', array['text']); + +-- Test: tables +select tables_are( + 'public', + array[ + 'version_functions', + 'version_schema', + 'vote' + ] +); + +-- Test: vote check constraints +select results_eq( + $$ + select + conname::text collate "default", + pg_get_constraintdef(oid) collate "default" + from pg_constraint + where conrelid = 'public.vote'::regclass + and contype = 'c' + order by conname collate "C" + $$, + $$ + values + ('vote_cancelled_by_chk', 'CHECK (((cancelled_at IS NULL) = (cancelled_by IS NULL)))'), + ('vote_cancelled_closed_chk', 'CHECK (((cancelled_at IS NULL) OR closed))'), + ('vote_close_attempts_chk', 'CHECK ((close_attempts >= 0))'), + ('vote_close_pending_chk', 'CHECK (((NOT close_pending) OR closed))') + $$, + 'Vote should have the expected check constraints' +); + +-- Test: vote columns +select columns_are( + 'public', + 'vote', + array[ + 'vote_id', + 'cfg', + 'close_attempts', + 'close_pending', + 'closed', + 'created_at', + 'created_by', + 'ends_at', + 'installation_id', + 'is_pull_request', + 'issue_id', + 'issue_number', + 'repository_full_name', + 'vote_comment_id', + + 'cancelled_at', + 'cancelled_by', + 'checked_at', + 'close_announcement_posted_at', + 'close_check_run_created_at', + 'close_comment_posted_at', + 'close_head_sha', + 'close_labels_updated_at', + 'close_next_attempt_at', + 'closed_at', + 'issue_title', + 'organization', + 'results' + ] +); + +-- Test: vote columns definition +select results_eq( + $$ + select + column_name::text collate "default", + data_type::text collate "default", + is_nullable::text collate "default", + column_default::text collate "default" + from information_schema.columns + where table_schema = 'public' + and table_name = 'vote' + order by column_name collate "C" + $$, + $$ + values + ('cancelled_at', 'timestamp with time zone', 'YES', null), + ('cancelled_by', 'text', 'YES', null), + ('cfg', 'jsonb', 'NO', null), + ('checked_at', 'timestamp with time zone', 'YES', null), + ('close_announcement_posted_at', 'timestamp with time zone', 'YES', null), + ('close_attempts', 'integer', 'NO', '0'), + ('close_check_run_created_at', 'timestamp with time zone', 'YES', null), + ('close_comment_posted_at', 'timestamp with time zone', 'YES', null), + ('close_head_sha', 'text', 'YES', null), + ('close_labels_updated_at', 'timestamp with time zone', 'YES', null), + ('close_next_attempt_at', 'timestamp with time zone', 'YES', null), + ('close_pending', 'boolean', 'NO', 'false'), + ('closed', 'boolean', 'NO', 'false'), + ('closed_at', 'timestamp with time zone', 'YES', null), + ('created_at', 'timestamp with time zone', 'NO', 'CURRENT_TIMESTAMP'), + ('created_by', 'text', 'NO', null), + ('ends_at', 'timestamp with time zone', 'NO', null), + ('installation_id', 'bigint', 'NO', null), + ('is_pull_request', 'boolean', 'NO', null), + ('issue_id', 'bigint', 'NO', null), + ('issue_number', 'bigint', 'NO', null), + ('issue_title', 'text', 'YES', null), + ('organization', 'text', 'YES', null), + ('repository_full_name', 'text', 'NO', null), + ('results', 'jsonb', 'YES', null), + ('vote_comment_id', 'bigint', 'NO', null), + ('vote_id', 'uuid', 'NO', 'gen_random_uuid()') + $$, + 'Vote columns should have the expected types, nullability and defaults' +); + +-- Test: vote indexes +select indexes_are('public', 'vote', array['vote_open_issue_idx', 'vote_pkey']); + +-- Test: vote open issue index definition +select is( + pg_get_indexdef('public.vote_open_issue_idx'::regclass), + 'CREATE UNIQUE INDEX vote_open_issue_idx ON public.vote USING btree ' + '(repository_full_name, issue_number) WHERE (closed = false)', + 'Vote open issue index should be unique, partial and on the expected columns' +); + +-- Test: vote primary key +select col_is_pk('public', 'vote', 'vote_id', 'Vote id should be the primary key'); + +-- Should allow a cancelled closed vote +select lives_ok( + $$ + insert into vote ( + cfg, + closed, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id, + + cancelled_at, + cancelled_by + ) values ( + '{}', + true, + 'user', + current_timestamp, + 1, + false, + 2, + 2, + 'org/repo', + 6, + + current_timestamp, + 'user' + ) + $$, + 'Should allow a cancelled closed vote' +); + +-- Should allow an open vote in the same issue number of another repository +select lives_ok( + $$ + insert into vote ( + cfg, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id + ) values ( + '{}', + 'user', + current_timestamp, + 1, + false, + 1, + 1, + 'org/other-repo', + 4 + ) + $$, + 'Should allow an open vote in the same issue number of another repository' +); + +-- Should allow another closed vote in an issue with an open vote +select lives_ok( + $$ + insert into vote ( + cfg, + closed, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id + ) values ( + '{}', + true, + 'user', + current_timestamp, + 1, + false, + 1, + 1, + 'org/repo', + 3 + ) + $$, + 'Should allow another closed vote in an issue with an open vote' +); + +-- Should allow close pending on a closed vote +select lives_ok( + format( + $$ + update vote + set close_pending = true + where vote_id = %L + $$, + :'closedVoteID' + ), + 'Should allow close pending on a closed vote' +); + +-- Should reject a cancellation time without the user who cancelled the vote +select throws_ok( + $$ + insert into vote ( + cfg, + closed, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id, + + cancelled_at + ) values ( + '{}', + true, + 'user', + current_timestamp, + 1, + false, + 2, + 2, + 'org/repo', + 7, + + current_timestamp + ) + $$, + '23514', + 'new row for relation "vote" violates check constraint "vote_cancelled_by_chk"', + 'Should reject a cancellation time without the user who cancelled the vote' +); + +-- Should reject a cancelled open vote +select throws_ok( + $$ + insert into vote ( + cfg, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id, + + cancelled_at, + cancelled_by + ) values ( + '{}', + 'user', + current_timestamp, + 1, + false, + 3, + 3, + 'org/repo', + 8, + + current_timestamp, + 'user' + ) + $$, + '23514', + 'new row for relation "vote" violates check constraint "vote_cancelled_closed_chk"', + 'Should reject a cancelled open vote' +); + +-- Should reject a negative number of close attempts +select throws_ok( + format( + $$ + update vote + set close_attempts = -1 + where vote_id = %L + $$, + :'openVoteID' + ), + '23514', + 'new row for relation "vote" violates check constraint "vote_close_attempts_chk"', + 'Should reject a negative number of close attempts' +); + +-- Should reject a second open vote in an issue +select throws_ok( + $$ + insert into vote ( + cfg, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id + ) values ( + '{}', + 'user', + current_timestamp, + 1, + false, + 1, + 1, + 'org/repo', + 5 + ) + $$, + '23505', + 'duplicate key value violates unique constraint "vote_open_issue_idx"', + 'Should reject a second open vote in an issue' +); + +-- Should reject a user who cancelled the vote without a cancellation time +select throws_ok( + $$ + insert into vote ( + cfg, + closed, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id, + + cancelled_by + ) values ( + '{}', + true, + 'user', + current_timestamp, + 1, + false, + 2, + 2, + 'org/repo', + 9, + + 'user' + ) + $$, + '23514', + 'new row for relation "vote" violates check constraint "vote_cancelled_by_chk"', + 'Should reject a user who cancelled the vote without a cancellation time' +); + +-- Should reject close pending on an open vote +select throws_ok( + format( + $$ + update vote + set close_pending = true + where vote_id = %L + $$, + :'openVoteID' + ), + '23514', + 'new row for relation "vote" violates check constraint "vote_close_pending_chk"', + 'Should reject close pending on an open vote' +); + +-- ============================================================================ +-- CLEANUP +-- ============================================================================ + +select * from finish(); +rollback; diff --git a/docs/config/.gitvote.yml b/docs/config/.gitvote.yml index d951f90..0e5f1b6 100644 --- a/docs/config/.gitvote.yml +++ b/docs/config/.gitvote.yml @@ -33,13 +33,20 @@ audit: # # Create votes automatically on PRs when any of the files affected by the PR # match any of the patterns provided. Patterns must follow the gitignore -# format (https://git-scm.com/docs/gitignore#_pattern_format). +# format (https://git-scm.com/docs/gitignore#_pattern_format), so a pattern +# that matches a directory (i.e. docs/) matches all the files below it too. # # Each automation rule must include a list of patterns and the profile to use # when creating the vote. This allows creating votes automatically using the # desired configuration based on the patterns matched. Rules are processed in # the order provided, and the first match wins. # +# Please note that only repository collaborators can create votes, and this +# also applies to automatic votes. The vote is created on behalf of the user +# who opens the PR, so when that user is not a collaborator no vote is created +# and GitVote posts a comment saying so. In that case, a collaborator can start +# the vote by calling the /vote command. +# # automation: # enabled: true # rules: @@ -71,11 +78,15 @@ profiles: # # Units supported (can be combined as in 1hour 30mins): # + # seconds | second | secs | sec | s # minutes | minute | mins | min | m # hours | hour | hrs | hrs | h # days | day | d # weeks | week | w # + # It must be at least 1 second, and it cannot be greater than 36500 days. Any + # other duration is rejected as an invalid configuration when creating the vote. + # duration: 5m # Pass threshold (required) @@ -181,6 +192,11 @@ profiles: # Automatic status checks won't be run if the vote will be closed within # the next hour. # + # The value must be a whole number followed by one of the units below, + # separated by a space. It must be at least 1 day and at most 36500 days. + # Any other value is rejected as an invalid configuration when creating the + # vote. + # # Units supported: # # - day / days @@ -221,6 +237,11 @@ profiles: # already pass. This allows participants sufficient opportunity to engage # and reflect before the vote is automatically finalized. # + # The value must be a whole number followed by one of the units below, + # separated by a space. It must be at least 1 day and at most 36500 days. + # Any other value is rejected as an invalid configuration when creating the + # vote. + # # Units supported: # # - day / days diff --git a/src/cfg_repo.rs b/src/cfg_repo.rs index a25e4a0..edf27b1 100644 --- a/src/cfg_repo.rs +++ b/src/cfg_repo.rs @@ -11,42 +11,69 @@ use thiserror::Error; use crate::github::{DynGH, File, TeamSlug, UserName}; +#[cfg(test)] +mod tests; + /// Default configuration profile. const DEFAULT_PROFILE: &str = "default"; +/// Error message used when the close on passing minimum wait is not a valid period. +const ERR_INVALID_CLOSE_ON_PASSING_MIN_WAIT: &str = "close_on_passing_min_wait must be a number of days \ + or weeks between 1 day and 36500 days, such as 5 days or 1 week"; + +/// Error message used when the duration is outside the range allowed. +const ERR_INVALID_DURATION: &str = "duration must be at least 1 second and not greater than 36500 days"; + /// Error message used when the pass threshold is not a valid percentage. const ERR_INVALID_PASS_THRESHOLD: &str = "pass threshold must be greater than 0 and not greater than 100"; +/// Error message used when the periodic status check is not a valid period. +const ERR_INVALID_PERIODIC_STATUS_CHECK: &str = "periodic_status_check must be a number of days or weeks \ + between 1 day and 36500 days, such as 5 days or 1 week"; + /// Error message used when teams are listed in the allowed voters section on a /// repository that does not belong to an organization. const ERR_TEAMS_NOT_ALLOWED: &str = "teams in allowed voters can only be used in organizations"; -/// Type alias to represent a profile name. -type ProfileName = String; +/// Maximum duration of a vote, the same span allowed for a period. Comparing against it +/// needs no arithmetic on the value configured, so any duration that fits in the type is +/// safely handled. +const MAX_DURATION: Duration = Duration::from_hours(MAX_PERIOD_DAYS * 24); + +/// Maximum number of days a period can span. It is far beyond any useful value, and it +/// keeps periods well within the range of a PostgreSQL interval. +const MAX_PERIOD_DAYS: u64 = 36_500; + +/// Minimum duration of a vote. Votes end a whole number of seconds after being created, +/// so a shorter duration would end the vote as soon as it is created. +const MIN_DURATION: Duration = Duration::from_secs(1); /// `GitVote` configuration. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub(crate) struct Cfg { + /// Vote configuration profiles, by name. pub profiles: HashMap, + /// Audit configuration. #[serde(skip_serializing_if = "Option::is_none")] pub audit: Option, + /// Automation configuration. #[serde(skip_serializing_if = "Option::is_none")] pub automation: Option, } impl Cfg { - /// Get the `GitVote` configuration for the repository provided. + /// Gets the `GitVote` configuration for the repository provided. pub(crate) async fn get<'a>( gh: DynGH, inst_id: u64, owner: &'a str, repo: &'a str, ) -> Result { - match gh.get_config_file(inst_id, owner, repo).await { + match gh.get_config_file(inst_id, owner, repo).await.map_err(CfgError::ConfigLookup)? { Some(content) => { let cfg: Cfg = - serde_yaml::from_str(&content).map_err(|e| CfgError::InvalidConfig(e.to_string()))?; + serde_yaml::from_str(&content).map_err(|err| CfgError::InvalidConfig(err.to_string()))?; Ok(cfg) } None => Err(CfgError::ConfigNotFound), @@ -54,50 +81,112 @@ impl Cfg { } } +/// Represents the teams and users allowed to vote. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub(crate) struct AllowedVoters { + /// Whether the maintainers of the teams listed are excluded from the voters. + #[serde(skip_serializing_if = "Option::is_none")] + pub exclude_team_maintainers: Option, + /// Teams whose members are allowed to vote. + #[serde(skip_serializing_if = "Option::is_none")] + pub teams: Option>, + /// Users allowed to vote. + #[serde(skip_serializing_if = "Option::is_none")] + pub users: Option>, +} + +/// Announcements configuration. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub(crate) struct Announcements { + /// GitHub discussions announcements configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub discussions: Option, +} + /// Audit configuration. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub(crate) struct Audit { + /// Whether the audit page of the repository is enabled. pub enabled: bool, } /// Automation configuration. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub(crate) struct Automation { + /// Whether votes are created automatically on pull requests. pub enabled: bool, + /// Rules evaluated to decide if a vote is created, in order. pub rules: Vec, } /// Automation rule. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub(crate) struct AutomationRule { + /// Patterns, in the gitignore format, the pull request files are matched against. pub patterns: Vec, + /// Name of the profile used to create the vote when the rule matches. pub profile: ProfileName, } impl AutomationRule { - /// Check if any of the files provided matches any of the rule patterns. - /// Patterns must follow the gitignore format. + /// Checks if any of the files provided matches any of the rule patterns. + /// Patterns must follow the gitignore format, so a pattern matching a directory + /// also matches all the files below it. pub(crate) fn matches(&self, files: &[File]) -> Result { + // Build a gitignore matcher from the rule patterns let mut builder = GitignoreBuilder::new("/"); for pattern in &self.patterns { builder.add_line(None, pattern)?; } let checker = builder.build()?; - let matches = files.iter().any(|file| checker.matched(&file.filename, false).is_ignore()); + + // Check if any of the files, or any of its parent directories, matches + let matches = files + .iter() + .any(|file| checker.matched_path_or_any_parents(&file.filename, false).is_ignore()); Ok(matches) } } +/// Errors that may occur while getting the configuration profile. +#[derive(Debug, Error)] +pub(crate) enum CfgError { + /// The config file could not be looked up in the repository. + #[error("error looking up config file")] + ConfigLookup(#[source] anyhow::Error), + /// The repository has no config file. + #[error("config not found")] + ConfigNotFound, + /// The config file is not valid. + #[error("invalid config: {0}")] + InvalidConfig(String), + /// The profile requested is not defined in the config file. + #[error("profile not found")] + ProfileNotFound, +} + /// Vote configuration profile. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub(crate) struct CfgProfile { + /// Time the vote stays open. #[serde(with = "humantime_serde")] pub duration: Duration, + /// Percentage of votes in favor required for the vote to pass. pub pass_threshold: f64, + + /// Teams and users allowed to vote. #[serde(skip_serializing_if = "Option::is_none")] pub allowed_voters: Option, + /// Announcements configuration. #[serde(skip_serializing_if = "Option::is_none")] pub announcements: Option, + /// Whether the vote is closed as soon as it passes. + #[serde(skip_serializing_if = "Option::is_none")] + pub close_on_passing: Option, + /// Minimum period the vote stays open before being closed on passing. + #[serde(skip_serializing_if = "Option::is_none")] + pub close_on_passing_min_wait: Option, + /// Base used to calculate the pass threshold percentage. // The singleton map representation allows using the `votes_cast:` map syntax // in YAML, instead of the `!votes_cast` tag expected by default #[serde( @@ -106,16 +195,13 @@ pub(crate) struct CfgProfile { with = "serde_yaml::with::singleton_map" )] pub pass_threshold_base: Option, + /// Period between the status checks run automatically while the vote is open. #[serde(skip_serializing_if = "Option::is_none")] pub periodic_status_check: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub close_on_passing: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub close_on_passing_min_wait: Option, } impl CfgProfile { - /// Get the vote configuration profile requested from the config file in + /// Gets the vote configuration profile requested from the config file in /// the repository if available. pub(crate) async fn get<'a>( gh: DynGH, @@ -125,7 +211,10 @@ impl CfgProfile { repo: &'a str, profile_name: Option, ) -> Result { + // Get the repository configuration let mut cfg = Cfg::get(gh, inst_id, owner, repo).await?; + + // Pick the profile requested, or the default one, and validate it let profile_name = profile_name.unwrap_or_else(|| DEFAULT_PROFILE.to_string()); match cfg.profiles.remove(&profile_name) { Some(profile) => match profile.validate(is_org) { @@ -136,16 +225,31 @@ impl CfgProfile { } } - /// Check if the configuration profile is valid. + /// Checks if the configuration profile is valid. fn validate(&self, is_org: bool) -> Result<()> { + // A vote must stay open for some time. The end of the vote is calculated by + // PostgreSQL from the duration, which can be out of its range when too large + if !(MIN_DURATION..=MAX_DURATION).contains(&self.duration) { + bail!(ERR_INVALID_DURATION); + } + // The pass threshold must be a percentage in the (0, 100] range. Written - // as a negated range check so that NaN values are rejected as well. + // as a negated range check so that NaN values are rejected as well if !(self.pass_threshold > 0.0 && self.pass_threshold <= 100.0) { bail!(ERR_INVALID_PASS_THRESHOLD); } + // The periods are stored in the vote and read by PostgreSQL as intervals, so + // only the documented format is accepted + if !self.close_on_passing_min_wait.as_deref().is_none_or(is_valid_period) { + bail!(ERR_INVALID_CLOSE_ON_PASSING_MIN_WAIT); + } + if !self.periodic_status_check.as_deref().is_none_or(is_valid_period) { + bail!(ERR_INVALID_PERIODIC_STATUS_CHECK); + } + // Only repositories that belong to some organization can use teams in - // the allowed voters configuration section. + // the allowed voters configuration section if !is_org && let Some(teams) = self.allowed_voters.as_ref().and_then(|allowed_voters| allowed_voters.teams.as_ref()) @@ -158,27 +262,10 @@ impl CfgProfile { } } -/// Represents the teams and users allowed to vote. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -pub(crate) struct AllowedVoters { - #[serde(skip_serializing_if = "Option::is_none")] - pub teams: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub users: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub exclude_team_maintainers: Option, -} - -/// Announcements configuration. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] -pub(crate) struct Announcements { - #[serde(skip_serializing_if = "Option::is_none")] - pub discussions: Option, -} - /// GitHub discussions announcements configuration. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub(crate) struct DiscussionsAnnouncements { + /// Slug of the discussions category where the results are announced. pub category: String, } @@ -191,21 +278,33 @@ pub(crate) enum PassThresholdBase { AllowedVoters, /// Binding votes cast. VotesCast { + /// Whether abstentions are left out of the votes cast. #[serde(skip_serializing_if = "Option::is_none")] exclude_abstentions: Option, }, } -/// Errors that may occur while getting the configuration profile. -#[derive(Debug, Error, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) enum CfgError { - #[error("config not found")] - ConfigNotFound, - #[error("invalid config: {0}")] - InvalidConfig(String), - #[error("profile not found")] - ProfileNotFound, -} +/// Type alias to represent a profile name. +type ProfileName = String; -#[cfg(test)] -mod tests; +/// Checks if the value provided is a period in the ` day(s)` or ` week(s)` format, +/// where `n` is at least 1 and the period does not exceed the maximum allowed. +fn is_valid_period(value: &str) -> bool { + // Split the number and the unit + let Some((number, unit)) = value.split_once(' ') else { + return false; + }; + + // Get the number of days in a unit + let days_per_unit = match unit { + "day" | "days" => 1, + "week" | "weeks" => 7, + _ => return false, + }; + + // Check the number, which parsing alone would allow to have a sign + number.bytes().all(|byte| byte.is_ascii_digit()) + && number.parse::().is_ok_and(|number| { + number >= 1 && number.checked_mul(days_per_unit).is_some_and(|days| days <= MAX_PERIOD_DAYS) + }) +} diff --git a/src/cfg_repo/tests.rs b/src/cfg_repo/tests.rs index e1aeeaa..ed30295 100644 --- a/src/cfg_repo/tests.rs +++ b/src/cfg_repo/tests.rs @@ -1,14 +1,48 @@ -use std::sync::Arc; +use std::{collections::BTreeSet, sync::Arc}; +use anyhow::format_err; use futures::future; use mockall::predicate::eq; use serde_json::json; +use serde_yaml::{Mapping, Value}; -use crate::github::MockGH; -use crate::testutil::*; +use crate::{github::MockGH, testutil::*}; use super::*; +/// Configuration reference published for users. +const CONFIG_REFERENCE: &str = include_str!("../../docs/config/.gitvote.yml"); + +/// Periods that must be rejected by the validation. +const INVALID_PERIODS: &[&str] = &[ + "", + " ", + "5", + "days", + "0 days", + "0 weeks", + "-1 days", + "+1 days", + "1.5 days", + "5 dayz", + "5days", + "5 days", + " 5 days", + "5 days ", + "5 Days", + "1 WEEK", + "1 hour", + "1 month", + "1 week 2 days", + "one week", + "every 5 days", + "٣ days", + "36501 days", + "5215 weeks", + "18446744073709551615 weeks", + "18446744073709551616 days", +]; + #[test] fn automation_rule_does_not_match() { // Setup automation rule @@ -16,6 +50,7 @@ fn automation_rule_does_not_match() { patterns: vec!["path/image.svg".to_string()], profile: "default".to_string(), }; + // Check unrelated paths do not match assert!( !rule @@ -71,13 +106,13 @@ fn automation_rule_invalid_pattern_returns_error() { profile: "default".to_string(), }; - // Check invalid pattern returns an error - assert!( - rule.matches(&[File { - filename: "README.md".to_string() + // Check invalid pattern returns an error naming it + let err = rule + .matches(&[File { + filename: "README.md".to_string(), }]) - .is_err() - ); + .unwrap_err(); + assert!(err.to_string().contains("docs/{a,b"), "error: {err}"); } #[test] @@ -87,6 +122,7 @@ fn automation_rule_matches() { patterns: vec!["*.md".to_string(), "file.txt".to_string()], profile: "default".to_string(), }; + // Check matching paths are accepted assert!( rule.matches(&[File { @@ -102,6 +138,32 @@ fn automation_rule_matches() { ); } +#[test] +fn automation_rule_matches_anchored_pattern_only_from_root() { + // Setup automation rule + let rule = AutomationRule { + patterns: vec!["/README.md".to_string()], + profile: "default".to_string(), + }; + + // Check root path matches + assert!( + rule.matches(&[File { + filename: "README.md".to_string() + }]) + .unwrap() + ); + + // Check nested path does not match + assert!( + !rule + .matches(&[File { + filename: "docs/README.md".to_string() + }]) + .unwrap() + ); +} + #[test] fn automation_rule_matches_any_of_the_files() { // Setup automation rule @@ -125,28 +187,42 @@ fn automation_rule_matches_any_of_the_files() { } #[test] -fn automation_rule_matches_anchored_pattern_only_from_root() { - // Setup automation rule - let rule = AutomationRule { - patterns: vec!["/README.md".to_string()], - profile: "default".to_string(), - }; +fn automation_rule_matches_directory_patterns() { + // Setup cases (patterns, file, expected result) + let cases = [ + (vec!["docs/"], "docs/guide.md", true), + (vec!["docs"], "docs/guide.md", true), + (vec!["/docs/"], "docs/guide.md", true), + (vec!["docs/"], "docs/nested/deep/guide.md", true), + (vec!["docs/"], "src/docs/guide.md", true), + (vec![".github/"], ".github/workflows/ci.yml", true), + (vec!["src/testdata"], "src/testdata/config.yml", true), + (vec!["docs/**"], "docs/guide.md", true), + (vec!["/docs/"], "src/docs/guide.md", false), + (vec!["docs/"], "docs", false), + (vec!["docs/"], "documentation/guide.md", false), + (vec!["src/testdata"], "testdata/config.yml", false), + (vec!["docs/", "!docs/keep.md"], "docs/keep.md", false), + (vec!["docs/", "!docs/keep.md"], "docs/other.md", true), + ]; - // Check root path matches - assert!( - rule.matches(&[File { - filename: "README.md".to_string() - }]) - .unwrap() - ); - // Check nested path does not match - assert!( - !rule - .matches(&[File { - filename: "docs/README.md".to_string() - }]) - .unwrap() - ); + for (patterns, filename, expected) in cases { + // Setup automation rule + let rule = AutomationRule { + patterns: patterns.iter().map(ToString::to_string).collect(), + profile: "default".to_string(), + }; + + // Check the file is matched as expected + let files = [File { + filename: filename.to_string(), + }]; + assert_eq!( + rule.matches(&files).unwrap(), + expected, + "patterns: {patterns:?}, file: {filename}" + ); + } } #[test] @@ -194,21 +270,22 @@ fn cfg_profile_deserialize_pass_threshold_base() { #[test] fn cfg_profile_deserialize_pass_threshold_base_invalid() { - // Setup invalid YAML snippets + // Setup cases (invalid YAML snippet, expected error fragment) let cases = [ - "pass_threshold_base: other", - "pass_threshold_base: votes_cast", - "pass_threshold_base:\n allowed_voters: {}", - "pass_threshold_base:\n votes_cast:\n exclude_abstentions: \"yes\"", + ("pass_threshold_base: other", "unknown variant `other`"), + ("pass_threshold_base: votes_cast", "expected struct variant"), + ("pass_threshold_base:\n allowed_voters: {}", "invalid type: map"), + ( + "pass_threshold_base:\n votes_cast:\n exclude_abstentions: \"yes\"", + "invalid type: string \"yes\", expected a boolean", + ), ]; - for snippet in cases { - // Check the profile is rejected + for (snippet, expected_error) in cases { + // Check the profile is rejected for the expected reason let yaml = format!("duration: 5m\npass_threshold: 50\n{snippet}\n"); - assert!( - serde_yaml::from_str::(&yaml).is_err(), - "snippet: {snippet}" - ); + let err = serde_yaml::from_str::(&yaml).unwrap_err().to_string(); + assert!(err.contains(expected_error), "snippet: {snippet}, error: {err}"); } } @@ -219,18 +296,18 @@ fn cfg_profile_deserialize_stored_json() { "duration": "1month 2days 3h", "pass_threshold": 66.5, "allowed_voters": { + "exclude_team_maintainers": true, "teams": [TEAM1], - "users": [USER1], - "exclude_team_maintainers": true + "users": [USER1] }, "announcements": { "discussions": { "category": DISCUSSIONS_CATEGORY } }, - "periodic_status_check": "1 week", "close_on_passing": true, - "close_on_passing_min_wait": "2 days" + "close_on_passing_min_wait": "2 days", + "periodic_status_check": "1 week" }); // Check it deserializes into the expected profile @@ -240,20 +317,21 @@ fn cfg_profile_deserialize_stored_json() { CfgProfile { duration: Duration::from_secs(2_630_016 + 2 * 86_400 + 3 * 3_600), pass_threshold: 66.5, + allowed_voters: Some(AllowedVoters { + exclude_team_maintainers: Some(true), teams: Some(vec![TEAM1.to_string()]), users: Some(vec![USER1.to_string()]), - exclude_team_maintainers: Some(true), }), announcements: Some(Announcements { discussions: Some(DiscussionsAnnouncements { category: DISCUSSIONS_CATEGORY.to_string(), }), }), - pass_threshold_base: None, - periodic_status_check: Some("1 week".to_string()), close_on_passing: Some(true), close_on_passing_min_wait: Some("2 days".to_string()), + pass_threshold_base: None, + periodic_status_check: Some("1 week".to_string()), } ); } @@ -310,6 +388,7 @@ fn cfg_profile_deserialize_stored_json_pass_threshold_base() { CfgProfile { duration: Duration::from_mins(5), pass_threshold: 50.0, + pass_threshold_base: Some(expected), ..Default::default() }, @@ -331,22 +410,23 @@ fn cfg_profile_serialize_full() { let cfg = CfgProfile { duration: Duration::from_hours(49), pass_threshold: 75.0, + allowed_voters: Some(AllowedVoters { + exclude_team_maintainers: Some(false), teams: Some(vec![TEAM1.to_string()]), users: Some(vec![USER1.to_string()]), - exclude_team_maintainers: Some(false), }), announcements: Some(Announcements { discussions: Some(DiscussionsAnnouncements { category: DISCUSSIONS_CATEGORY.to_string(), }), }), + close_on_passing: Some(true), + close_on_passing_min_wait: Some("1 hour".to_string()), pass_threshold_base: Some(PassThresholdBase::VotesCast { exclude_abstentions: Some(true), }), periodic_status_check: Some("1 day".to_string()), - close_on_passing: Some(true), - close_on_passing_min_wait: Some("1 hour".to_string()), }; // Check the JSON stored in the database (keys are used in SQL queries) @@ -356,23 +436,23 @@ fn cfg_profile_serialize_full() { "duration": "2days 1h", "pass_threshold": 75.0, "allowed_voters": { + "exclude_team_maintainers": false, "teams": [TEAM1], - "users": [USER1], - "exclude_team_maintainers": false + "users": [USER1] }, "announcements": { "discussions": { "category": DISCUSSIONS_CATEGORY } }, + "close_on_passing": true, + "close_on_passing_min_wait": "1 hour", "pass_threshold_base": { "votes_cast": { "exclude_abstentions": true } }, - "periodic_status_check": "1 day", - "close_on_passing": true, - "close_on_passing_min_wait": "1 hour" + "periodic_status_check": "1 day" }) ); } @@ -383,6 +463,7 @@ fn cfg_profile_serialize_skips_unset_optional_fields() { let cfg = CfgProfile { duration: Duration::from_mins(5), pass_threshold: 50.0, + allowed_voters: Some(AllowedVoters::default()), announcements: Some(Announcements::default()), ..Default::default() @@ -400,11 +481,56 @@ fn cfg_profile_serialize_skips_unset_optional_fields() { ); } +#[test] +fn cfg_profile_validate_duration_limit() { + // Setup cases (duration, expected to be valid) + let cases = [ + (Duration::ZERO, false), + (Duration::from_nanos(1), false), + (Duration::from_millis(999), false), + (Duration::from_nanos(999_999_999), false), + (MIN_DURATION, true), + (Duration::from_millis(1500), true), + (Duration::from_mins(1), true), + (Duration::from_mins(5), true), + (Duration::from_hours(12), true), + (Duration::from_secs(36_500 * 86_400 - 1), true), + (Duration::from_hours(36_500 * 24), true), + (MAX_DURATION + Duration::from_nanos(1), false), + (MAX_DURATION + Duration::from_secs(1), false), + (Duration::from_hours(36_501 * 24), false), + (Duration::from_secs(u64::MAX), false), + (Duration::MAX, false), + ]; + + for (duration, expected_valid) in cases { + // Setup profile with the duration provided + let cfg = CfgProfile { + duration, + pass_threshold: 50.0, + ..Default::default() + }; + + // Check the outcome of the validation + let result = cfg.validate(OWNER_IS_ORG); + if expected_valid { + assert!(result.is_ok(), "duration: {duration:?}"); + } else { + assert_eq!( + result.unwrap_err().to_string(), + ERR_INVALID_DURATION, + "duration: {duration:?}" + ); + } + } +} + #[test] fn cfg_profile_validate_invalid_pass_threshold() { for pass_threshold in [-10.0, 0.0, 100.01, f64::NAN] { // Setup profile with invalid pass threshold let cfg = CfgProfile { + duration: Duration::from_mins(5), pass_threshold, ..Default::default() }; @@ -418,11 +544,47 @@ fn cfg_profile_validate_invalid_pass_threshold() { } } +#[test] +fn cfg_profile_validate_invalid_periods() { + for period in INVALID_PERIODS { + // Setup profiles with an invalid period + let period = Some((*period).to_string()); + let close_on_passing_cfg = CfgProfile { + duration: Duration::from_mins(5), + pass_threshold: 50.0, + + close_on_passing_min_wait: period.clone(), + ..Default::default() + }; + let periodic_status_check_cfg = CfgProfile { + duration: Duration::from_mins(5), + pass_threshold: 50.0, + + periodic_status_check: period.clone(), + ..Default::default() + }; + + // Check validation fails + assert_eq!( + close_on_passing_cfg.validate(OWNER_IS_ORG).unwrap_err().to_string(), + ERR_INVALID_CLOSE_ON_PASSING_MIN_WAIT, + "period: {period:?}" + ); + assert_eq!( + periodic_status_check_cfg.validate(OWNER_IS_ORG).unwrap_err().to_string(), + ERR_INVALID_PERIODIC_STATUS_CHECK, + "period: {period:?}" + ); + } +} + #[test] fn cfg_profile_validate_owner_not_org_with_empty_teams() { // Setup profile with empty teams let cfg = CfgProfile { + duration: Duration::from_mins(5), pass_threshold: 50.0, + allowed_voters: Some(AllowedVoters { teams: Some(vec![]), ..Default::default() @@ -438,7 +600,9 @@ fn cfg_profile_validate_owner_not_org_with_empty_teams() { fn cfg_profile_validate_owner_not_org_with_users_only() { // Setup profile with users only let cfg = CfgProfile { + duration: Duration::from_mins(5), pass_threshold: 50.0, + allowed_voters: Some(AllowedVoters { users: Some(vec![USER1.to_string()]), ..Default::default() @@ -454,7 +618,9 @@ fn cfg_profile_validate_owner_not_org_with_users_only() { fn cfg_profile_validate_owner_org_with_teams() { // Setup profile with teams let cfg = CfgProfile { + duration: Duration::from_mins(5), pass_threshold: 50.0, + allowed_voters: Some(AllowedVoters { teams: Some(vec![TEAM1.to_string()]), ..Default::default() @@ -471,6 +637,7 @@ fn cfg_profile_validate_valid_pass_threshold() { for pass_threshold in [0.01, 50.0, 100.0] { // Setup profile with valid pass threshold let cfg = CfgProfile { + duration: Duration::from_mins(5), pass_threshold, ..Default::default() }; @@ -483,6 +650,147 @@ fn cfg_profile_validate_valid_pass_threshold() { } } +#[test] +fn cfg_profile_validate_valid_periods() { + for period in [ + "1 day", + "1 days", + "5 days", + "007 days", + "36500 days", + "1 week", + "1 weeks", + "2 weeks", + "5214 weeks", + ] { + // Setup profile with a valid period in both fields + let cfg = CfgProfile { + duration: Duration::from_mins(5), + pass_threshold: 50.0, + + close_on_passing_min_wait: Some(period.to_string()), + periodic_status_check: Some(period.to_string()), + ..Default::default() + }; + + // Check validation succeeds + assert!(cfg.validate(OWNER_IS_ORG).is_ok(), "period: {period}"); + } +} + +#[test] +fn cfg_profile_validate_without_periods() { + // Setup profile without periods + let cfg = CfgProfile { + duration: Duration::from_mins(5), + pass_threshold: 50.0, + ..Default::default() + }; + + // Check validation succeeds + assert!(cfg.validate(OWNER_IS_ORG).is_ok()); +} + +#[test] +fn config_reference_active_values_parse_and_validate() { + // Parse the configuration reference + let cfg: Cfg = serde_yaml::from_str(CONFIG_REFERENCE).unwrap(); + + // Check the default profile + let default = &cfg.profiles["default"]; + assert_eq!(default.duration, Duration::from_mins(5)); + assert_eq!(default.pass_threshold, 50.0); + assert_eq!( + default.pass_threshold_base, + Some(PassThresholdBase::AllowedVoters) + ); + assert!(default.validate(OWNER_IS_ORG).is_ok()); + assert!(default.validate(!OWNER_IS_ORG).is_ok()); + + // Check the additional profile, which uses teams + let profile1 = &cfg.profiles[PROFILE_NAME]; + assert_eq!(profile1.duration, Duration::from_mins(1)); + assert_eq!(profile1.pass_threshold, 75.0); + assert_eq!( + profile1.allowed_voters.as_ref().and_then(|voters| voters.teams.clone()), + Some(vec![TEAM1.to_string()]) + ); + assert!(profile1.validate(OWNER_IS_ORG).is_ok()); + assert_eq!( + profile1.validate(!OWNER_IS_ORG).unwrap_err().to_string(), + ERR_TEAMS_NOT_ALLOWED + ); + + // Check the automation rules use a profile defined in the file + let automation = cfg.automation.as_ref().unwrap(); + assert!(cfg.profiles.contains_key(&automation.rules[0].profile)); + + // Check no key in the file is ignored, as unknown keys are not reported + let reference: Value = serde_yaml::from_str(CONFIG_REFERENCE).unwrap(); + assert_keys_are_read(&reference, &serde_yaml::to_value(&cfg).unwrap(), ""); +} + +#[test] +fn config_reference_examples_parse_and_validate() { + // Extract the examples commented out in the configuration reference + let examples = commented_examples(CONFIG_REFERENCE); + + // Check all the documented sections have examples + let keys: BTreeSet<&str> = examples.iter().map(|(key, _)| key.as_str()).collect(); + assert_eq!( + keys, + BTreeSet::from([ + "allowed_voters", + "announcements", + "audit", + "automation", + "close_on_passing", + "close_on_passing_min_wait", + "pass_threshold", + "pass_threshold_base", + "periodic_status_check", + ]) + ); + + for (key, example) in &examples { + // Setup a configuration using the example in the section it belongs to + let mut profile: Mapping = serde_yaml::from_str("duration: 5m\npass_threshold: 50").unwrap(); + let mut cfg = Mapping::new(); + if ["audit", "automation"].contains(&key.as_str()) { + cfg.insert(key.as_str().into(), example.clone()); + } else { + profile.insert(key.as_str().into(), example.clone()); + } + let mut profiles = Mapping::new(); + profiles.insert("default".into(), profile.into()); + cfg.insert("profiles".into(), profiles.into()); + let source = Value::Mapping(cfg); + + // Check the example parses and validates + let cfg: Cfg = serde_yaml::from_value(source.clone()) + .unwrap_or_else(|err| panic!("key: {key}, example: {example:?}, error: {err}")); + let validation = cfg.profiles["default"].validate(OWNER_IS_ORG); + assert!(validation.is_ok(), "key: {key}, error: {validation:?}"); + + // Check none of the keys in the example is ignored + assert_keys_are_read(&source, &serde_yaml::to_value(&cfg).unwrap(), key); + } +} + +#[tokio::test] +async fn get_cfg_config_lookup_error() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(OWNER), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run and check the failure is not taken for a missing config file + let err = Cfg::get(Arc::new(gh), INST_ID, OWNER, REPO).await.unwrap_err(); + assert!(matches!(&err, CfgError::ConfigLookup(source) if source.to_string() == ERROR)); +} + #[tokio::test] async fn get_cfg_config_not_found() { // Setup GitHub expectations @@ -490,13 +798,13 @@ async fn get_cfg_config_not_found() { gh.expect_get_config_file() .with(eq(INST_ID), eq(OWNER), eq(REPO)) .times(1) - .returning(|_, _, _| Box::pin(future::ready(None))); + .returning(|_, _, _| Box::pin(future::ready(Ok(None)))); // Run and check the error returned - assert_eq!( + assert!(matches!( Cfg::get(Arc::new(gh), INST_ID, OWNER, REPO).await.unwrap_err(), CfgError::ConfigNotFound - ); + )); } #[tokio::test] @@ -520,20 +828,20 @@ profiles: duration: 2w pass_threshold: 66.6 allowed_voters: + exclude_team_maintainers: true teams: [team1] users: [user1] - exclude_team_maintainers: true announcements: discussions: category: announcements + close_on_passing: true + close_on_passing_min_wait: 1 day pass_threshold_base: votes_cast: exclude_abstentions: true periodic_status_check: 1 week - close_on_passing: true - close_on_passing_min_wait: 1 day "#; - Box::pin(future::ready(Some(config.to_string()))) + Box::pin(future::ready(Ok(Some(config.to_string())))) }); // Run and check the configuration returned @@ -545,24 +853,26 @@ profiles: CfgProfile { duration: Duration::from_hours(14 * 24), pass_threshold: 66.6, + allowed_voters: Some(AllowedVoters { + exclude_team_maintainers: Some(true), teams: Some(vec![TEAM1.to_string()]), users: Some(vec![USER1.to_string()]), - exclude_team_maintainers: Some(true), }), announcements: Some(Announcements { discussions: Some(DiscussionsAnnouncements { category: DISCUSSIONS_CATEGORY.to_string(), }), }), + close_on_passing: Some(true), + close_on_passing_min_wait: Some("1 day".to_string()), pass_threshold_base: Some(PassThresholdBase::VotesCast { exclude_abstentions: Some(true), }), periodic_status_check: Some("1 week".to_string()), - close_on_passing: Some(true), - close_on_passing_min_wait: Some("1 day".to_string()), } )]), + audit: Some(Audit { enabled: true }), automation: Some(Automation { enabled: true, @@ -582,13 +892,14 @@ async fn get_cfg_invalid_config_missing_profiles() { gh.expect_get_config_file() .with(eq(INST_ID), eq(OWNER), eq(REPO)) .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some("audit:\n enabled: true\n".to_string())))); + .returning(|_, _, _| Box::pin(future::ready(Ok(Some("audit:\n enabled: true\n".to_string()))))); - // Run and check the error returned - assert!(matches!( - Cfg::get(Arc::new(gh), INST_ID, OWNER, REPO).await.unwrap_err(), - CfgError::InvalidConfig(_) - )); + // Run and check the reason in the error returned + let err = Cfg::get(Arc::new(gh), INST_ID, OWNER, REPO).await.unwrap_err(); + assert!( + matches!(&err, CfgError::InvalidConfig(reason) if reason.contains("missing field `profiles`")), + "error: {err:?}" + ); } #[tokio::test] @@ -598,14 +909,14 @@ async fn get_cfg_profile_config_not_found() { gh.expect_get_config_file() .with(eq(INST_ID), eq(OWNER), eq(REPO)) .times(1) - .returning(|_, _, _| Box::pin(future::ready(None))); + .returning(|_, _, _| Box::pin(future::ready(Ok(None)))); let gh = Arc::new(gh); // Run and check the error returned - assert_eq!( + assert!(matches!( CfgProfile::get(gh, INST_ID, OWNER, OWNER_IS_ORG, REPO, None).await.unwrap_err(), CfgError::ConfigNotFound - ); + )); } #[tokio::test] @@ -615,7 +926,7 @@ async fn get_cfg_profile_default() { gh.expect_get_config_file() .with(eq(INST_ID), eq(OWNER), eq(REPO)) .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); + .returning(|_, _, _| Box::pin(future::ready(Ok(Some(get_test_valid_config()))))); let gh = Arc::new(gh); // Run and check the profile returned @@ -624,6 +935,7 @@ async fn get_cfg_profile_default() { CfgProfile { duration: Duration::from_mins(5), pass_threshold: 50.0, + allowed_voters: Some(AllowedVoters::default()), ..Default::default() } @@ -631,29 +943,117 @@ async fn get_cfg_profile_default() { } #[tokio::test] -async fn get_cfg_profile_invalid_config_invalid_yaml() { +async fn get_cfg_profile_default_with_teams_in_other_profile_owner_not_org() { // Setup GitHub expectations let mut gh = MockGH::new(); gh.expect_get_config_file() .with(eq(INST_ID), eq(OWNER), eq(REPO)) .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_invalid_config())))); + .returning(|_, _, _| Box::pin(future::ready(Ok(Some(get_test_valid_config()))))); + + // Run and check only the profile requested is validated + let profile = CfgProfile::get(Arc::new(gh), INST_ID, OWNER, !OWNER_IS_ORG, REPO, None).await.unwrap(); + assert_eq!(profile.duration, Duration::from_mins(5)); +} + +#[tokio::test] +async fn get_cfg_profile_duration_limit() { + // Setup cases (duration, expected error fragment) + let cases = [ + ("0", Some(ERR_INVALID_DURATION)), + ("0s", Some(ERR_INVALID_DURATION)), + ("0m", Some(ERR_INVALID_DURATION)), + ("500ms", Some(ERR_INVALID_DURATION)), + ("1s", None), + ("1m", None), + ("5m", None), + ("12h", None), + ("36500d", None), + ("36500days 1s", Some(ERR_INVALID_DURATION)), + ("36501d", Some(ERR_INVALID_DURATION)), + ("100y", Some(ERR_INVALID_DURATION)), + ("100000y", Some(ERR_INVALID_DURATION)), + ("500000000000years", Some(ERR_INVALID_DURATION)), + ("600000000000years", Some("profiles.default.duration")), + ]; + + for (duration, expected_error) in cases { + // Setup GitHub expectations + let mut gh = MockGH::new(); + let config = format!("profiles:\n default:\n duration: {duration}\n pass_threshold: 50\n"); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(OWNER), eq(REPO)) + .times(1) + .return_once(move |_, _, _| Box::pin(future::ready(Ok(Some(config))))); + + // Run and check the outcome + let result = CfgProfile::get(Arc::new(gh), INST_ID, OWNER, OWNER_IS_ORG, REPO, None).await; + match expected_error { + None => assert!(result.is_ok(), "duration: {duration}, result: {result:?}"), + Some(expected_error) => assert!( + matches!(&result, Err(CfgError::InvalidConfig(reason)) if reason.contains(expected_error)), + "duration: {duration}, result: {result:?}" + ), + } + } +} + +#[tokio::test] +async fn get_cfg_profile_invalid_config_other_profile_invalid_duration() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(OWNER), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Ok(Some(get_test_invalid_config()))))); let gh = Arc::new(gh); - // Run and check the error returned - assert!(matches!( - CfgProfile::get( - gh, - INST_ID, - OWNER, - OWNER_IS_ORG, - REPO, - Some(PROFILE_NAME.to_string()) - ) - .await - .unwrap_err(), - CfgError::InvalidConfig(_) - )); + // Run and check the whole file is rejected, naming the profile and the field + let err = CfgProfile::get( + gh, + INST_ID, + OWNER, + OWNER_IS_ORG, + REPO, + Some(PROFILE_NAME.to_string()), + ) + .await + .unwrap_err(); + assert!( + matches!(&err, CfgError::InvalidConfig(reason) if reason.contains("profiles.default.duration")), + "error: {err:?}" + ); +} + +#[tokio::test] +async fn get_cfg_profile_invalid_config_other_profile_missing_required_field() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(OWNER), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r" +profiles: + default: + duration: 5m + pass_threshold: 50 + other: + duration: 10m +"; + Box::pin(future::ready(Ok(Some(config.to_string())))) + }); + + // Run and check the whole file is rejected, naming the profile and the field + let err = CfgProfile::get(Arc::new(gh), INST_ID, OWNER, OWNER_IS_ORG, REPO, None).await.unwrap_err(); + assert!( + matches!( + &err, + CfgError::InvalidConfig(reason) + if reason.contains("profiles.other") && reason.contains("missing field `pass_threshold`") + ), + "error: {err:?}" + ); } #[tokio::test] @@ -671,15 +1071,67 @@ profiles: pass_threshold: 50 pass_threshold_base: other "; - Box::pin(future::ready(Some(config.to_string()))) + Box::pin(future::ready(Ok(Some(config.to_string())))) }); let gh = Arc::new(gh); - // Run and check the error returned - assert!(matches!( - CfgProfile::get(gh, INST_ID, OWNER, OWNER_IS_ORG, REPO, None).await.unwrap_err(), - CfgError::InvalidConfig(_) - )); + // Run and check the reason in the error returned + let err = CfgProfile::get(gh, INST_ID, OWNER, OWNER_IS_ORG, REPO, None).await.unwrap_err(); + assert!( + matches!( + &err, + CfgError::InvalidConfig(reason) + if reason.contains("pass_threshold_base") && reason.contains("unknown variant `other`") + ), + "error: {err:?}" + ); +} + +#[tokio::test] +async fn get_cfg_profile_invalid_config_periods() { + // Setup cases (field, period, expected error) + let cases = [ + ( + "close_on_passing_min_wait", + "one week", + ERR_INVALID_CLOSE_ON_PASSING_MIN_WAIT, + ), + ( + "periodic_status_check", + "5 dayz", + ERR_INVALID_PERIODIC_STATUS_CHECK, + ), + ( + "periodic_status_check", + "every 5 days", + ERR_INVALID_PERIODIC_STATUS_CHECK, + ), + ]; + + for (field, period, expected_error) in cases { + // Setup GitHub expectations + let mut gh = MockGH::new(); + let config = format!( + r#" +profiles: + default: + duration: 5m + pass_threshold: 50 + {field}: "{period}" +"# + ); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(OWNER), eq(REPO)) + .times(1) + .return_once(move |_, _, _| Box::pin(future::ready(Ok(Some(config))))); + + // Run and check the reason in the error returned + let err = CfgProfile::get(Arc::new(gh), INST_ID, OWNER, OWNER_IS_ORG, REPO, None).await.unwrap_err(); + assert!( + matches!(&err, CfgError::InvalidConfig(reason) if reason == expected_error), + "field: {field}, period: {period}, error: {err:?}" + ); + } } #[tokio::test] @@ -689,11 +1141,11 @@ async fn get_cfg_profile_invalid_config_teams_owner_not_org() { gh.expect_get_config_file() .with(eq(INST_ID), eq(OWNER), eq(REPO)) .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); + .returning(|_, _, _| Box::pin(future::ready(Ok(Some(get_test_valid_config()))))); let gh = Arc::new(gh); // Run and check the error returned - assert_eq!( + assert!(matches!( CfgProfile::get( gh, INST_ID, @@ -704,7 +1156,104 @@ async fn get_cfg_profile_invalid_config_teams_owner_not_org() { ) .await .unwrap_err(), - CfgError::InvalidConfig(ERR_TEAMS_NOT_ALLOWED.to_string()) + CfgError::InvalidConfig(reason) if reason == ERR_TEAMS_NOT_ALLOWED + )); +} + +#[tokio::test] +async fn get_cfg_profile_only_validates_the_profile_requested() { + // Setup cases (profile requested, expected to be valid) + let cases = [(None, true), (Some("other"), false)]; + + for (profile_name, expected_valid) in cases { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(OWNER), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r" +profiles: + default: + duration: 5m + pass_threshold: 50 + other: + duration: 5m + pass_threshold: 0 +"; + Box::pin(future::ready(Ok(Some(config.to_string())))) + }); + + // Run and check the outcome + let profile_name = profile_name.map(ToString::to_string); + let result = CfgProfile::get( + Arc::new(gh), + INST_ID, + OWNER, + OWNER_IS_ORG, + REPO, + profile_name.clone(), + ) + .await; + if expected_valid { + assert!(result.is_ok(), "profile: {profile_name:?}, result: {result:?}"); + } else { + assert!( + matches!(&result, Err(CfgError::InvalidConfig(reason)) if reason == ERR_INVALID_PASS_THRESHOLD), + "profile: {profile_name:?}, result: {result:?}" + ); + } + } +} + +#[tokio::test] +async fn get_cfg_profile_other_profile_does_not_inherit_from_default() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(OWNER), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r#" +profiles: + default: + duration: 5m + pass_threshold: 50 + allowed_voters: + users: + - user1 + announcements: + discussions: + category: announcements + close_on_passing: true + close_on_passing_min_wait: "1 week" + pass_threshold_base: + votes_cast: {} + periodic_status_check: "5 days" + other: + duration: 10m + pass_threshold: 75 +"#; + Box::pin(future::ready(Ok(Some(config.to_string())))) + }); + + // Run and check the optional fields are not taken from the default profile + assert_eq!( + CfgProfile::get( + Arc::new(gh), + INST_ID, + OWNER, + OWNER_IS_ORG, + REPO, + Some("other".to_string()) + ) + .await + .unwrap(), + CfgProfile { + duration: Duration::from_mins(10), + pass_threshold: 75.0, + ..Default::default() + } ); } @@ -725,7 +1274,7 @@ profiles: votes_cast: exclude_abstentions: true "; - Box::pin(future::ready(Some(config.to_string()))) + Box::pin(future::ready(Ok(Some(config.to_string())))) }); let gh = Arc::new(gh); @@ -735,6 +1284,7 @@ profiles: CfgProfile { duration: Duration::from_mins(5), pass_threshold: 50.01, + pass_threshold_base: Some(PassThresholdBase::VotesCast { exclude_abstentions: Some(true), }), @@ -750,7 +1300,7 @@ async fn get_cfg_profile_profile1() { gh.expect_get_config_file() .with(eq(INST_ID), eq(OWNER), eq(REPO)) .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); + .returning(|_, _, _| Box::pin(future::ready(Ok(Some(get_test_valid_config()))))); let gh = Arc::new(gh); // Run and check the profile returned @@ -768,6 +1318,7 @@ async fn get_cfg_profile_profile1() { CfgProfile { duration: Duration::from_mins(10), pass_threshold: 75.0, + allowed_voters: Some(AllowedVoters { teams: Some(vec![TEAM1.to_string()]), users: Some(vec![USER1.to_string(), USER2.to_string()]), @@ -785,11 +1336,11 @@ async fn get_cfg_profile_profile_not_found() { gh.expect_get_config_file() .with(eq(INST_ID), eq(OWNER), eq(REPO)) .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); + .returning(|_, _, _| Box::pin(future::ready(Ok(Some(get_test_valid_config()))))); let gh = Arc::new(gh); // Run and check the error returned - assert_eq!( + assert!(matches!( CfgProfile::get( gh, INST_ID, @@ -801,5 +1352,73 @@ async fn get_cfg_profile_profile_not_found() { .await .unwrap_err(), CfgError::ProfileNotFound - ); + )); +} + +// Helpers. + +/// Checks all the keys in the source value with a value set are present in the parsed +/// one, as unknown keys would be ignored silently when parsing a configuration file. +fn assert_keys_are_read(source: &Value, parsed: &Value, path: &str) { + match source { + // Check each key with a value set was read, then check its value + Value::Mapping(mapping) => { + for (key, value) in mapping { + let key_path = format!("{path}/{}", key.as_str().unwrap_or_default()); + if value.is_null() { + continue; + } + let parsed_value = parsed.get(key).unwrap_or_else(|| panic!("key not read: {key_path}")); + assert_keys_are_read(value, parsed_value, &key_path); + } + } + // Check each item was read, then check its value + Value::Sequence(items) => { + for (i, item) in items.iter().enumerate() { + let item_path = format!("{path}/{i}"); + let parsed_item = parsed.get(i).unwrap_or_else(|| panic!("item not read: {item_path}")); + assert_keys_are_read(item, parsed_item, &item_path); + } + } + // Scalar values have no keys to check + _ => {} + } +} + +/// Extracts the YAML examples commented out in the configuration reference, returning +/// the key of each one along with its value. An example starts with a comment holding +/// a lowercase key, and goes on while the comments below it are indented. +fn commented_examples(reference: &str) -> Vec<(String, Value)> { + // Collect the source of the examples + let mut sources: Vec = vec![]; + let mut in_example = false; + for line in reference.lines() { + let comment = line.trim_start().strip_prefix("# ").unwrap_or_default(); + + // Append indented comments to the example in progress + if in_example && comment.starts_with(' ') { + let source = sources.last_mut().expect("example to be started"); + source.push('\n'); + source.push_str(comment); + continue; + } + + // Start a new example when the comment holds a lowercase key + let key = comment.split_once(':').map_or("", |(key, _)| key); + in_example = !key.is_empty() && key.bytes().all(|byte| byte.is_ascii_lowercase() || byte == b'_'); + if in_example { + sources.push(comment.to_string()); + } + } + + // Parse each example into its key and value + sources + .iter() + .map(|source| { + let example: Mapping = + serde_yaml::from_str(source).unwrap_or_else(|err| panic!("example: {source}, error: {err}")); + let (key, value) = example.into_iter().next().expect("example to have a key"); + (key.as_str().expect("key to be a string").to_string(), value) + }) + .collect() } diff --git a/src/cfg_svc.rs b/src/cfg_svc.rs index 7c19b64..0d67b41 100644 --- a/src/cfg_svc.rs +++ b/src/cfg_svc.rs @@ -1,9 +1,9 @@ //! This module defines some types and functionality to represent and process //! the `GitVote` service configuration. -use std::path::Path; +use std::{fmt, path::Path}; -use anyhow::Result; +use anyhow::{Result, bail}; use deadpool_postgres::Config as Db; use figment::{ Figment, @@ -11,23 +11,41 @@ use figment::{ }; use serde::{Deserialize, Serialize}; +#[cfg(test)] +mod tests; + +/// Placeholder displayed instead of sensitive values in debug output. +const REDACTED: &str = "[REDACTED]"; + /// Server configuration. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize)] pub(crate) struct Cfg { - pub addr: String, + /// Database configuration. pub db: Db, + /// GitHub application configuration. pub github: GitHubApp, + + /// Address the HTTP server listens on, defaulted when not configured. + pub addr: String, + /// Logs configuration, defaulted when not configured. pub log: Log, } impl Cfg { - /// Create a new Cfg instance. + /// Creates a new Cfg instance. pub(crate) fn new(config_file: &Path) -> Result { Self::load(config_file, Env::prefixed("GITVOTE_")) } - /// Load the configuration from the file and environment provider given. + /// Loads the configuration from the file and environment provider given. fn load(config_file: &Path, env: Env) -> Result { + // A missing file would be read as empty, which only fails on the first missing + // field or not at all if the environment provides them all + if !config_file.is_file() { + bail!("config file not found: {}", config_file.display()); + } + + // Merge the defaults, the file and the environment, in increasing precedence Figment::new() .merge(Serialized::default("addr", "127.0.0.1:9000")) .merge(Serialized::default("log.format", "pretty")) @@ -38,9 +56,25 @@ impl Cfg { } } +/// GitHub application configuration. +#[derive(Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all(deserialize = "camelCase"))] +pub(crate) struct GitHubApp { + /// GitHub application id. + pub app_id: i64, + /// GitHub application private key, in PEM format. + pub app_private_key: String, + /// Secret used to verify the signature of the webhook payloads. + pub webhook_secret: String, + + /// Previous webhook secret, accepted as well while the secret is being rotated. + pub webhook_secret_fallback: Option, +} + /// Logs configuration. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub(crate) struct Log { + /// Format to use in logs. pub format: LogFormat, } @@ -48,19 +82,37 @@ pub(crate) struct Log { #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[serde(rename_all(deserialize = "lowercase"))] pub(crate) enum LogFormat { + /// Structured logs, one JSON object per line. Json, + /// Human readable logs. Pretty, } -/// GitHub application configuration. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all(deserialize = "camelCase"))] -pub struct GitHubApp { - pub app_id: i64, - pub app_private_key: String, - pub webhook_secret: String, - pub webhook_secret_fallback: Option, +impl fmt::Debug for Cfg { + /// Formats the configuration redacting the database one, which holds the + /// database credentials, possibly as part of a connection URL. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Cfg") + .field("db", &REDACTED) + .field("github", &self.github) + .field("addr", &self.addr) + .field("log", &self.log) + .finish() + } } -#[cfg(test)] -mod tests; +impl fmt::Debug for GitHubApp { + /// Formats the GitHub application configuration redacting the private key and + /// the webhook secrets. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("GitHubApp") + .field("app_id", &self.app_id) + .field("app_private_key", &REDACTED) + .field("webhook_secret", &REDACTED) + .field( + "webhook_secret_fallback", + &self.webhook_secret_fallback.as_ref().map(|_| REDACTED), + ) + .finish() + } +} diff --git a/src/cfg_svc/tests.rs b/src/cfg_svc/tests.rs index 6062505..8999be5 100644 --- a/src/cfg_svc/tests.rs +++ b/src/cfg_svc/tests.rs @@ -12,6 +12,39 @@ github: webhookSecret: secret "; +#[test] +fn cfg_debug_redacts_secrets() { + // Setup configuration with distinctive values in all the sensitive fields + let file = TempConfigFile::new( + "debug-redacts-secrets", + r" +db: + host: db.example.com + password: db-password +github: + appId: 1234 + appPrivateKey: private-key + webhookSecret: webhook-secret + webhookSecretFallback: webhook-secret-fallback +", + ); + let cfg = load_cfg(&file.path).unwrap(); + + // Format the configuration + let output = format!("{cfg:?}"); + + // Check the database configuration and the secrets are redacted + assert_eq!( + output, + concat!( + r#"Cfg { db: "[REDACTED]", github: GitHubApp { app_id: 1234, "#, + r#"app_private_key: "[REDACTED]", webhook_secret: "[REDACTED]", "#, + r#"webhook_secret_fallback: Some("[REDACTED]") }, "#, + r#"addr: "127.0.0.1:9000", log: Log { format: Pretty } }"#, + ) + ); +} + #[test] fn cfg_load_applies_defaults() { // Setup configuration file @@ -21,7 +54,6 @@ fn cfg_load_applies_defaults() { let cfg = load_cfg(&file.path).unwrap(); // Check defaults are applied - assert_eq!(cfg.addr, "127.0.0.1:9000"); assert_eq!(cfg.db.host, Some("db.example.com".to_string())); assert_eq!( cfg.github, @@ -29,9 +61,11 @@ fn cfg_load_applies_defaults() { app_id: 1234, app_private_key: "key".to_string(), webhook_secret: "secret".to_string(), + webhook_secret_fallback: None, } ); + assert_eq!(cfg.addr, "127.0.0.1:9000"); assert_eq!(cfg.log.format, LogFormat::Pretty); } @@ -54,18 +88,18 @@ fn cfg_load_loads_all_fields_from_file() { let file = TempConfigFile::new( "all-fields", r" -addr: 0.0.0.0:9000 db: + dbname: gitvote host: db.example.com + password: pass port: 5433 - dbname: gitvote user: postgres - password: pass github: appId: 1234 appPrivateKey: key webhookSecret: secret webhookSecretFallback: old-secret +addr: 0.0.0.0:9000 log: format: json ", @@ -75,24 +109,38 @@ log: let cfg = load_cfg(&file.path).unwrap(); // Check values match the file content - assert_eq!(cfg.addr, "0.0.0.0:9000"); + assert_eq!(cfg.db.dbname, Some("gitvote".to_string())); assert_eq!(cfg.db.host, Some("db.example.com".to_string())); + assert_eq!(cfg.db.password, Some("pass".to_string())); assert_eq!(cfg.db.port, Some(5433)); - assert_eq!(cfg.db.dbname, Some("gitvote".to_string())); assert_eq!(cfg.db.user, Some("postgres".to_string())); - assert_eq!(cfg.db.password, Some("pass".to_string())); assert_eq!( cfg.github, GitHubApp { app_id: 1234, app_private_key: "key".to_string(), webhook_secret: "secret".to_string(), + webhook_secret_fallback: Some("old-secret".to_string()), } ); + assert_eq!(cfg.addr, "0.0.0.0:9000"); assert_eq!(cfg.log.format, LogFormat::Json); } +#[test] +fn cfg_load_missing_file_returns_error_naming_the_path() { + // Setup path of a file that does not exist + let path = env::temp_dir().join(format!("gitvote-cfg-svc-missing-{}.yml", process::id())); + + // Check the error names the file + let err = load_cfg(&path).unwrap_err(); + assert_eq!( + err.to_string(), + format!("config file not found: {}", path.display()) + ); +} + #[test] fn cfg_load_missing_required_field_returns_error() { // Setup configuration file without the webhook secret @@ -117,19 +165,14 @@ github: // Helpers. -/// Load the configuration from the file provided, ignoring the process -/// environment so that local `GITVOTE_*` variables cannot affect the tests. -fn load_cfg(config_file: &Path) -> Result { - Cfg::load(config_file, Env::prefixed("GITVOTE_").filter(|_| false)) -} - /// Temporary configuration file removed when dropped. struct TempConfigFile { + /// Path of the configuration file, unique to the test and the process. path: PathBuf, } impl TempConfigFile { - /// Create a new temporary configuration file with the content provided. + /// Creates a new temporary configuration file with the content provided. fn new(name: &str, content: &str) -> Self { let path = env::temp_dir().join(format!("gitvote-cfg-svc-{name}-{}.yml", process::id())); fs::write(&path, content).unwrap(); @@ -138,7 +181,14 @@ impl TempConfigFile { } impl Drop for TempConfigFile { + /// [`Drop::drop`]. fn drop(&mut self) { _ = fs::remove_file(&self.path); } } + +/// Loads the configuration from the file provided, ignoring the process +/// environment so that local `GITVOTE_*` variables cannot affect the tests. +fn load_cfg(config_file: &Path) -> Result { + Cfg::load(config_file, Env::prefixed("GITVOTE_").filter(|_| false)) +} diff --git a/src/cmd.rs b/src/cmd.rs index ac3c2d4..f6d93ee 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -1,40 +1,72 @@ //! This module defines the commands supported and the logic to parse them from //! GitHub events. +use std::sync::LazyLock; + use anyhow::Result; +use askama::Template; use regex::Regex; use serde::{Deserialize, Serialize}; -use std::sync::LazyLock; -use tracing::error; +use tracing::warn; use crate::{ - cfg_repo::{Cfg, CfgError}, + cfg_repo::{AutomationRule, Cfg, CfgError}, + db::DynDB, github::{ - DynGH, Event, IssueCommentEventAction, IssueEventAction, PullRequestEventAction, split_full_name, + CheckDetails, DynGH, Event, IssueCommentEventAction, IssueEventAction, PullRequestEventAction, + split_full_name, }, + tmpl, }; -/// Available commands. -const CMD_CREATE_VOTE: &str = "vote"; +#[cfg(test)] +mod tests; + +/// Summary of the check reported when the automation rules could not be evaluated. +const CHECK_SUMMARY_AUTOMATION_FAILED: &str = + "Automation rules could not be evaluated, maintainers should run /vote"; + +/// Summary of the check reported when an automation rule matches a pull request that +/// has no vote. +const CHECK_SUMMARY_AUTOMATION_RULE_MATCHED: &str = + "An automation rule matches but there is no vote, maintainers should run /vote"; + +/// Summary of the check reported when a pull request does not need a vote. +const CHECK_SUMMARY_NO_VOTE_FOUND: &str = "No vote found"; + +/// Summary of the check reported when creating a vote failed. +const CHECK_SUMMARY_VOTE_NOT_CREATED: &str = "The vote could not be created, please run /vote again"; + +/// Command used to cancel an open vote. const CMD_CANCEL_VOTE: &str = "cancel-vote"; + +/// Command used to check the status of an open vote. const CMD_CHECK_VOTE: &str = "check-vote"; -/// Regex used to detect commands in issues/prs comments. +/// Command used to create a new vote. +const CMD_CREATE_VOTE: &str = "vote"; + +/// Regex used to detect commands in issues/prs comments. Only the create vote command +/// accepts a suffix, a hyphen followed by the name of the profile to use. static CMD: LazyLock = LazyLock::new(|| { - Regex::new(r"(?m)^/(vote|cancel-vote|check-vote)-?([a-zA-Z0-9_-]*)\s*$").expect("invalid CMD regexp") + Regex::new(r"(?m)^/(?:(vote)(?:-([a-zA-Z0-9_-]+))?|(cancel-vote|check-vote))\s*$") + .expect("CMD regexp literal must be valid") }); /// Represents a command to be executed, usually created from a GitHub event. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[allow(clippy::enum_variant_names)] pub(crate) enum Command { - CreateVote(CreateVoteInput), + /// Cancel an open vote. CancelVote(CancelVoteInput), + /// Check the status of an open vote. CheckVote(CheckVoteInput), + /// Create a new vote. + CreateVote(CreateVoteInput), } impl Command { - /// Try to create a new command from an event. + /// Tries to create a new command from an event. /// /// A command can be created from an event in two different ways: /// @@ -46,21 +78,42 @@ impl Command { /// Manual commands have preference, and if one is found, automatic ones /// won't be processed. /// - pub(crate) async fn from_event(gh: DynGH, event: &Event) -> Option { + /// An error means the automation rules could not be evaluated, so a vote + /// that should have been created automatically may be missing. + pub(crate) async fn from_event(gh: DynGH, event: &Event) -> Result> { + // Look for a manual command first, as it has preference if let Some(cmd) = Command::from_event_manual(event) { - Some(cmd) - } else { - match Command::from_event_automatic(gh, event).await { - Ok(cmd) => cmd, - Err(err) => { - error!(?err, ?event, "error processing automatic command"); - None - } + return Ok(Some(cmd)); + } + + // Otherwise, check if the event triggers an automatic command + Command::from_event_automatic(gh, event).await + } + + /// Creates automatic command from event, if applicable. + async fn from_event_automatic(gh: DynGH, event: &Event) -> Result> { + match event { + // Pull request opened + Event::PullRequest(event) if event.action == PullRequestEventAction::Opened => { + // Find the first automation rule matching the pull request + let inst_id = event.installation.id as u64; + let (owner, repo) = split_full_name(&event.repository.full_name); + let pr_number = event.pull_request.number; + let rule = find_automation_rule(gh, inst_id, owner, repo, pr_number).await?; + + // Create the vote using the profile of the rule + Ok(rule.map(|rule| { + Command::CreateVote(CreateVoteInput::new_automatic( + &rule.profile, + &Event::PullRequest(event.clone()), + )) + })) } + _ => Ok(None), } } - /// Get manual command from event, if available. + /// Gets manual command from event, if available. fn from_event_manual(event: &Event) -> Option { // Get the content where we'll try to extract the command from let content = match event { @@ -78,186 +131,400 @@ impl Command { if let Some(content) = content && let Some(captures) = CMD.captures(content) { - let cmd = captures.get(1)?.as_str(); - let profile = match captures.get(2)?.as_str() { - "" => None, - profile => Some(profile), - }; + let cmd = captures.get(1).or_else(|| captures.get(3))?.as_str(); + let profile = captures.get(2).map(|profile| profile.as_str()); match cmd { + CMD_CANCEL_VOTE => return Some(Command::CancelVote(CancelVoteInput::new(event))), + CMD_CHECK_VOTE => return Some(Command::CheckVote(CheckVoteInput::new(event))), CMD_CREATE_VOTE => { return Some(Command::CreateVote(CreateVoteInput::new(profile, event))); } - CMD_CANCEL_VOTE => return Some(Command::CancelVote(CancelVoteInput::new(event))), - CMD_CHECK_VOTE => return Some(Command::CheckVote(CheckVoteInput::new(event))), _ => return None, } } None } - - /// Create automatic command from event, if applicable. - async fn from_event_automatic(gh: DynGH, event: &Event) -> Result> { - match event { - // Pull request opened - Event::PullRequest(event) if event.action == PullRequestEventAction::Opened => { - // Get configuration - let inst_id = event.installation.id as u64; - let (owner, repo) = split_full_name(&event.repository.full_name); - let cfg = match Cfg::get(gh.clone(), inst_id, owner, repo).await { - Err(CfgError::ConfigNotFound) => return Ok(None), - Err(err) => return Err(err.into()), - Ok(cfg) => cfg, - }; - - // Process automation if enabled - if let Some(automation) = cfg.automation { - if !automation.enabled || automation.rules.is_empty() { - return Ok(None); - } - - // Check if any of the PR files matches the automation rules - let pr_number = event.pull_request.number; - let pr_files = gh.get_pr_files(inst_id, owner, repo, pr_number).await?; - for rule in automation.rules { - if rule.matches(pr_files.as_slice())? { - let cmd = Command::CreateVote(CreateVoteInput::new( - Some(&rule.profile), - &Event::PullRequest(event.clone()), - )); - return Ok(Some(cmd)); - } - } - } - - Ok(None) - } - _ => Ok(None), - } - } } -/// Information required to create a new vote. +/// Information required to cancel an open vote. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct CreateVoteInput { - pub profile_name: Option, - pub created_by: String, +pub(crate) struct CancelVoteInput { + /// Login of the user who requested the cancellation. + pub cancelled_by: String, + /// GitHub application installation id. pub installation_id: i64, - pub issue_id: i64, - pub issue_number: i64, - pub issue_title: String, + /// Whether the vote is on a pull request. pub is_pull_request: bool, + /// Number of the issue or pull request of the vote. + pub issue_number: i64, + /// Full name of the repository, in the `owner/repo` format. pub repository_full_name: String, - pub organization: Option, } -impl CreateVoteInput { - /// Create a new `CreateVoteInput` instance from the profile and event - /// provided. - pub(crate) fn new(profile_name: Option<&str>, event: &Event) -> Self { +impl CancelVoteInput { + /// Creates a new `CancelVoteInput` instance from the event provided. + pub(crate) fn new(event: &Event) -> Self { match event { Event::Issue(event) => Self { - profile_name: profile_name.map(ToString::to_string), - created_by: event.sender.login.clone(), + cancelled_by: event.sender.login.clone(), installation_id: event.installation.id, - issue_id: event.issue.id, - issue_number: event.issue.number, - issue_title: event.issue.title.clone(), is_pull_request: event.issue.pull_request.is_some(), + issue_number: event.issue.number, repository_full_name: event.repository.full_name.clone(), - organization: event.organization.as_ref().map(|o| o.login.clone()), }, Event::IssueComment(event) => Self { - profile_name: profile_name.map(ToString::to_string), - created_by: event.sender.login.clone(), + cancelled_by: event.sender.login.clone(), installation_id: event.installation.id, - issue_id: event.issue.id, - issue_number: event.issue.number, - issue_title: event.issue.title.clone(), is_pull_request: event.issue.pull_request.is_some(), + issue_number: event.issue.number, repository_full_name: event.repository.full_name.clone(), - organization: event.organization.as_ref().map(|o| o.login.clone()), }, Event::PullRequest(event) => Self { - profile_name: profile_name.map(ToString::to_string), - created_by: event.sender.login.clone(), + cancelled_by: event.sender.login.clone(), installation_id: event.installation.id, - issue_id: event.pull_request.id, - issue_number: event.pull_request.number, - issue_title: event.pull_request.title.clone(), is_pull_request: true, + issue_number: event.pull_request.number, repository_full_name: event.repository.full_name.clone(), - organization: event.organization.as_ref().map(|o| o.login.clone()), }, } } } -/// Information required to cancel an open vote. +/// Information required to check the status of an open vote. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct CancelVoteInput { - pub cancelled_by: String, - pub installation_id: i64, +pub(crate) struct CheckVoteInput { + /// Number of the issue or pull request of the vote. pub issue_number: i64, - pub is_pull_request: bool, + /// Full name of the repository, in the `owner/repo` format. pub repository_full_name: String, + + /// Installation id, only set when a user requested the check. Failures are not + /// reported on periodic status checks, as nobody is waiting for them. + pub installation_id: Option, } -impl CancelVoteInput { - /// Create a new `CancelVoteInput` instance from the event provided. +impl CheckVoteInput { + /// Creates a new `CheckVoteInput` instance from the event provided. pub(crate) fn new(event: &Event) -> Self { match event { Event::Issue(event) => Self { - cancelled_by: event.sender.login.clone(), - installation_id: event.installation.id, issue_number: event.issue.number, - is_pull_request: event.issue.pull_request.is_some(), repository_full_name: event.repository.full_name.clone(), + + installation_id: Some(event.installation.id), }, Event::IssueComment(event) => Self { - cancelled_by: event.sender.login.clone(), - installation_id: event.installation.id, issue_number: event.issue.number, - is_pull_request: event.issue.pull_request.is_some(), repository_full_name: event.repository.full_name.clone(), + + installation_id: Some(event.installation.id), }, Event::PullRequest(event) => Self { - cancelled_by: event.sender.login.clone(), - installation_id: event.installation.id, issue_number: event.pull_request.number, - is_pull_request: true, repository_full_name: event.repository.full_name.clone(), + + installation_id: Some(event.installation.id), }, } } } -/// Information required to check the status of an open vote. +/// Information required to create a new vote. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct CheckVoteInput { +pub(crate) struct CreateVoteInput { + /// Login of the user who requested the vote. + pub created_by: String, + /// GitHub application installation id. + pub installation_id: i64, + /// Whether the vote was requested by an automation rule, not by a user's command. + pub is_automatic: bool, + /// Whether the vote is on a pull request. + pub is_pull_request: bool, + /// Id of the issue or pull request of the vote. + pub issue_id: i64, + /// Number of the issue or pull request of the vote. pub issue_number: i64, + /// Title of the issue or pull request of the vote. + pub issue_title: String, + /// Full name of the repository, in the `owner/repo` format. pub repository_full_name: String, + + /// Base branch of the pull request when the command comes from its opened event. + /// As no other event reports the check on these pull requests, the vote processor + /// must do it if the vote is not created. + pub opened_pr_base_branch: Option, + /// Login of the organization the repository belongs to, if any. + pub organization: Option, + /// Name of the configuration profile to use, the default one when not set. + pub profile_name: Option, } -impl CheckVoteInput { - /// Create a new `CheckVoteInput` instance from the event provided. - pub(crate) fn new(event: &Event) -> Self { +impl CreateVoteInput { + /// Creates a new `CreateVoteInput` instance from the profile and event + /// provided. + pub(crate) fn new(profile_name: Option<&str>, event: &Event) -> Self { match event { Event::Issue(event) => Self { + created_by: event.sender.login.clone(), + installation_id: event.installation.id, + is_automatic: false, + is_pull_request: event.issue.pull_request.is_some(), + issue_id: event.issue.id, issue_number: event.issue.number, + issue_title: event.issue.title.clone(), repository_full_name: event.repository.full_name.clone(), + + opened_pr_base_branch: None, + organization: event.organization.as_ref().map(|organization| organization.login.clone()), + profile_name: profile_name.map(ToString::to_string), }, Event::IssueComment(event) => Self { + created_by: event.sender.login.clone(), + installation_id: event.installation.id, + is_automatic: false, + is_pull_request: event.issue.pull_request.is_some(), + issue_id: event.issue.id, issue_number: event.issue.number, + issue_title: event.issue.title.clone(), repository_full_name: event.repository.full_name.clone(), + + opened_pr_base_branch: None, + organization: event.organization.as_ref().map(|organization| organization.login.clone()), + profile_name: profile_name.map(ToString::to_string), }, Event::PullRequest(event) => Self { + created_by: event.sender.login.clone(), + installation_id: event.installation.id, + is_automatic: false, + is_pull_request: true, + issue_id: event.pull_request.id, issue_number: event.pull_request.number, + issue_title: event.pull_request.title.clone(), repository_full_name: event.repository.full_name.clone(), + + opened_pr_base_branch: Some(event.pull_request.base.reference.clone()), + organization: event.organization.as_ref().map(|organization| organization.login.clone()), + profile_name: profile_name.map(ToString::to_string), }, } } + + /// Creates a new `CreateVoteInput` instance for a vote requested by an automation + /// rule using the profile provided. + pub(crate) fn new_automatic(profile_name: &str, event: &Event) -> Self { + Self { + is_automatic: true, + ..Self::new(Some(profile_name), event) + } + } } -#[cfg(test)] -mod tests; +/// Returns the details of the failure check of a pull request whose automation rules +/// could not be evaluated. +pub(crate) fn automation_failed_check() -> CheckDetails { + failed_check(CHECK_SUMMARY_AUTOMATION_FAILED) +} + +/// Creates the check run of a pull request with no vote. The automation rules are +/// evaluated only to decide the check, no vote is started: the check fails if any of +/// them matches or they cannot be evaluated, as a vote may be missing. Nothing is +/// reported if the pull request has a vote, as described in [`publish_no_vote_check`]. +pub(crate) async fn create_no_vote_check_run( + db: DynDB, + gh: DynGH, + inst_id: u64, + owner: &str, + repo: &str, + pr_number: i64, +) -> Result<()> { + // Decide the check from the automation rules + let check_details = match find_automation_rule(gh.clone(), inst_id, owner, repo, pr_number).await { + Ok(None) => no_vote_found_check(), + Ok(Some(_)) => failed_check(CHECK_SUMMARY_AUTOMATION_RULE_MATCHED), + Err(err) => { + warn!(?err, owner, repo, pr_number, "error evaluating automation rules"); + automation_failed_check() + } + }; + + // Report the check, unless the pull request has a vote + publish_no_vote_check(db, gh, inst_id, owner, repo, pr_number, &check_details).await +} + +/// Returns the details of the success check of a pull request that needs no vote. +pub(crate) fn no_vote_found_check() -> CheckDetails { + CheckDetails { + status: "completed".to_string(), + summary: CHECK_SUMMARY_NO_VOTE_FOUND.to_string(), + + conclusion: Some("success".to_string()), + } +} + +/// Reports the failure check provided on a pull request just opened, unless a vote owns +/// it, as described in [`is_check_owned_by_vote`]. +pub(crate) async fn publish_failure_check( + db: DynDB, + gh: DynGH, + inst_id: u64, + owner: &str, + repo: &str, + pr_number: i64, + check_details: &CheckDetails, +) -> Result<()> { + // Skip the check if a vote owns it + if is_check_owned_by_vote(db, owner, repo, pr_number).await { + return Ok(()); + } + + // Report the check + gh.create_check_run(inst_id, owner, repo, pr_number, check_details).await +} + +/// Reports the check provided on a pull request with no vote. Nothing is reported if +/// the pull request has a vote, open or closed, as the vote owns the check: a late, +/// replayed or concurrent event must not replace the check of an open vote or the result +/// of a closed one. A cancelled vote owns it only until its cancellation check has been +/// reported, as described in [`DB::has_vote`](crate::db::DB::has_vote). The vote is +/// looked up right before reporting the check to narrow the window of the race. +pub(crate) async fn publish_no_vote_check( + db: DynDB, + gh: DynGH, + inst_id: u64, + owner: &str, + repo: &str, + pr_number: i64, + check_details: &CheckDetails, +) -> Result<()> { + // Skip the check if a vote owns it + let repository_full_name = format!("{owner}/{repo}"); + if db.has_vote(&repository_full_name, pr_number).await? { + return Ok(()); + } + + // Report the check + gh.create_check_run(inst_id, owner, repo, pr_number, check_details).await +} + +/// Reports an automation failure on a pull request just opened, with a failure check and +/// a comment asking maintainers to run the command manually, which includes the details +/// provided. Both are tried even if the other fails, as they are the only trace of the +/// failure. The check does not depend on being required, as a vote would also have +/// reported it. Nothing is reported if the pull request has a vote, as described in +/// [`is_check_owned_by_vote`]: no vote is missing then, like when the event that +/// created it is delivered again. +pub(crate) async fn report_automation_failure( + db: DynDB, + gh: DynGH, + inst_id: u64, + owner: &str, + repo: &str, + pr_number: i64, + details: Option<&str>, +) -> Result<()> { + // Skip the report if a vote owns the check + if is_check_owned_by_vote(db, owner, repo, pr_number).await { + return Ok(()); + } + + // Report the failure check + let check_result = gh.create_check_run(inst_id, owner, repo, pr_number, &automation_failed_check()).await; + + // Post the comment + let body = tmpl::AutomationFailed::new(details).render()?; + let comment_result = gh.post_comment(inst_id, owner, repo, pr_number, &body).await; + + // Propagate any failure once both reports have been tried, the check one first + check_result?; + comment_result?; + Ok(()) +} + +/// Updates the check of a pull request with no vote, if the check is required in the +/// branch provided. Nothing is reported if the pull request has a vote, as described in +/// [`publish_no_vote_check`]. +pub(crate) async fn update_no_vote_check( + db: DynDB, + gh: DynGH, + inst_id: u64, + owner: &str, + repo: &str, + pr_number: i64, + branch: &str, +) -> Result<()> { + // Skip the check if it is not required in the branch + if !gh.is_check_required(inst_id, owner, repo, branch).await? { + return Ok(()); + } + + // Create the check run + create_no_vote_check_run(db, gh, inst_id, owner, repo, pr_number).await +} + +/// Returns the details of the failure check of a pull request where creating a vote +/// failed. +pub(crate) fn vote_not_created_check() -> CheckDetails { + failed_check(CHECK_SUMMARY_VOTE_NOT_CREATED) +} + +/// Returns the details of a failure check with the summary provided. +fn failed_check(summary: &str) -> CheckDetails { + CheckDetails { + status: "completed".to_string(), + summary: summary.to_string(), + + conclusion: Some("failure".to_string()), + } +} + +/// Finds the first automation rule matching the files of the pull request, if the +/// repository has automation enabled. +async fn find_automation_rule( + gh: DynGH, + inst_id: u64, + owner: &str, + repo: &str, + pr_number: i64, +) -> Result> { + // Get configuration (the specific not found arm must precede the generic error one) + let cfg = match Cfg::get(gh.clone(), inst_id, owner, repo).await { + Err(CfgError::ConfigNotFound) => return Ok(None), + Err(err) => return Err(err.into()), + Ok(cfg) => cfg, + }; + + // Automation must be enabled and have some rules + let Some(automation) = cfg.automation else { + return Ok(None); + }; + if !automation.enabled || automation.rules.is_empty() { + return Ok(None); + } + + // Check if any of the PR files matches the automation rules, the first match wins + let pr_files = gh.get_pr_files(inst_id, owner, repo, pr_number).await?; + for rule in automation.rules { + if rule.matches(pr_files.as_slice())? { + return Ok(Some(rule)); + } + } + + Ok(None) +} + +/// Checks if a vote owns the check of the pull request provided, so that no failure is +/// reported on it, as described in [`publish_no_vote_check`]. Unlike the checks reported +/// through that function, which are skipped when the vote lookup fails, the failures +/// reported after this are reported anyway: the report may be the only trace of the +/// failure, and a failure check never allows merging a pull request. +async fn is_check_owned_by_vote(db: DynDB, owner: &str, repo: &str, pr_number: i64) -> bool { + let repository_full_name = format!("{owner}/{repo}"); + match db.has_vote(&repository_full_name, pr_number).await { + Ok(has_vote) => has_vote, + Err(err) => { + warn!(?err, owner, repo, pr_number, "error looking up vote"); + false + } + } +} diff --git a/src/cmd/tests.rs b/src/cmd/tests.rs index fb06be0..667a6a6 100644 --- a/src/cmd/tests.rs +++ b/src/cmd/tests.rs @@ -6,12 +6,15 @@ use mockall::predicate::eq; use crate::{ cfg_repo::CfgError, + db::MockDB, github::{File, MockGH, Organization, PullRequestInIssue}, testutil::*, }; use super::*; +// Commands and their inputs. + #[tokio::test] async fn automatic_command_from_issue_event_is_ignored() { // Setup event (no GitHub calls expected) @@ -28,18 +31,13 @@ async fn automatic_command_from_issue_event_is_ignored() { async fn automatic_command_from_pr_event() { // Setup GitHub expectations let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); - gh.expect_get_pr_files() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _, _, _| { - Box::pin(future::ready(Ok(vec![File { - filename: "README.md".to_string(), - }]))) - }); + expect_valid_config(&mut gh); + expect_pr_files( + &mut gh, + Ok(vec![File { + filename: "README.md".to_string(), + }]), + ); let gh = Arc::new(gh); // Setup event @@ -50,7 +48,9 @@ async fn automatic_command_from_pr_event() { // Run and check the automatic command is created assert_eq!( Command::from_event_automatic(gh, &event.clone()).await.unwrap(), - Some(Command::CreateVote(CreateVoteInput::new(Some("default"), &event))) + Some(Command::CreateVote(CreateVoteInput::new_automatic( + "default", &event + ))) ); } @@ -74,8 +74,10 @@ profiles: ); gh.expect_get_pr_files().never(); - // Run and check no command is created + // Setup event let event = setup_test_pr_opened_event(); + + // Run and check no command is created assert_eq!( Command::from_event_automatic(Arc::new(gh), &event).await.unwrap(), None @@ -97,8 +99,10 @@ profiles: ); gh.expect_get_pr_files().never(); - // Run and check no command is created + // Setup event let event = setup_test_pr_opened_event(); + + // Run and check no command is created assert_eq!( Command::from_event_automatic(Arc::new(gh), &event).await.unwrap(), None @@ -123,8 +127,10 @@ profiles: ); gh.expect_get_pr_files().never(); - // Run and check no command is created + // Setup event let event = setup_test_pr_opened_event(); + + // Run and check no command is created assert_eq!( Command::from_event_automatic(Arc::new(gh), &event).await.unwrap(), None @@ -135,14 +141,13 @@ profiles: async fn automatic_command_from_pr_event_config_not_found() { // Setup GitHub expectations let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(None))); + expect_no_config(&mut gh); gh.expect_get_pr_files().never(); - // Run and check no command is created + // Setup event let event = setup_test_pr_opened_event(); + + // Run and check no command is created assert_eq!( Command::from_event_automatic(Arc::new(gh), &event).await.unwrap(), None @@ -150,36 +155,107 @@ async fn automatic_command_from_pr_event_config_not_found() { } #[tokio::test] -async fn automatic_command_from_pr_event_error_getting_pr_files() { +async fn automatic_command_from_pr_event_error_getting_config() { // Setup GitHub expectations let mut gh = MockGH::new(); gh.expect_get_config_file() .with(eq(INST_ID), eq(ORG), eq(REPO)) .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); - gh.expect_get_pr_files() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_get_pr_files().never(); - // Run and check the error is propagated + // Setup event + let event = setup_test_pr_opened_event(); + + // Run and check the error is propagated instead of reporting no command + let err = Command::from_event_automatic(Arc::new(gh), &event).await.unwrap_err(); + assert!(matches!( + err.downcast_ref::(), + Some(CfgError::ConfigLookup(_)) + )); +} + +#[tokio::test] +async fn automatic_command_from_pr_event_error_getting_pr_files() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_pr_files(&mut gh, Err(format_err!(ERROR))); + + // Setup event let event = setup_test_pr_opened_event(); + + // Run and check the error is propagated let err = Command::from_event_automatic(Arc::new(gh), &event).await.unwrap_err(); assert_eq!(err.to_string(), ERROR); } +#[tokio::test] +async fn automatic_command_from_pr_event_first_matching_rule_wins() { + // Setup cases (patterns of each rule, files in the PR, expected profile) + let cases = [ + (["*.md", "*.md"], vec!["README.md"], "default"), + (["docs/", "*.md"], vec!["docs/guide.md"], "default"), + (["*.md", "docs/**"], vec!["docs/guide.md"], "default"), + (["*.md", "src/**"], vec!["src/main.rs", "README.md"], "default"), + (["*.md", "src/**"], vec!["src/main.rs"], PROFILE_NAME), + ]; + + for ([first_pattern, second_pattern], files, expected_profile) in cases { + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_config_content( + &mut gh, + format!( + r#" +automation: + enabled: true + rules: + - patterns: ["{first_pattern}"] + profile: default + - patterns: ["{second_pattern}"] + profile: profile1 +profiles: + default: + duration: 5m + pass_threshold: 50 + profile1: + duration: 10m + pass_threshold: 75 +"# + ), + ); + let pr_files = files.iter().map(|filename| File { + filename: (*filename).to_string(), + }); + expect_pr_files(&mut gh, Ok(pr_files.collect())); + + // Setup event + let event = setup_test_pr_opened_event(); + + // Run and check the profile of the first rule that matches is used + assert_eq!( + Command::from_event_automatic(Arc::new(gh), &event).await.unwrap(), + Some(Command::CreateVote(CreateVoteInput::new_automatic( + expected_profile, + &event + ))), + "patterns: {first_pattern}, {second_pattern}, files: {files:?}" + ); + } +} + #[tokio::test] async fn automatic_command_from_pr_event_invalid_config() { // Setup GitHub expectations let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_invalid_config())))); + expect_config_content(&mut gh, get_test_invalid_config()); gh.expect_get_pr_files().never(); - // Run and check the configuration error is propagated + // Setup event let event = setup_test_pr_opened_event(); + + // Run and check the configuration error is propagated let err = Command::from_event_automatic(Arc::new(gh), &event).await.unwrap_err(); assert!(matches!( err.downcast_ref::(), @@ -191,21 +267,18 @@ async fn automatic_command_from_pr_event_invalid_config() { async fn automatic_command_from_pr_event_no_matching_files() { // Setup GitHub expectations let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); - gh.expect_get_pr_files() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _, _, _| { - Box::pin(future::ready(Ok(vec![File { - filename: "src/main.rs".to_string(), - }]))) - }); + expect_valid_config(&mut gh); + expect_pr_files( + &mut gh, + Ok(vec![File { + filename: "src/main.rs".to_string(), + }]), + ); - // Run and check no command is created + // Setup event let event = setup_test_pr_opened_event(); + + // Run and check no command is created assert_eq!( Command::from_event_automatic(Arc::new(gh), &event).await.unwrap(), None @@ -235,21 +308,21 @@ profiles: pass_threshold: 75 "#, ); - gh.expect_get_pr_files() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _, _, _| { - Box::pin(future::ready(Ok(vec![File { - filename: "src/main.rs".to_string(), - }]))) - }); + expect_pr_files( + &mut gh, + Ok(vec![File { + filename: "src/main.rs".to_string(), + }]), + ); - // Run and check the matching rule profile is used + // Setup event let event = setup_test_pr_opened_event(); + + // Run and check the matching rule profile is used assert_eq!( Command::from_event_automatic(Arc::new(gh), &event).await.unwrap(), - Some(Command::CreateVote(CreateVoteInput::new( - Some(PROFILE_NAME), + Some(Command::CreateVote(CreateVoteInput::new_automatic( + PROFILE_NAME, &event ))) ); @@ -283,8 +356,8 @@ fn cancel_vote_input_from_issue_comment_event_on_pr() { CancelVoteInput { cancelled_by: USER.to_string(), installation_id: 1, - issue_number: 2, is_pull_request: true, + issue_number: 2, repository_full_name: REPOFN.to_string(), } ); @@ -303,8 +376,8 @@ fn cancel_vote_input_from_issue_event() { CancelVoteInput { cancelled_by: USER.to_string(), installation_id: 1, - issue_number: 2, is_pull_request: false, + issue_number: 2, repository_full_name: REPOFN.to_string(), } ); @@ -323,8 +396,8 @@ fn cancel_vote_input_from_pr_event() { CancelVoteInput { cancelled_by: USER.to_string(), installation_id: 1, - issue_number: 2, is_pull_request: true, + issue_number: 2, repository_full_name: REPOFN.to_string(), } ); @@ -342,6 +415,8 @@ fn check_vote_input_from_issue_comment_event() { CheckVoteInput { issue_number: 2, repository_full_name: REPOFN.to_string(), + + installation_id: Some(INST_ID as i64), } ); } @@ -358,6 +433,8 @@ fn check_vote_input_from_issue_event() { CheckVoteInput { issue_number: 2, repository_full_name: REPOFN.to_string(), + + installation_id: Some(INST_ID as i64), } ); } @@ -374,6 +451,8 @@ fn check_vote_input_from_pr_event() { CheckVoteInput { issue_number: 2, repository_full_name: REPOFN.to_string(), + + installation_id: Some(INST_ID as i64), } ); } @@ -382,24 +461,23 @@ fn check_vote_input_from_pr_event() { async fn command_from_event_automatic_command() { // Setup GitHub expectations let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); - gh.expect_get_pr_files() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _, _, _| { - Box::pin(future::ready(Ok(vec![File { - filename: "README.md".to_string(), - }]))) - }); + expect_valid_config(&mut gh); + expect_pr_files( + &mut gh, + Ok(vec![File { + filename: "README.md".to_string(), + }]), + ); - // Run and check the automatic command is returned + // Setup event let event = setup_test_pr_opened_event(); + + // Run and check the automatic command is returned assert_eq!( - Command::from_event(Arc::new(gh), &event).await, - Some(Command::CreateVote(CreateVoteInput::new(Some("default"), &event))) + Command::from_event(Arc::new(gh), &event).await.unwrap(), + Some(Command::CreateVote(CreateVoteInput::new_automatic( + "default", &event + ))) ); } @@ -407,15 +485,18 @@ async fn command_from_event_automatic_command() { async fn command_from_event_automatic_command_error() { // Setup GitHub expectations let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_invalid_config())))); + expect_config_content(&mut gh, get_test_invalid_config()); gh.expect_get_pr_files().never(); - // Run and check errors result in no command + // Setup event let event = setup_test_pr_opened_event(); - assert_eq!(Command::from_event(Arc::new(gh), &event).await, None); + + // Run and check the error is returned, so that it is not mistaken for no command + let err = Command::from_event(Arc::new(gh), &event).await.unwrap_err(); + assert!(matches!( + err.downcast_ref::(), + Some(CfgError::InvalidConfig(_)) + )); } #[tokio::test] @@ -429,7 +510,7 @@ async fn command_from_event_manual_command_takes_precedence() { // Run and check the manual command is returned assert_eq!( - Command::from_event(gh, &event).await, + Command::from_event(gh, &event).await.unwrap(), Some(Command::CreateVote(CreateVoteInput::new( Some(PROFILE_NAME), &event @@ -447,7 +528,7 @@ async fn command_from_event_no_command() { let event = Event::IssueComment(event); // Run and check no command is returned - assert_eq!(Command::from_event(gh, &event).await, None); + assert_eq!(Command::from_event(gh, &event).await.unwrap(), None); } #[test] @@ -462,15 +543,18 @@ fn create_vote_input_from_issue_comment_event() { assert_eq!( CreateVoteInput::new(Some(PROFILE_NAME), &Event::IssueComment(event)), CreateVoteInput { - profile_name: Some(PROFILE_NAME.to_string()), created_by: USER.to_string(), installation_id: 1, + is_automatic: false, + is_pull_request: false, issue_id: 2, issue_number: 3, issue_title: TITLE.to_string(), - is_pull_request: false, repository_full_name: REPOFN.to_string(), + + opened_pr_base_branch: None, organization: Some(ORG.to_string()), + profile_name: Some(PROFILE_NAME.to_string()), } ); } @@ -500,15 +584,18 @@ fn create_vote_input_from_issue_event() { assert_eq!( CreateVoteInput::new(None, &Event::Issue(event)), CreateVoteInput { - profile_name: None, created_by: USER.to_string(), installation_id: 1, + is_automatic: false, + is_pull_request: false, issue_id: 2, issue_number: 3, issue_title: TITLE.to_string(), - is_pull_request: false, repository_full_name: REPOFN.to_string(), + + opened_pr_base_branch: None, organization: Some(ORG.to_string()), + profile_name: None, } ); } @@ -539,15 +626,35 @@ fn create_vote_input_from_pr_event() { assert_eq!( CreateVoteInput::new(Some(PROFILE_NAME), &Event::PullRequest(event)), CreateVoteInput { - profile_name: Some(PROFILE_NAME.to_string()), created_by: USER.to_string(), installation_id: 1, + is_automatic: false, + is_pull_request: true, issue_id: 2, issue_number: 3, issue_title: TITLE.to_string(), - is_pull_request: true, repository_full_name: REPOFN.to_string(), + + opened_pr_base_branch: Some(BRANCH.to_string()), organization: Some("other-org".to_string()), + profile_name: Some(PROFILE_NAME.to_string()), + } + ); +} + +#[test] +fn create_vote_input_new_automatic() { + // Setup event + let event = Event::PullRequest(setup_test_pr_event()); + + // Check only the origin of the vote differs from the one of a manual command + let input = CreateVoteInput::new_automatic(PROFILE_NAME, &event); + assert!(input.is_automatic); + assert_eq!( + input, + CreateVoteInput { + is_automatic: true, + ..CreateVoteInput::new(Some(PROFILE_NAME), &event) } ); } @@ -582,23 +689,6 @@ fn manual_command_from_issue_comment_event_check_vote_cmd() { ); } -#[test] -fn manual_command_from_issue_comment_event_profile_with_dash_and_underscore() { - for profile in ["supply-chain-removal", "my_profile", "my-profile_1"] { - // Setup event - let mut event = setup_test_issue_comment_event(); - event.action = IssueCommentEventAction::Created; - event.comment.body = Some(format!("/{CMD_CREATE_VOTE}-{profile}")); - let event = Event::IssueComment(event); - - // Run and check the manual command is returned with the full profile - assert_eq!( - Command::from_event_manual(&event), - Some(Command::CreateVote(CreateVoteInput::new(Some(profile), &event))) - ); - } -} - #[test] fn manual_command_from_issue_comment_event_cmd_in_issue_body_is_ignored() { // Setup event with command only in issue body @@ -699,6 +789,46 @@ fn manual_command_from_issue_comment_event_create_vote_cmd_profile1() { ); } +#[test] +fn manual_command_from_issue_comment_event_crlf_line_endings() { + // Setup cases (comment body, expected command name, expected profile) + let cases = [ + ("/vote\r\n", CMD_CREATE_VOTE, None), + ("/vote\r", CMD_CREATE_VOTE, None), + ("Some text\r\n/vote\r\nMore text", CMD_CREATE_VOTE, None), + ( + "Some text\r\n\r\n/vote-profile1\r\n", + CMD_CREATE_VOTE, + Some(PROFILE_NAME), + ), + ("/vote-profile1\r\nMore text", CMD_CREATE_VOTE, Some(PROFILE_NAME)), + ("/cancel-vote\r\n/vote\r\n", CMD_CANCEL_VOTE, None), + ("Some text\r\n/cancel-vote\r\n", CMD_CANCEL_VOTE, None), + ("/check-vote\r\n", CMD_CHECK_VOTE, None), + ("Some text\r\n/check-vote\r\nMore text", CMD_CHECK_VOTE, None), + ]; + + for (body, cmd, profile) in cases { + // Setup event + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Created; + event.comment.body = Some(body.to_string()); + let event = Event::IssueComment(event); + + // Run and check the expected command is returned + let expected_cmd = match cmd { + CMD_CANCEL_VOTE => Command::CancelVote(CancelVoteInput::new(&event)), + CMD_CHECK_VOTE => Command::CheckVote(CheckVoteInput::new(&event)), + _ => Command::CreateVote(CreateVoteInput::new(profile, &event)), + }; + assert_eq!( + Command::from_event_manual(&event), + Some(expected_cmd), + "body: {body:?}" + ); + } +} + #[test] fn manual_command_from_issue_comment_event_first_cmd_wins() { // Setup event with multiple commands @@ -714,6 +844,78 @@ fn manual_command_from_issue_comment_event_first_cmd_wins() { ); } +#[test] +fn manual_command_from_issue_comment_event_indented_or_quoted_cmds_are_not_commands() { + // Check each indented or quoted command body + for body in [ + " /vote", + " /vote", + "\t/vote", + "> /vote", + ">/vote", + "> /cancel-vote", + "> /check-vote", + "- /vote", + "* /vote", + "`/vote`", + "```\n /vote\n```", + ] { + // Setup event + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Created; + event.comment.body = Some(body.to_string()); + let event = Event::IssueComment(event); + + // Run and check no command is returned + assert_eq!(Command::from_event_manual(&event), None, "body: {body:?}"); + } +} + +#[test] +fn manual_command_from_issue_comment_event_invalid_suffix_does_not_hide_later_cmd() { + // Setup event with a valid command after a line that is not a command anymore + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Created; + event.comment.body = Some("/cancel-vote-profile1\n/check-vote".to_string()); + let event = Event::IssueComment(event); + + // Run and check the valid command is returned + assert_eq!( + Command::from_event_manual(&event), + Some(Command::CheckVote(CheckVoteInput::new(&event))) + ); +} + +#[test] +fn manual_command_from_issue_comment_event_invalid_suffixes_are_not_commands() { + // Check each command body with an invalid suffix + for body in [ + "/cancel-vote-", + "/cancel-vote-profile1", + "/cancel-votes", + "/check-vote-", + "/check-vote-profile1", + "/check-votes", + "/vote-", + "/vote-café", + "/vote-foo bar", + "/vote-foo.bar", + "/vote-foo/bar", + "/voter", + "/votes", + "/vote_profile1", + ] { + // Setup event + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Created; + event.comment.body = Some(body.to_string()); + let event = Event::IssueComment(event); + + // Run and check no command is returned + assert_eq!(Command::from_event_manual(&event), None, "body: {body}"); + } +} + #[test] fn manual_command_from_issue_comment_event_no_body() { // Setup event without body @@ -726,6 +928,42 @@ fn manual_command_from_issue_comment_event_no_body() { assert_eq!(Command::from_event_manual(&event), None); } +#[test] +fn manual_command_from_issue_comment_event_profile_keeps_its_case() { + // Check each profile name with uppercase letters + for profile in ["Profile1", "PROFILE", "myProfile-A_1"] { + // Setup event + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Created; + event.comment.body = Some(format!("/{CMD_CREATE_VOTE}-{profile}")); + let event = Event::IssueComment(event); + + // Run and check the profile is returned as it was written + assert_eq!( + Command::from_event_manual(&event), + Some(Command::CreateVote(CreateVoteInput::new(Some(profile), &event))) + ); + } +} + +#[test] +fn manual_command_from_issue_comment_event_profile_with_dash_and_underscore() { + // Check each profile name with dashes or underscores + for profile in ["supply-chain-removal", "my_profile", "my-profile_1"] { + // Setup event + let mut event = setup_test_issue_comment_event(); + event.action = IssueCommentEventAction::Created; + event.comment.body = Some(format!("/{CMD_CREATE_VOTE}-{profile}")); + let event = Event::IssueComment(event); + + // Run and check the manual command is returned with the full profile + assert_eq!( + Command::from_event_manual(&event), + Some(Command::CreateVote(CreateVoteInput::new(Some(profile), &event))) + ); + } +} + #[test] fn manual_command_from_issue_comment_event_unsupported_action() { // Setup event with unsupported action @@ -924,17 +1162,438 @@ fn manual_command_from_pr_event_unsupported_action() { assert_eq!(Command::from_event_manual(&event), None); } +// Module functions. + +#[tokio::test] +async fn create_no_vote_check_run_automation_disabled_reports_success() { + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_config_file( + &mut gh, + r#" +automation: + enabled: false + rules: + - patterns: + - "*.md" + profile: default +profiles: {} +"#, + ); + gh.expect_get_pr_files().never(); + expect_check_run(&mut gh, no_vote_found_check()); + + // Run and check the check run is created + create_no_vote_check_run(Arc::new(db), Arc::new(gh), INST_ID, ORG, REPO, ISSUE_NUM) + .await + .unwrap(); +} + +#[tokio::test] +async fn create_no_vote_check_run_config_not_found_reports_success() { + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_no_config(&mut gh); + gh.expect_get_pr_files().never(); + expect_check_run(&mut gh, no_vote_found_check()); + + // Run and check the check run is created + create_no_vote_check_run(Arc::new(db), Arc::new(gh), INST_ID, ORG, REPO, ISSUE_NUM) + .await + .unwrap(); +} + +#[tokio::test] +async fn create_no_vote_check_run_error_checking_vote_is_returned() { + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Err(format_err!(ERROR))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_no_config(&mut gh); + gh.expect_create_check_run().never(); + + // Run and check the error is returned + let err = create_no_vote_check_run(Arc::new(db), Arc::new(gh), INST_ID, ORG, REPO, ISSUE_NUM) + .await + .unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn create_no_vote_check_run_error_creating_check_run_is_returned() { + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_no_config(&mut gh); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(no_vote_found_check()), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run and check the error is returned + let err = create_no_vote_check_run(Arc::new(db), Arc::new(gh), INST_ID, ORG, REPO, ISSUE_NUM) + .await + .unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn create_no_vote_check_run_error_getting_config_reports_failure() { + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_get_pr_files().never(); + expect_check_run(&mut gh, automation_failed_check()); + + // Run and check the check run is created + create_no_vote_check_run(Arc::new(db), Arc::new(gh), INST_ID, ORG, REPO, ISSUE_NUM) + .await + .unwrap(); +} + +#[tokio::test] +async fn create_no_vote_check_run_error_getting_pr_files_reports_failure() { + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_pr_files(&mut gh, Err(format_err!(ERROR))); + expect_check_run(&mut gh, automation_failed_check()); + + // Run and check the check run is created + create_no_vote_check_run(Arc::new(db), Arc::new(gh), INST_ID, ORG, REPO, ISSUE_NUM) + .await + .unwrap(); +} + +#[tokio::test] +async fn create_no_vote_check_run_invalid_config_reports_failure() { + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_config_content(&mut gh, get_test_invalid_config()); + gh.expect_get_pr_files().never(); + expect_check_run(&mut gh, automation_failed_check()); + + // Run and check the check run is created + create_no_vote_check_run(Arc::new(db), Arc::new(gh), INST_ID, ORG, REPO, ISSUE_NUM) + .await + .unwrap(); +} + +#[tokio::test] +async fn create_no_vote_check_run_no_rule_matches_reports_success() { + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_pr_files( + &mut gh, + Ok(vec![File { + filename: "main.rs".to_string(), + }]), + ); + expect_check_run(&mut gh, no_vote_found_check()); + + // Run and check the check run is created + create_no_vote_check_run(Arc::new(db), Arc::new(gh), INST_ID, ORG, REPO, ISSUE_NUM) + .await + .unwrap(); +} + +#[tokio::test] +async fn create_no_vote_check_run_rule_matches_reports_failure() { + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_pr_files( + &mut gh, + Ok(vec![File { + filename: "README.md".to_string(), + }]), + ); + expect_check_run( + &mut gh, + CheckDetails { + status: "completed".to_string(), + summary: CHECK_SUMMARY_AUTOMATION_RULE_MATCHED.to_string(), + + conclusion: Some("failure".to_string()), + }, + ); + + // Run and check the check run is created + create_no_vote_check_run(Arc::new(db), Arc::new(gh), INST_ID, ORG, REPO, ISSUE_NUM) + .await + .unwrap(); +} + +#[tokio::test] +async fn create_no_vote_check_run_with_vote_skips_check_run() { + // Setup cases (config result, pr files requests) for every check outcome, all owned + // by the vote: success, rule matched and failed evaluation + type ConfigResult = fn() -> Result>; + let cases: [(ConfigResult, usize); 3] = [ + (|| Ok(None), 0), + (|| Ok(Some(get_test_valid_config())), 1), + (|| Err(format_err!(ERROR)), 0), + ]; + + for (config_result, pr_files_requests) in cases { + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(true)); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(move |_, _, _| Box::pin(future::ready(config_result()))); + gh.expect_get_pr_files() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) + .times(pr_files_requests) + .returning(|_, _, _, _| { + Box::pin(future::ready(Ok(vec![File { + filename: "README.md".to_string(), + }]))) + }); + gh.expect_create_check_run().never(); + + // Run and check nothing is reported + create_no_vote_check_run(Arc::new(db), Arc::new(gh), INST_ID, ORG, REPO, ISSUE_NUM) + .await + .unwrap(); + } +} + +#[tokio::test] +async fn publish_no_vote_check_error_checking_vote_is_returned() { + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Err(format_err!(ERROR))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_create_check_run().never(); + + // Setup check details + let check_details = no_vote_found_check(); + + // Run and check the error is returned + let err = publish_no_vote_check( + Arc::new(db), + Arc::new(gh), + INST_ID, + ORG, + REPO, + ISSUE_NUM, + &check_details, + ) + .await + .unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn publish_no_vote_check_with_vote_skips_check_run() { + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(true)); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_create_check_run().never(); + + // Setup check details + let check_details = no_vote_found_check(); + + // Run and check nothing is reported + publish_no_vote_check( + Arc::new(db), + Arc::new(gh), + INST_ID, + ORG, + REPO, + ISSUE_NUM, + &check_details, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn publish_no_vote_check_without_vote_creates_check_run() { + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_check_run(&mut gh, no_vote_found_check()); + + // Setup check details + let check_details = no_vote_found_check(); + + // Run and check the check run is created + publish_no_vote_check( + Arc::new(db), + Arc::new(gh), + INST_ID, + ORG, + REPO, + ISSUE_NUM, + &check_details, + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn update_no_vote_check_check_not_required_skips_evaluation() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_is_check_required(&mut gh, false); + gh.expect_get_config_file().never(); + gh.expect_create_check_run().never(); + + // Run and check nothing is reported + update_no_vote_check(Arc::new(db), Arc::new(gh), INST_ID, ORG, REPO, ISSUE_NUM, BRANCH) + .await + .unwrap(); +} + +#[tokio::test] +async fn update_no_vote_check_check_required_creates_check_run() { + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_is_check_required(&mut gh, true); + expect_no_config(&mut gh); + expect_check_run(&mut gh, no_vote_found_check()); + + // Run and check the check run is created + update_no_vote_check(Arc::new(db), Arc::new(gh), INST_ID, ORG, REPO, ISSUE_NUM, BRANCH) + .await + .unwrap(); +} + +#[tokio::test] +async fn update_no_vote_check_error_checking_if_required_is_returned() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_is_check_required() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_create_check_run().never(); + + // Run and check the error is returned + let err = update_no_vote_check(Arc::new(db), Arc::new(gh), INST_ID, ORG, REPO, ISSUE_NUM, BRANCH) + .await + .unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn update_no_vote_check_with_vote_skips_check_run() { + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(true)); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_is_check_required(&mut gh, true); + expect_no_config(&mut gh); + gh.expect_create_check_run().never(); + + // Run and check nothing is reported + update_no_vote_check(Arc::new(db), Arc::new(gh), INST_ID, ORG, REPO, ISSUE_NUM, BRANCH) + .await + .unwrap(); +} + // Helpers. -/// Expect a single configuration file request returning the content provided. +/// Expects a single configuration file request returning the owned content provided. +fn expect_config_content(gh: &mut MockGH, config: String) { + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .return_once(move |_, _, _| Box::pin(future::ready(Ok(Some(config))))); +} + +/// Expects a single configuration file request returning the content provided. fn expect_config_file(gh: &mut MockGH, config: &'static str) { gh.expect_get_config_file() .with(eq(INST_ID), eq(ORG), eq(REPO)) .times(1) - .returning(move |_, _, _| Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string())))); + .returning(move |_, _, _| { + Box::pin(future::ready(Ok(Some( + config.trim_start_matches('\n').to_string(), + )))) + }); +} + +/// Expects a single pull request files request returning the result provided. +fn expect_pr_files(gh: &mut MockGH, files: Result>) { + gh.expect_get_pr_files() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) + .times(1) + .return_once(move |_, _, _, _| Box::pin(future::ready(files))); } -/// Setup a pull request opened event without body. +/// Sets up a pull request opened event without body. fn setup_test_pr_opened_event() -> Event { let mut event = setup_test_pr_event(); event.action = PullRequestEventAction::Opened; diff --git a/src/db.rs b/src/db.rs index f50b64c..36638f7 100644 --- a/src/db.rs +++ b/src/db.rs @@ -2,56 +2,156 @@ use std::sync::Arc; -use anyhow::Result; +use anyhow::{Result, bail}; use async_trait::async_trait; -use deadpool_postgres::{Pool, Transaction}; +use deadpool_postgres::{GenericClient, Pool}; #[cfg(test)] use mockall::automock; +use thiserror::Error; +use time::OffsetDateTime; use tokio_postgres::types::Json; +use tracing::warn; use uuid::Uuid; use crate::{ cfg_repo::CfgProfile, cmd::{CheckVoteInput, CreateVoteInput}, - github::{self, DynGH, split_full_name}, - results::{self, Vote, VoteResults}, + results::{Vote, VoteResults}, }; -/// Type alias to represent a DB trait object. -pub(crate) type DynDB = Arc; +#[cfg(test)] +mod tests; + +/// How long a claimed vote is reserved for the worker that claimed it, in seconds. If +/// the worker dies the vote is picked up again after this time, and no other worker can +/// claim it until then. It must be longer than a closing attempt takes: the processor +/// stops attempts that take longer than its attempt timeout, which a compile time +/// assertion keeps well below this duration. +pub(crate) const CLOSE_CLAIM_DURATION_SECS: i64 = 1800; + +/// Number of failed attempts after which the closing side effects of a closed vote, or +/// the publication of a cancellation, are no longer retried. With the delays below the +/// last attempt happens after about 20 hours. Closing a vote restarts its attempts, so +/// its side effects get them all. Finished votes still open have no limit, as only +/// closing them publishes their results: they are retried at the maximum delay. +pub(crate) const CLOSE_MAX_ATTEMPTS: i32 = 12; + +/// Delay before retrying a failed attempt to close a vote, doubled on each attempt. +const CLOSE_RETRY_BASE_DELAY_SECS: f64 = 60.0; + +/// Maximum delay before retrying a failed attempt to close a vote. +const CLOSE_RETRY_MAX_DELAY_SECS: f64 = 21600.0; + +/// Highest number of times the base delay is doubled. The maximum delay is reached well +/// before it, and capping it keeps the power from overflowing as attempts grow. +const CLOSE_RETRY_MAX_EXPONENT: i32 = 30; /// Trait that defines some operations a DB implementation must support. #[async_trait] +#[allow(clippy::ref_option_ref)] #[cfg_attr(test, automock)] pub(crate) trait DB { - /// Cancel open vote (if exists) in the issue/pr provided. - async fn cancel_vote(&self, repository_full_name: &str, issue_number: i64) -> Result>; + /// Cancels the open vote (if any) in the issue/pr provided, returning it. The vote + /// is closed and marked as cancelled right away, with the publication of the + /// cancellation pending and claimed by the caller, so that the votes closer only + /// takes over if the caller fails to publish it. Once published, the vote is + /// deleted (see [`DB::complete_vote_cancellation`]), as well as when its publication + /// has no attempts left (see [`DB::record_vote_close_failure`]). + async fn cancel_vote( + &self, + repository_full_name: &str, + issue_number: i64, + cancelled_by: &str, + ) -> Result>; + + /// Claims the vote that has been waiting the longest for closing work: a finished + /// vote to close, a closed vote with side effects pending or a cancelled vote whose + /// cancellation has not been published yet. Votes waiting for a retry are not + /// selected, nor closed votes without attempts left. Finished votes still open are + /// selected however many attempts failed. A claim lasts until the outcome is + /// recorded, or expires if that never happens. + async fn claim_vote_to_close(&self) -> Result>; + + /// Closes the vote provided storing its results and the head commit of its pull + /// request (if known), leaving its closing side effects pending. Its failed attempts + /// are restarted, so the side effects get all of them, and its claim is kept. It + /// returns false if the vote is not open anymore. + async fn close_vote( + &self, + vote_id: Uuid, + results: Option<&VoteResults>, + head_sha: Option<&str>, + ) -> Result; - /// Close any pending finished vote. - async fn close_finished_vote(&self, gh: DynGH) -> Result)>>; + /// Deletes the cancelled vote provided once its cancellation has been published, as + /// nothing else needs it. Votes not cancelled are never deleted. + async fn complete_vote_cancellation(&self, vote_id: Uuid) -> Result<()>; - /// Get open vote (if available) in the issue/pr provided. + /// Marks the closing side effects of the vote provided as complete. + async fn complete_vote_close(&self, vote_id: Uuid) -> Result<()>; + + /// Gets open vote (if available) in the issue/pr provided. async fn get_open_vote(&self, repository_full_name: &str, issue_number: i64) -> Result>; - /// Get open votes that have close on passing enabled. + /// Gets open votes that have close on passing enabled. async fn get_open_votes_with_close_on_passing(&self) -> Result>; - /// Get pending status checks. + /// Gets pending status checks. async fn get_pending_status_checks(&self) -> Result>; - /// Get vote by id. + /// Gets vote by id. Cancelled votes are not returned, even while their cancellation + /// is pending publication. async fn get_vote(&self, vote_id: Uuid) -> Result>; - /// Check if the issue/pr provided has a vote. + /// Checks if the issue/pr provided has a vote that owns its check. A cancelled vote + /// only counts until the check run step of its cancellation is done, as nothing + /// publishes the check of the pull request heads pushed after that. It stops + /// counting as well when its cancellation is abandoned, as the vote is deleted then. async fn has_vote(&self, repository_full_name: &str, issue_number: i64) -> Result; - /// Check if the issue/pr provided already has a vote open. + /// Checks if the issue/pr provided already has a vote open. async fn has_vote_open(&self, repository_full_name: &str, issue_number: i64) -> Result; - /// List all votes stored for the provided repository. + /// Checks if a vote referencing the vote comment provided exists. + async fn has_vote_with_comment(&self, vote_comment_id: i64) -> Result; + + /// Checks if the check run or labels step provided of the vote still has to publish + /// its state, according to the vote stored rather than to the one the caller read. + /// It does not once it has been recorded as done, or if the vote does not exist + /// anymore, as well as if a newer vote exists in the same issue/pr, either open, + /// closed or cancelled with its cancellation pending publication. Newer votes + /// already deleted are not seen, but storing a vote marks these steps of the older + /// votes as done (see [`DB::store_vote`]). Other steps are never superseded, so + /// checking them returns an error. + async fn is_close_state_step_pending(&self, vote_id: Uuid, step: VoteCloseStep) -> Result; + + /// Lists all votes stored for the provided repository. Cancelled votes are not + /// listed, even while their cancellation is pending publication. async fn list_votes(&self, repository_full_name: &str) -> Result>; - /// Store the vote provided in the database. + /// Records a failed attempt to close the vote provided, or to publish its + /// cancellation, scheduling the next one with an exponential backoff. Votes still + /// open are always rescheduled. When a cancelled vote runs out of attempts, its + /// cancellation is abandoned and the vote is deleted, leaving the steps still + /// missing unpublished, so the issue/pr behaves as if it had no vote. The failure is + /// only recorded while the caller still holds the claim it got the vote with, which + /// is identified by the time it ends at: a claim expired and claimed again, or taken + /// over by the cancellation of the vote, belongs to another worker, whose attempts + /// must not be rescheduled. + async fn record_vote_close_failure( + &self, + vote_id: Uuid, + claimed_until: OffsetDateTime, + ) -> Result; + + /// Records that the closing or cancellation side effect provided is done, so it is + /// not repeated. A step recorded already keeps the time it was first recorded at. + async fn record_vote_close_step(&self, vote_id: Uuid, step: VoteCloseStep) -> Result<()>; + + /// Stores the vote provided in the database. If the issue/pr has a vote open + /// already the error returned is a [`VoteAlreadyOpenError`]. The new vote + /// supersedes the state of the older votes in the issue/pr, so their check run and + /// labels steps still pending are marked as done without publishing anything. async fn store_vote( &self, vote_comment_id: i64, @@ -59,118 +159,247 @@ pub(crate) trait DB { cfg: &CfgProfile, ) -> Result; - /// Update vote's ending timestamp. + /// Finishes the vote provided now, unless it has finished already: reactions are + /// only counted until the end of the vote, so that end must not move forward. async fn update_vote_ends_at(&self, vote_id: Uuid) -> Result<()>; - /// Update vote's last check ts. + /// Updates vote's last check ts. async fn update_vote_last_check(&self, vote_id: Uuid) -> Result<()>; } +/// Type alias to represent a DB trait object. +pub(crate) type DynDB = Arc; + +/// Error returned when storing a vote in an issue/pr that has a vote open already. +#[derive(Debug, Error)] +#[error("the issue or pull request has a vote open already")] +pub(crate) struct VoteAlreadyOpenError; + +/// Outcome of recording a failed attempt to close a vote or to publish its cancellation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum VoteCloseFailureOutcome { + /// The failure was the last attempt to publish the cancellation of the vote, so the + /// cancellation was abandoned and the vote deleted. + CancellationAbandoned, + /// Nothing was recorded, as the caller does not hold the claim of the vote anymore + /// or the vote does not exist. + ClaimLost, + /// The failure was recorded, and there are no attempts left for the closing side + /// effects of the vote, which was closed already. + NoAttemptsLeft, + /// The failure was recorded, and the next attempt has been scheduled. + RetryScheduled, +} + +/// Side effects of closing or cancelling a vote that are tracked individually. A step is +/// recorded once it needs no more work, even if nothing was published: GitHub may have +/// rejected the announcement, and the check run and labels are skipped when a newer vote +/// exists or has existed (storing a vote marks them as done in the older votes). +/// Cancellations only use the comment, check run and labels steps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum VoteCloseStep { + /// Results announcement posted to discussions. + Announcement, + /// Closing check run created. + CheckRun, + /// Results comment posted. + Comment, + /// Closing labels updated. + Labels, +} + /// DB implementation backed by `PostgreSQL`. pub(crate) struct PgDB { + /// Pool of database connections. pool: Pool, } impl PgDB { - /// Create a new `PgDB` instance. + /// Creates a new `PgDB` instance. pub(crate) fn new(pool: Pool) -> Self { Self { pool } } +} - /// Get any pending finished vote. - async fn get_pending_finished_vote(tx: &Transaction<'_>) -> Result> { - let vote = tx +#[async_trait] +impl DB for PgDB { + /// [`DB::cancel_vote`]. + async fn cancel_vote( + &self, + repository_full_name: &str, + issue_number: i64, + cancelled_by: &str, + ) -> Result> { + // Close and mark as cancelled the open vote (if any), claiming the publication + // of its cancellation in the same statement + let db = self.pool.get().await?; + let vote = db .query_opt( " - select * - from vote - where current_timestamp > ends_at + update vote + set + cancelled_at = current_timestamp, + cancelled_by = $3::text, + close_attempts = 0, + close_next_attempt_at = current_timestamp + make_interval(secs => $4::bigint), + close_pending = true, + closed = true, + closed_at = current_timestamp + where repository_full_name = $1::text + and issue_number = $2::bigint and closed = false - order by random() - for update of vote skip locked - limit 1 + returning * ", - &[], + &[ + &repository_full_name, + &issue_number, + &cancelled_by, + &CLOSE_CLAIM_DURATION_SECS, + ], ) .await? - .map(|row| Vote::from(&row)); + .map(|row| Vote::try_from(&row)) + .transpose()?; Ok(vote) } - /// Store the vote results provided in the database. - async fn store_vote_results( - tx: &Transaction<'_>, - vote_id: Uuid, - results: Option<&VoteResults>, - ) -> Result<()> { - tx.execute( - " - update vote set - closed = true, - closed_at = current_timestamp, - results = $1::jsonb - where vote_id = $2::uuid; - ", - &[&Json(&results), &vote_id], - ) - .await?; - Ok(()) - } -} - -#[async_trait] -impl DB for PgDB { - /// [`DB::cancel_vote`] - async fn cancel_vote(&self, repository_full_name: &str, issue_number: i64) -> Result> { + /// [`DB::claim_vote_to_close`]. + async fn claim_vote_to_close(&self) -> Result> { + // Claim the next vote with closing work due. Cancelled votes whose cancellation + // is pending publication are included, as they are closed with close pending + // set. The attempts limit only applies to closed votes: finished votes still + // open are retried until they close, as only that publishes their results let db = self.pool.get().await?; - let cancelled_vote_id = db + let Some(row) = db .query_opt( " - delete from vote - where repository_full_name = $1::text - and issue_number = $2::bigint - and closed = false - returning vote_id + update vote v + set close_next_attempt_at = current_timestamp + make_interval(secs => $1::bigint) + where v.vote_id = ( + select c.vote_id + from vote c + where ( + (c.close_pending and c.close_attempts < $2::integer) + or (c.closed = false and current_timestamp > c.ends_at) + ) + and (c.close_next_attempt_at is null or c.close_next_attempt_at <= current_timestamp) + order by coalesce(c.close_next_attempt_at, c.ends_at) + for update skip locked + limit 1 + ) + returning * ", - &[&repository_full_name, &issue_number], + &[&CLOSE_CLAIM_DURATION_SECS, &CLOSE_MAX_ATTEMPTS], ) .await? - .and_then(|row| row.get("vote_id")); - Ok(cancelled_vote_id) - } - - /// [`DB::close_finished_vote`] - async fn close_finished_vote(&self, gh: DynGH) -> Result)>> { - // Get pending finished vote (if any) from database - let mut db = self.pool.get().await?; - let tx = db.transaction().await?; - let Some(vote) = PgDB::get_pending_finished_vote(&tx).await? else { + else { return Ok(None); }; - // Calculate results - let (owner, repo) = split_full_name(&vote.repository_full_name); - let results = match results::calculate(gh, owner, repo, &vote).await { - Ok(results) => Some(results), + // The worker never gets a vote that cannot be read, so its failure is recorded + // here to back off its next claim, which stops once a closed vote has no + // attempts left. The connection held is reused, as waiting for another one could + // deadlock a pool of one connection + match Vote::try_from(&row) { + Ok(vote) => Ok(Some(vote)), Err(err) => { - if github::is_not_found_error(&err) { - // Vote comment was deleted. We still want to proceed and - // close the vote so that we don't try again to close it. - None - } else { - return Err(err); + // Identify the vote in the error, as the caller cannot tell which it is + let vote_id: Uuid = row.try_get("vote_id")?; + let err = err.context(format!("error decoding claimed vote {vote_id}")); + + // Record the failure, keeping the decode error as the one returned if + // that fails too + let record_result = async { + let claimed_until = row.try_get("close_next_attempt_at")?; + record_vote_close_failure_with(&db, vote_id, claimed_until).await + } + .await; + + // Leave a trace of the attempts running out, as nothing else reports it + match record_result { + Ok(VoteCloseFailureOutcome::CancellationAbandoned) => { + Err(err.context("cancellation abandoned, no attempts left")) + } + Ok(VoteCloseFailureOutcome::NoAttemptsLeft) => Err(err.context("no attempts left")), + Ok(VoteCloseFailureOutcome::ClaimLost | VoteCloseFailureOutcome::RetryScheduled) => { + Err(err) + } + Err(record_err) => { + warn!( + ?record_err, + %vote_id, + "error recording failure of undecodable claimed vote" + ); + Err(err) + } } } - }; + } + } + + /// [`DB::close_vote`]. + async fn close_vote( + &self, + vote_id: Uuid, + results: Option<&VoteResults>, + head_sha: Option<&str>, + ) -> Result { + // Close the vote only while it is still open, restarting its attempts + let db = self.pool.get().await?; + let closed = db + .execute( + " + update vote + set + close_attempts = 0, + close_head_sha = $1::text, + close_pending = true, + closed = true, + closed_at = current_timestamp, + results = $2::jsonb + where vote_id = $3::uuid + and closed = false; + ", + &[&head_sha, &results.map(Json), &vote_id], + ) + .await?; + Ok(closed == 1) + } - // Store results in database - PgDB::store_vote_results(&tx, vote.vote_id, results.as_ref()).await?; - tx.commit().await?; + /// [`DB::complete_vote_cancellation`]. + async fn complete_vote_cancellation(&self, vote_id: Uuid) -> Result<()> { + // Delete the vote only if it was cancelled + let db = self.pool.get().await?; + db.execute( + " + delete from vote + where vote_id = $1::uuid + and cancelled_at is not null; + ", + &[&vote_id], + ) + .await?; + Ok(()) + } - Ok(Some((vote, results))) + /// [`DB::complete_vote_close`]. + async fn complete_vote_close(&self, vote_id: Uuid) -> Result<()> { + let db = self.pool.get().await?; + db.execute( + " + update vote + set + close_next_attempt_at = null, + close_pending = false + where vote_id = $1::uuid; + ", + &[&vote_id], + ) + .await?; + Ok(()) } - /// [`DB::get_open_vote`] + /// [`DB::get_open_vote`]. async fn get_open_vote(&self, repository_full_name: &str, issue_number: i64) -> Result> { let db = self.pool.get().await?; let vote = db @@ -185,88 +414,107 @@ impl DB for PgDB { &[&repository_full_name, &issue_number], ) .await? - .map(|row| Vote::from(&row)); + .map(|row| Vote::try_from(&row)) + .transpose()?; Ok(vote) } - /// [`DB::get_open_votes_with_close_on_passing`] + /// [`DB::get_open_votes_with_close_on_passing`]. async fn get_open_votes_with_close_on_passing(&self) -> Result> { + // Cancelled votes are closed, so they are never selected let db = self.pool.get().await?; let votes = db .query( " - select * - from vote - where closed = false - and cfg ? 'close_on_passing' - and (cfg->>'close_on_passing')::boolean = true - and - case - when cfg ? 'close_on_passing_min_wait' and string_to_interval(cfg->>'close_on_passing_min_wait') is not null then - current_timestamp > created_at + (cfg->>'close_on_passing_min_wait')::interval - else true - end + select v.* + from vote v + cross join lateral string_to_interval(v.cfg->>'close_on_passing_min_wait') as min_wait + where v.closed = false + and v.cfg->'close_on_passing' = 'true'::jsonb + and ( + v.cfg->>'close_on_passing_min_wait' is null + or ( + min_wait > '0'::interval + and current_timestamp - v.created_at > min_wait + ) + ) ", &[], ) .await? .iter() - .map(Vote::from) - .collect(); + .map(Vote::try_from) + .collect::>()?; Ok(votes) } - /// [`DB::get_pending_status_checks`] + /// [`DB::get_pending_status_checks`]. async fn get_pending_status_checks(&self) -> Result> { + // Cancelled votes are closed, so they are never selected let db = self.pool.get().await?; let inputs = db .query( " - select repository_full_name, issue_number - from vote - where closed = false - and cfg ? 'periodic_status_check' - and string_to_interval(cfg->>'periodic_status_check') is not null - and (cfg->>'periodic_status_check')::interval >= '1 day'::interval - and current_timestamp > created_at + (cfg->>'periodic_status_check')::interval - and - case when checked_at is not null then - current_timestamp > checked_at + (cfg->>'periodic_status_check')::interval - else true end - and ends_at > current_timestamp + '1 hour'::interval + select v.issue_number, v.repository_full_name + from vote v + cross join lateral string_to_interval(v.cfg->>'periodic_status_check') as check_period + where v.closed = false + and check_period >= '1 day'::interval + and current_timestamp - v.created_at > check_period + and (v.checked_at is null or current_timestamp - v.checked_at > check_period) + and v.ends_at > current_timestamp + '1 hour'::interval ", &[], ) .await? .iter() .map(|row| CheckVoteInput { - repository_full_name: row.get("repository_full_name"), issue_number: row.get("issue_number"), + repository_full_name: row.get("repository_full_name"), + + installation_id: None, }) .collect(); Ok(inputs) } - /// [`DB::get_vote`] + /// [`DB::get_vote`]. async fn get_vote(&self, vote_id: Uuid) -> Result> { + // Cancelled votes are left out, as they are only kept until their cancellation + // has been published let db = self.pool.get().await?; let vote = db - .query_opt("select * from vote where vote_id = $1::uuid", &[&vote_id]) + .query_opt( + " + select * + from vote + where vote_id = $1::uuid + and cancelled_at is null + ", + &[&vote_id], + ) .await? - .map(|row| Vote::from(&row)); + .map(|row| Vote::try_from(&row)) + .transpose()?; Ok(vote) } - /// [`DB::has_vote`] + /// [`DB::has_vote`]. async fn has_vote(&self, repository_full_name: &str, issue_number: i64) -> Result { + // Cancelled votes pending publication count until their check run step is done let db = self.pool.get().await?; let has_vote = db .query_one( " select exists ( - select 1 from vote + select 1 + from vote where repository_full_name = $1::text and issue_number = $2::bigint + and not ( + cancelled_at is not null + and close_check_run_created_at is not null + ) ) ", &[&repository_full_name, &issue_number], @@ -276,14 +524,17 @@ impl DB for PgDB { Ok(has_vote) } - /// [`DB::has_vote_open`] + /// [`DB::has_vote_open`]. async fn has_vote_open(&self, repository_full_name: &str, issue_number: i64) -> Result { + // Cancelled votes are closed, so a new vote can be created while the + // cancellation is pending publication let db = self.pool.get().await?; let has_vote_open = db .query_one( " select exists ( - select 1 from vote + select 1 + from vote where repository_full_name = $1::text and issue_number = $2::bigint and closed = false @@ -296,8 +547,82 @@ impl DB for PgDB { Ok(has_vote_open) } - /// [`DB::list_votes`] + /// [`DB::has_vote_with_comment`]. + async fn has_vote_with_comment(&self, vote_comment_id: i64) -> Result { + // Cancelled votes pending publication count, as they were stored and their vote + // comment must be kept + let db = self.pool.get().await?; + let has_vote_with_comment = db + .query_one( + " + select exists ( + select 1 + from vote + where vote_comment_id = $1::bigint + ) + ", + &[&vote_comment_id], + ) + .await? + .get(0); + Ok(has_vote_with_comment) + } + + /// [`DB::is_close_state_step_pending`]. + async fn is_close_state_step_pending(&self, vote_id: Uuid, step: VoteCloseStep) -> Result { + // Pick the query checking the step provided, as only the state steps can be + // superseded. Cancelled votes pending publication count as newer votes, as their + // own state supersedes the state of the older votes + let query = match step { + VoteCloseStep::Announcement | VoteCloseStep::Comment => { + bail!("only the check run and labels steps publish the state of a vote") + } + VoteCloseStep::CheckRun => { + " + select exists ( + select 1 + from vote v + where v.vote_id = $1::uuid + and v.close_check_run_created_at is null + and not exists ( + select 1 + from vote newer + where newer.repository_full_name = v.repository_full_name + and newer.issue_number = v.issue_number + and newer.created_at > v.created_at + ) + ) + " + } + VoteCloseStep::Labels => { + " + select exists ( + select 1 + from vote v + where v.vote_id = $1::uuid + and v.close_labels_updated_at is null + and not exists ( + select 1 + from vote newer + where newer.repository_full_name = v.repository_full_name + and newer.issue_number = v.issue_number + and newer.created_at > v.created_at + ) + ) + " + } + }; + + // Check the step against the vote stored + let db = self.pool.get().await?; + let is_pending = db.query_one(query, &[&vote_id]).await?.get(0); + Ok(is_pending) + } + + /// [`DB::list_votes`]. async fn list_votes(&self, repository_full_name: &str) -> Result> { + // Cancelled votes are left out, as they are only kept until their cancellation + // has been published let db = self.pool.get().await?; let votes = db .query( @@ -305,83 +630,165 @@ impl DB for PgDB { select * from vote where repository_full_name = $1::text + and cancelled_at is null order by created_at desc ", &[&repository_full_name], ) .await? .iter() - .map(Vote::from) - .collect(); + .map(Vote::try_from) + .collect::>()?; Ok(votes) } - /// [`DB::store_vote`] + /// [`DB::record_vote_close_failure`]. + async fn record_vote_close_failure( + &self, + vote_id: Uuid, + claimed_until: OffsetDateTime, + ) -> Result { + let db = self.pool.get().await?; + record_vote_close_failure_with(&db, vote_id, claimed_until).await + } + + /// [`DB::record_vote_close_step`]. + async fn record_vote_close_step(&self, vote_id: Uuid, step: VoteCloseStep) -> Result<()> { + // Pick the statement setting the step provided, unless it was set already + let statement = match step { + VoteCloseStep::Announcement => { + " + update vote + set close_announcement_posted_at = coalesce(close_announcement_posted_at, current_timestamp) + where vote_id = $1::uuid + " + } + VoteCloseStep::CheckRun => { + " + update vote + set close_check_run_created_at = coalesce(close_check_run_created_at, current_timestamp) + where vote_id = $1::uuid + " + } + VoteCloseStep::Comment => { + " + update vote + set close_comment_posted_at = coalesce(close_comment_posted_at, current_timestamp) + where vote_id = $1::uuid + " + } + VoteCloseStep::Labels => { + " + update vote + set close_labels_updated_at = coalesce(close_labels_updated_at, current_timestamp) + where vote_id = $1::uuid + " + } + }; + + // Record the step + let db = self.pool.get().await?; + db.execute(statement, &[&vote_id]).await?; + Ok(()) + } + + /// [`DB::store_vote`]. async fn store_vote( &self, vote_comment_id: i64, input: &CreateVoteInput, cfg: &CfgProfile, ) -> Result { + // Insert the vote unless the issue/pr has one open already. In the same + // statement, mark the check run and labels steps still pending of older votes in + // the issue/pr as done, as the new vote supersedes the state they would publish + // even if it is cancelled and deleted later (newer votes are only seen while + // they exist). Their comment and announcement are still published let db = self.pool.get().await?; - let vote_id = db - .query_one( + let row = db + .query_opt( " - insert into vote ( - vote_comment_id, - ends_at, - cfg, - created_by, - installation_id, - issue_id, - issue_number, - issue_title, - is_pull_request, - repository_full_name, - organization - - ) values ( - $1::bigint, - current_timestamp + ($2::bigint || ' seconds')::interval, - $3::jsonb, - $4::text, - $5::bigint, - $6::bigint, - $7::bigint, - $8::text, - $9::boolean, - $10::text, - $11::text + with inserted as ( + insert into vote ( + cfg, + created_by, + ends_at, + installation_id, + is_pull_request, + issue_id, + issue_number, + repository_full_name, + vote_comment_id, + issue_title, + organization + ) values ( + $1::jsonb, + $2::text, + current_timestamp + ($3::bigint || ' seconds')::interval, + $4::bigint, + $5::boolean, + $6::bigint, + $7::bigint, + $8::text, + $9::bigint, + $10::text, + $11::text + ) + on conflict (repository_full_name, issue_number) where closed = false do nothing + returning vote_id, issue_number, repository_full_name + ), superseded as ( + update vote v + set + close_check_run_created_at = coalesce( + v.close_check_run_created_at, + current_timestamp + ), + close_labels_updated_at = coalesce( + v.close_labels_updated_at, + current_timestamp + ) + from inserted i + where v.repository_full_name = i.repository_full_name + and v.issue_number = i.issue_number + and v.vote_id <> i.vote_id + and v.close_pending ) - returning vote_id + select vote_id + from inserted ", &[ - &vote_comment_id, - &(cfg.duration.as_secs() as i64), &Json(&cfg), &input.created_by, + &(cfg.duration.as_secs() as i64), &input.installation_id, + &input.is_pull_request, &input.issue_id, &input.issue_number, - &input.issue_title, - &input.is_pull_request, &input.repository_full_name, + &vote_comment_id, + &input.issue_title, &input.organization, ], ) - .await? - .get("vote_id"); - Ok(vote_id) + .await?; + + // No row is returned when the insert was skipped + let Some(row) = row else { + return Err(VoteAlreadyOpenError.into()); + }; + Ok(row.get("vote_id")) } - /// [`DB::update_vote_ends_at`] + /// [`DB::update_vote_ends_at`]. async fn update_vote_ends_at(&self, vote_id: Uuid) -> Result<()> { + // Move the end of the vote to now, unless it has finished already let db = self.pool.get().await?; db.execute( " - update vote set - ends_at = current_timestamp - where vote_id = $1::uuid; + update vote + set ends_at = current_timestamp + where vote_id = $1::uuid + and ends_at > current_timestamp; ", &[&vote_id], ) @@ -389,13 +796,13 @@ impl DB for PgDB { Ok(()) } - /// [`DB::update_vote_last_check`] + /// [`DB::update_vote_last_check`]. async fn update_vote_last_check(&self, vote_id: Uuid) -> Result<()> { let db = self.pool.get().await?; db.execute( " - update vote set - checked_at = current_timestamp + update vote + set checked_at = current_timestamp where vote_id = $1::uuid; ", &[&vote_id], @@ -404,3 +811,79 @@ impl DB for PgDB { Ok(()) } } + +// Helpers. + +/// Records a failed attempt to close the vote provided using the database client given +/// (see [`DB::record_vote_close_failure`]). +async fn record_vote_close_failure_with( + db: &impl GenericClient, + vote_id: Uuid, + claimed_until: OffsetDateTime, +) -> Result { + // Provided that the caller still holds the claim, abandon the cancellation of a + // cancelled vote on its last attempt deleting the vote, or schedule the next attempt + // otherwise. Both statements see the same row, and their conditions exclude each + // other, so only one of them applies. The exponent of the delay is capped, as votes + // still open have no attempts limit + let row = db + .query_opt( + " + with abandoned as ( + delete from vote + where vote_id = $1::uuid + and close_next_attempt_at = $2::timestamptz + and cancelled_at is not null + and close_attempts + 1 >= $3::integer + returning vote_id + ), rescheduled as ( + update vote + set + close_attempts = close_attempts + 1, + close_next_attempt_at = current_timestamp + make_interval( + secs => least( + $4::double precision * 2 ^ least(close_attempts, $5::integer), + $6::double precision + ) + ) + where vote_id = $1::uuid + and close_next_attempt_at = $2::timestamptz + and not (cancelled_at is not null and close_attempts + 1 >= $3::integer) + returning + close_attempts, + closed + ) + select + false as has_attempts_left, + true as is_abandoned + from abandoned + union all + select + (not closed or close_attempts < $3::integer), + false + from rescheduled + ", + &[ + &vote_id, + &claimed_until, + &CLOSE_MAX_ATTEMPTS, + &CLOSE_RETRY_BASE_DELAY_SECS, + &CLOSE_RETRY_MAX_EXPONENT, + &CLOSE_RETRY_MAX_DELAY_SECS, + ], + ) + .await?; + + // Tell the caller whether the failure was recorded and if more attempts are left + let Some(row) = row else { + return Ok(VoteCloseFailureOutcome::ClaimLost); + }; + let outcome = if row.try_get("is_abandoned")? { + VoteCloseFailureOutcome::CancellationAbandoned + } else if row.try_get("has_attempts_left")? { + VoteCloseFailureOutcome::RetryScheduled + } else { + VoteCloseFailureOutcome::NoAttemptsLeft + }; + Ok(outcome) +} diff --git a/src/db/tests.rs b/src/db/tests.rs new file mode 100644 index 0000000..0811afd --- /dev/null +++ b/src/db/tests.rs @@ -0,0 +1,2026 @@ +//! Database contract tests. +//! +//! These tests run the real `PgDB` against a database with the real migrations +//! applied, so they are ignored by default. To run them, point the +//! `GITVOTE_TEST_DB_URL` environment variable at a migrated database and use +//! `cargo test db_contracts -- --ignored`. That database is only used as a +//! template: each test works on its own clone, which is dropped when it ends. + +use std::{error::Error as _, time::Duration}; + +use anyhow::Error; +use futures::future; +use serde_json::{Value, json}; +use time::{Duration as TimeDuration, OffsetDateTime}; +use tokio::time::timeout; +use uuid::Uuid; + +use crate::{ + cfg_repo::{AllowedVoters, Announcements, CfgProfile, DiscussionsAnnouncements, PassThresholdBase}, + cmd::CreateVoteInput, + testutil::*, +}; + +use super::*; + +/// Cause of the error reading a vote whose stored configuration profile is not valid. +const CFG_DECODE_ERROR: &str = "missing field `duration`"; + +/// Maximum time to wait for a claim using a pool of one connection, which must reuse the +/// connection it holds instead of waiting for another one. +const CLAIM_TIMEOUT: Duration = Duration::from_secs(5); + +/// Time allowed between setting the delay of the next attempt and reading it back. +const CLOSE_RETRY_DELAY_TOLERANCE_SECS: f64 = 5.0; + +/// Full name of a repository different from the one used by default in the tests. +const OTHER_REPOFN: &str = "org/other-repo"; + +/// Cause of the error reading a vote whose stored results are not valid. +const RESULTS_DECODE_ERROR: &str = "missing field `abstain`"; + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_cancel_vote_closes_and_claims_the_open_vote() { + // Setup database with a finished vote that failed to close twice + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + close_attempts: 2, + ends_in: "-1 minute", + ..Default::default() + }) + .await; + + // Cancel the vote + let vote = db.cancel_vote(REPOFN, ISSUE_NUM, USER1).await.unwrap().unwrap(); + + // Check the vote returned is closed and cancelled, with its cancellation pending + assert_eq!(vote.close_attempts, 0); + assert!(vote.close_pending); + assert!(vote.closed); + assert_eq!(vote.vote_id, vote_id); + assert_eq!(vote.cancelled_at, vote.closed_at); + assert_eq!(vote.cancelled_by.as_deref(), Some(USER1)); + assert_eq!(vote.close_announcement_posted_at, None); + assert_eq!(vote.close_check_run_created_at, None); + assert_eq!(vote.close_comment_posted_at, None); + assert_eq!(vote.close_labels_updated_at, None); + assert!(vote.closed_at.is_some()); + assert_eq!(vote.results, None); + assert_eq!(test_db.read_vote(vote_id).await, Some(vote.clone())); + + // Check the publication of the cancellation is claimed by the caller + let (close_attempts, delay) = test_db.close_retry_state().await; + assert_eq!(close_attempts, 0); + assert_close_retry_delay( + delay, + f64::from(i32::try_from(CLOSE_CLAIM_DURATION_SECS).unwrap()), + ); + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); + + // Check the votes closer can claim it once the claim expires + test_db.expire_close_claims().await; + let claimed_vote = db.claim_vote_to_close().await.unwrap().unwrap(); + assert_eq!(claimed_vote.vote_id, vote_id); + assert_eq!(claimed_vote.cancelled_by.as_deref(), Some(USER1)); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_cancel_vote_concurrent_calls_cancel_once() { + // Setup database with an open vote + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db.seed_vote(SeedVote::default()).await; + + // Cancel the vote concurrently + let calls = (0..TEST_DB_POOL_SIZE).map(|_| db.cancel_vote(REPOFN, ISSUE_NUM, USER1)); + let outcomes = future::join_all(calls).await; + + // Check only one call cancelled the vote + let cancelled: Vec = outcomes.into_iter().filter_map(|outcome| outcome.unwrap()).collect(); + assert_eq!(cancelled.len(), 1); + assert_eq!(cancelled[0].vote_id, vote_id); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_cancel_vote_without_open_vote_returns_none() { + // Setup database with a closed vote + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + closed: true, + ..Default::default() + }) + .await; + let vote = test_db.read_vote(vote_id).await; + + // Try cancelling the vote + let cancelled_vote = db.cancel_vote(REPOFN, ISSUE_NUM, USER1).await.unwrap(); + + // Check nothing was cancelled and the closed vote did not change + assert_eq!(cancelled_vote, None); + assert_eq!(test_db.read_vote(vote_id).await, vote); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_claim_vote_to_close_concurrent_calls_claim_once() { + // Setup database with a finished vote + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + ends_in: "-1 minute", + ..Default::default() + }) + .await; + + // Claim the vote concurrently + let calls = (0..TEST_DB_POOL_SIZE).map(|_| db.claim_vote_to_close()); + let outcomes = future::join_all(calls).await; + + // Check only one call got the vote + let claimed: Vec = outcomes.into_iter().filter_map(|outcome| outcome.unwrap()).collect(); + assert_eq!(claimed.len(), 1); + assert_eq!(claimed[0].vote_id, vote_id); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_claim_vote_to_close_expired_claim_can_be_claimed_again() { + // Setup database with a vote claimed by a worker that never finished + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + ends_in: "-1 hour", + + close_next_attempt_in: Some("-1 minute"), + ..Default::default() + }) + .await; + + // Check the vote is claimed, and not claimed twice + let vote = db.claim_vote_to_close().await.unwrap().unwrap(); + assert_eq!(vote.vote_id, vote_id); + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_claim_vote_to_close_returns_the_longest_waiting_first() { + // Setup database with several votes with closing work due + let test_db = TestDB::new().await; + let db = test_db.db(); + let newest = test_db + .seed_vote(SeedVote { + ends_in: "-1 minute", + issue_number: 1, + ..Default::default() + }) + .await; + let oldest = test_db + .seed_vote(SeedVote { + close_pending: true, + closed: true, + issue_number: 2, + + close_next_attempt_in: Some("-3 hours"), + ..Default::default() + }) + .await; + let middle = test_db + .seed_vote(SeedVote { + ends_in: "-2 hours", + issue_number: 3, + ..Default::default() + }) + .await; + + // Check the votes are claimed in order, and only once + for vote_id in [oldest, middle, newest] { + assert_eq!(db.claim_vote_to_close().await.unwrap().unwrap().vote_id, vote_id); + } + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_claim_vote_to_close_selection() { + // Setup votes covering each selection condition: attempts, close pending, closed, + // ends in, next attempt in and whether the vote is selected or not + let cases = [ + // Selected: finished votes, even without attempts left, as they are retried + // until they close + (0, false, false, "-1 minute", None, true), + (3, false, false, "-1 minute", None, true), + (3, false, false, "-1 minute", Some("-1 minute"), true), + (12, false, false, "-1 minute", None, true), + (100, false, false, "-1 minute", Some("-1 minute"), true), + // Selected: closed votes with side effects pending and attempts left + (0, true, true, "-1 minute", None, true), + (0, true, true, "1 day", None, true), + (11, true, true, "-1 minute", Some("-1 minute"), true), + // Not selected: votes open that have not finished + (0, false, false, "1 minute", None, false), + // Not selected: closed votes with nothing pending, as the ones closed before + // the closing steps were tracked + (0, false, true, "-1 minute", None, false), + // Not selected: votes waiting for a retry + (1, false, false, "-1 minute", Some("1 minute"), false), + (1, true, true, "-1 minute", Some("1 minute"), false), + // Not selected: closed votes with side effects pending without attempts left + (12, true, true, "-1 minute", Some("-1 minute"), false), + (13, true, true, "-1 minute", None, false), + ]; + + // Setup database + let test_db = TestDB::new().await; + let db = test_db.db(); + let mut expected = vec![]; + for (index, case) in cases.into_iter().enumerate() { + let (close_attempts, close_pending, closed, ends_in, close_next_attempt_in, selected) = case; + let issue_number = index as i64 + 1; + test_db + .seed_vote(SeedVote { + close_attempts, + close_pending, + closed, + ends_in, + issue_number, + + close_next_attempt_in, + ..Default::default() + }) + .await; + if selected { + expected.push(issue_number); + } + } + + // Claim all the votes available + let mut claimed = vec![]; + while let Some(vote) = db.claim_vote_to_close().await.unwrap() { + claimed.push(vote.issue_number); + assert!( + claimed.len() <= expected.len(), + "votes claimed twice: {claimed:?}" + ); + } + + // Check only the expected votes were claimed + claimed.sort_unstable(); + assert_eq!(claimed, expected); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_claim_vote_to_close_undecodable_cancelled_vote_last_attempt_is_abandoned() { + // Setup database with a cancelled vote without the required cfg fields, on its last + // attempt to publish its cancellation + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + cfg: json!({"close_on_passing": true}), + close_attempts: CLOSE_MAX_ATTEMPTS - 1, + close_pending: true, + closed: true, + + cancelled_ago: Some("1 day"), + close_next_attempt_in: Some("-1 minute"), + ..Default::default() + }) + .await; + + // Check the error returned tells the cancellation was abandoned + let err = db.claim_vote_to_close().await.unwrap_err(); + assert_eq!(err.to_string(), "cancellation abandoned, no attempts left"); + assert_decode_error(&err, CFG_DECODE_ERROR); + + // Check the vote is deleted and nothing else is claimed + assert_eq!(test_db.read_vote(vote_id).await, None); + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_claim_vote_to_close_undecodable_closed_vote_last_attempt_has_no_attempts_left() { + // Setup database with a closed vote without the required cfg fields, on its last + // attempt to run its closing side effects + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + cfg: json!({"close_on_passing": true}), + close_attempts: CLOSE_MAX_ATTEMPTS - 1, + close_pending: true, + closed: true, + ends_in: "-1 day", + + close_next_attempt_in: Some("-1 minute"), + ..Default::default() + }) + .await; + + // Check the error tells there are no attempts left for the vote identified + let err = db.claim_vote_to_close().await.unwrap_err(); + assert_eq!(err.to_string(), "no attempts left"); + assert_eq!( + err.chain().nth(1).unwrap().to_string(), + format!("error decoding claimed vote {vote_id}") + ); + assert_decode_error(&err, CFG_DECODE_ERROR); + + // Check the last failed attempt was recorded and nothing else is claimed + let (close_attempts, _) = test_db.close_retry_state().await; + assert_eq!(close_attempts, CLOSE_MAX_ATTEMPTS); + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_claim_vote_to_close_undecodable_vote_counts_as_failure() { + // Setup database with a finished vote without the required cfg fields + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + cfg: json!({"close_on_passing": true}), + ends_in: "-1 minute", + ..Default::default() + }) + .await; + + // Check the error identifies the vote and it does not block the next claim + let err = db.claim_vote_to_close().await.unwrap_err(); + assert_eq!(err.to_string(), format!("error decoding claimed vote {vote_id}")); + assert_decode_error(&err, CFG_DECODE_ERROR); + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); + + // Check the failed attempt was recorded + let (close_attempts, delay) = test_db.close_retry_state().await; + assert_eq!(close_attempts, 1); + assert_close_retry_delay(delay, 60.0); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_claim_vote_to_close_undecodable_vote_does_not_block_other_votes() { + // Setup database with an undecodable vote that has been waiting the longest + let test_db = TestDB::new().await; + let db = test_db.db(); + test_db + .seed_vote(SeedVote { + cfg: json!({"close_on_passing": true}), + ends_in: "-2 hours", + ..Default::default() + }) + .await; + let vote_id = test_db + .seed_vote(SeedVote { + ends_in: "-1 hour", + issue_number: 2, + ..Default::default() + }) + .await; + + // Check the valid vote is claimed right after the failure + assert_decode_error(&db.claim_vote_to_close().await.unwrap_err(), CFG_DECODE_ERROR); + assert_eq!(db.claim_vote_to_close().await.unwrap().unwrap().vote_id, vote_id); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_claim_vote_to_close_undecodable_vote_does_not_wait_for_another_connection() { + // Setup database with a single connection and a finished vote without the required + // cfg fields + let test_db = TestDB::with_pool_size(1).await; + let db = test_db.db(); + test_db + .seed_vote(SeedVote { + cfg: json!({"close_on_passing": true}), + ends_in: "-1 minute", + ..Default::default() + }) + .await; + + // Check the claim fails without waiting for a connection it already holds + let outcome = timeout(CLAIM_TIMEOUT, db.claim_vote_to_close()).await; + assert_decode_error( + &outcome.expect("claim should not wait for another connection").unwrap_err(), + CFG_DECODE_ERROR, + ); + + // Check the failed attempt was recorded + let (close_attempts, delay) = test_db.close_retry_state().await; + assert_eq!(close_attempts, 1); + assert_close_retry_delay(delay, 60.0); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_claim_vote_to_close_undecodable_vote_recording_error_keeps_the_decode_error() { + // Setup database with a finished vote without the required cfg fields, where + // recording a failed attempt fails + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + cfg: json!({"close_on_passing": true}), + ends_in: "-1 minute", + ..Default::default() + }) + .await; + test_db.fail_vote_operation("update of close_attempts").await; + + // Check the error returned is the decode one, identifying the vote + let err = db.claim_vote_to_close().await.unwrap_err(); + assert_eq!(err.to_string(), format!("error decoding claimed vote {vote_id}")); + assert_decode_error(&err, CFG_DECODE_ERROR); + + // Check the failed attempt was not recorded, so the vote stays claimed until its + // claim expires + let (close_attempts, delay) = test_db.close_retry_state().await; + assert_eq!(close_attempts, 0); + assert_close_retry_delay( + delay, + f64::from(i32::try_from(CLOSE_CLAIM_DURATION_SECS).unwrap()), + ); + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_vote_cancelled_vote_is_not_closed() { + // Setup database with a vote cancelled after being claimed to close + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + ends_in: "-1 minute", + ..Default::default() + }) + .await; + claim_vote_to_close_until(&db).await; + let vote = db.cancel_vote(REPOFN, ISSUE_NUM, USER1).await.unwrap().unwrap(); + + // Check closing it is reported and leaves the cancellation untouched + let results = setup_test_vote_results(); + assert!(!db.close_vote(vote_id, Some(&results), Some(HEAD_SHA)).await.unwrap()); + assert_eq!(test_db.read_vote(vote_id).await, Some(vote)); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_vote_closed_vote_is_not_closed_again() { + // Setup database with a closed vote + let test_db = TestDB::new().await; + let db = test_db.db(); + let results = setup_test_vote_results(); + let vote_id = test_db.seed_vote(SeedVote::default()).await; + assert!(db.close_vote(vote_id, Some(&results), Some(HEAD_SHA)).await.unwrap()); + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + + // Check closing it again does not change it + assert!(!db.close_vote(vote_id, None, Some("def456")).await.unwrap()); + assert_eq!(db.get_vote(vote_id).await.unwrap(), Some(vote)); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_vote_leaves_side_effects_pending_until_complete() { + // Setup database with a claimed vote that failed more attempts than the limit + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + close_attempts: CLOSE_MAX_ATTEMPTS + 3, + ends_in: "-1 minute", + ..Default::default() + }) + .await; + let claimed_until = claim_vote_to_close_until(&db).await; + + // Close the vote + let results = setup_test_vote_results(); + assert!(db.close_vote(vote_id, Some(&results), Some(HEAD_SHA)).await.unwrap()); + + // Check the vote is closed, with its attempts restarted, its claim kept and side + // effects pending + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert_eq!(vote.close_attempts, 0); + assert!(vote.close_pending); + assert!(vote.closed); + assert_eq!(vote.close_head_sha.as_deref(), Some(HEAD_SHA)); + assert_eq!(vote.close_next_attempt_at, Some(claimed_until)); + assert!(vote.closed_at.is_some()); + assert_eq!(vote.results.as_ref(), Some(&results)); + + // Check the closed vote is not claimed while the attempt is running + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); + + // Check a failure of its side effects starts the backoff over, with attempts left + assert_eq!( + db.record_vote_close_failure(vote_id, claimed_until).await.unwrap(), + VoteCloseFailureOutcome::RetryScheduled + ); + let (close_attempts, delay) = test_db.close_retry_state().await; + assert_eq!(close_attempts, 1); + assert_close_retry_delay(delay, CLOSE_RETRY_BASE_DELAY_SECS); + + // Check the vote can be claimed with its results once the retry is due + test_db.expire_close_claims().await; + let claimed_vote = db.claim_vote_to_close().await.unwrap().unwrap(); + assert_eq!(claimed_vote.vote_id, vote_id); + assert_eq!(claimed_vote.close_head_sha.as_deref(), Some(HEAD_SHA)); + assert_eq!(claimed_vote.results, Some(results)); + + // Check nothing is left to do once the close is complete + db.complete_vote_close(vote_id).await.unwrap(); + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(!vote.close_pending); + assert_eq!(vote.close_next_attempt_at, None); + test_db.expire_close_claims().await; + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_vote_unknown_vote_is_not_closed() { + // Setup database without votes + let test_db = TestDB::new().await; + let db = test_db.db(); + + // Check closing a vote that does not exist is reported + let vote_id = Uuid::parse_str(VOTE_ID).unwrap(); + assert!(!db.close_vote(vote_id, None, None).await.unwrap()); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_vote_without_results_stores_sql_null() { + // Setup database + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db.seed_vote(SeedVote::default()).await; + + // Close the vote without results, as when its comment has been deleted + assert!(db.close_vote(vote_id, None, None).await.unwrap()); + + // Check the results are stored as SQL null and the vote can be read back + assert!(test_db.results_are_sql_null(vote_id).await); + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(vote.closed); + assert!(vote.closed_at.is_some()); + assert_eq!(vote.results, None); + assert_eq!(db.list_votes(REPOFN).await.unwrap(), vec![vote]); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_complete_vote_cancellation_deletes_only_cancelled_votes() { + // Setup database with an open vote, a closed vote and a cancelled vote + let test_db = TestDB::new().await; + let db = test_db.db(); + let open_vote_id = test_db.seed_vote(SeedVote::default()).await; + let closed_vote_id = test_db + .seed_vote(SeedVote { + close_pending: true, + closed: true, + issue_number: 2, + ..Default::default() + }) + .await; + let cancelled_vote_id = test_db + .seed_vote(SeedVote { + issue_number: 3, + ..Default::default() + }) + .await; + db.cancel_vote(REPOFN, 3, USER1).await.unwrap().unwrap(); + + // Complete the cancellation of all of them + for vote_id in [open_vote_id, closed_vote_id, cancelled_vote_id] { + db.complete_vote_cancellation(vote_id).await.unwrap(); + } + + // Check only the cancelled vote was deleted + assert!(test_db.read_vote(open_vote_id).await.is_some()); + assert!(test_db.read_vote(closed_vote_id).await.is_some()); + assert_eq!(test_db.read_vote(cancelled_vote_id).await, None); + assert!(!db.has_vote(REPOFN, 3).await.unwrap()); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_get_open_votes_with_close_on_passing_selection() { + // Setup votes covering each selection condition, created two days ago by default + let enabled = json!({"close_on_passing": true}); + let with_min_wait = + |min_wait: Value| json!({"close_on_passing": true, "close_on_passing_min_wait": min_wait}); + let cases = [ + // Selected: no min wait, null min wait and min wait elapsed + (enabled.clone(), false, true), + (with_min_wait(Value::Null), false, true), + (with_min_wait(json!("1 day")), false, true), + // Not selected: closed, not enabled and min wait not elapsed + (enabled, true, false), + (json!({"close_on_passing": false}), false, false), + (json!({}), false, false), + (json!({"close_on_passing": null}), false, false), + (json!({"close_on_passing": "banana"}), false, false), + (with_min_wait(json!("1 week")), false, false), + // Bad stored min wait values must not close the vote or fail the query + (with_min_wait(json!("")), false, false), + (with_min_wait(json!("-1 day")), false, false), + (with_min_wait(json!("0 days")), false, false), + (with_min_wait(json!("1 wek")), false, false), + (with_min_wait(json!("100000000 years")), false, false), + (with_min_wait(json!("infinity")), false, false), + ]; + + // Setup database + let test_db = TestDB::new().await; + let mut expected = vec![]; + for (index, (cfg, closed, selected)) in cases.into_iter().enumerate() { + let issue_number = index as i64 + 1; + test_db + .seed_vote(SeedVote { + cfg: merge_cfg(&cfg), + closed, + issue_number, + ..Default::default() + }) + .await; + if selected { + expected.push(issue_number); + } + } + + // Check only the expected votes are selected + let votes = test_db.db().get_open_votes_with_close_on_passing().await.unwrap(); + let mut selected: Vec = votes.iter().map(|vote| vote.issue_number).collect(); + selected.sort_unstable(); + assert_eq!(selected, expected); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_get_pending_status_checks_selection() { + // Setup votes covering each selection condition: check period, closed, created ago, + // ends in, checked ago and whether the vote is selected or not + let cases = [ + // Selected + (Some("1 day"), false, "2 days", "10 days", None, true), + (Some("24 hours"), false, "2 days", "10 days", None, true), + (Some("1 day"), false, "25 hours", "10 days", None, true), + (Some("1 day"), false, "2 days", "10 days", Some("25 hours"), true), + (Some("1 day"), false, "2 days", "61 minutes", None, true), + // Not selected + (None, false, "2 days", "10 days", None, false), + (Some("1 day"), false, "2 days", "10 days", Some("23 hours"), false), + (Some("1 day"), true, "2 days", "10 days", None, false), + (Some("1 day"), false, "23 hours", "10 days", None, false), + (Some("1 day"), false, "2 days", "59 minutes", None, false), + (Some("1 week"), false, "2 days", "10 days", None, false), + (Some("23 hours"), false, "2 days", "10 days", None, false), + // Bad stored values must be skipped and must not fail the query + (Some(""), false, "2 days", "10 days", None, false), + (Some("-1 day"), false, "2 days", "10 days", None, false), + (Some("100000000 years"), false, "2 days", "10 days", None, false), + (Some("banana"), false, "2 days", "10 days", None, false), + (Some("banana"), false, "2 days", "59 minutes", None, false), + (Some("infinity"), false, "2 days", "10 days", None, false), + (Some("weekly"), false, "2 days", "10 days", None, false), + ]; + + // Setup database, including a vote without the status check configured + let test_db = TestDB::new().await; + let mut expected = vec![]; + for (index, case) in cases.into_iter().enumerate() { + let (check_period, closed, created_ago, ends_in, checked_ago, selected) = case; + let issue_number = index as i64 + 1; + test_db + .seed_vote(SeedVote { + cfg: merge_cfg(&json!({"periodic_status_check": check_period})), + closed, + created_ago, + ends_in, + issue_number, + + checked_ago, + ..Default::default() + }) + .await; + if selected { + expected.push(issue_number); + } + } + test_db + .seed_vote(SeedVote { + issue_number: 100, + ..Default::default() + }) + .await; + + // Check only the expected votes are selected + let inputs = test_db.db().get_pending_status_checks().await.unwrap(); + assert!(inputs.iter().all(|input| input.repository_full_name == REPOFN)); + let mut selected: Vec = inputs.iter().map(|input| input.issue_number).collect(); + selected.sort_unstable(); + assert_eq!(selected, expected); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_get_vote_unknown_id_returns_none() { + // Setup database + let test_db = TestDB::new().await; + + // Check an unknown vote is not found + assert_eq!( + test_db.db().get_vote(Uuid::parse_str(VOTE_ID).unwrap()).await.unwrap(), + None + ); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_has_vote_cancelled_vote_counts_until_its_check_run_is_done() { + // Setup database with a pull request vote whose cancellation is pending, as well as + // a closed vote with its closing check run created in another pull request + let test_db = TestDB::new().await; + let db = test_db.db(); + let cancelled_vote_id = test_db + .seed_vote(SeedVote { + is_pull_request: true, + ..Default::default() + }) + .await; + db.cancel_vote(REPOFN, ISSUE_NUM, USER1).await.unwrap().unwrap(); + let closed_vote_id = test_db + .seed_vote(SeedVote { + close_pending: true, + closed: true, + is_pull_request: true, + issue_number: 2, + ..Default::default() + }) + .await; + db.record_vote_close_step(closed_vote_id, VoteCloseStep::CheckRun).await.unwrap(); + + // Check the cancelled vote counts while its check run is pending, even if the other + // steps are done + db.record_vote_close_step(cancelled_vote_id, VoteCloseStep::Comment).await.unwrap(); + db.record_vote_close_step(cancelled_vote_id, VoteCloseStep::Labels).await.unwrap(); + assert!(db.has_vote(REPOFN, ISSUE_NUM).await.unwrap()); + + // Check the cancelled vote stops counting once its check run is done, while the + // closed vote keeps counting + db.record_vote_close_step(cancelled_vote_id, VoteCloseStep::CheckRun).await.unwrap(); + assert!(!db.has_vote(REPOFN, ISSUE_NUM).await.unwrap()); + assert!(db.has_vote(REPOFN, 2).await.unwrap()); + + // Check the cancelled vote is still stored, as its cancellation is not completed + assert!(test_db.read_vote(cancelled_vote_id).await.is_some()); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_has_vote_with_comment_selection() { + // Setup database without votes + let test_db = TestDB::new().await; + let db = test_db.db(); + + // Check no vote references the comment + assert!(!db.has_vote_with_comment(COMMENT_ID).await.unwrap()); + + // Check an open vote references its comment, and only that one + let vote_id = test_db.seed_vote(SeedVote::default()).await; + assert!(db.has_vote_with_comment(COMMENT_ID).await.unwrap()); + assert!(!db.has_vote_with_comment(COMMENT_ID2).await.unwrap()); + + // Check a closed vote still references its comment + assert!(db.close_vote(vote_id, None, None).await.unwrap()); + assert!(db.has_vote_with_comment(COMMENT_ID).await.unwrap()); + + // Check a vote whose cancellation is pending still references its comment + test_db + .seed_vote(SeedVote { + issue_number: 2, + vote_comment_id: COMMENT_ID2, + ..Default::default() + }) + .await; + db.cancel_vote(REPOFN, 2, USER1).await.unwrap().unwrap(); + assert!(db.has_vote_with_comment(COMMENT_ID2).await.unwrap()); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_is_close_state_step_pending_other_steps_return_errors() { + // Setup database + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db.seed_vote(SeedVote::default()).await; + + // Check the steps that do not publish the state of the vote cannot be checked + for step in [VoteCloseStep::Announcement, VoteCloseStep::Comment] { + let err = db.is_close_state_step_pending(vote_id, step).await.unwrap_err(); + assert_eq!( + err.to_string(), + "only the check run and labels steps publish the state of a vote" + ); + } +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_is_close_state_step_pending_selection() { + // Setup database with a closed vote and an older one in the same issue + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + closed: true, + created_ago: "2 days", + ..Default::default() + }) + .await; + let older_vote_id = test_db + .seed_vote(SeedVote { + closed: true, + created_ago: "3 days", + ..Default::default() + }) + .await; + + // Check the steps are pending in the newest vote only + assert_eq!(state_steps_pending(&db, vote_id).await, [true, true]); + assert_eq!(state_steps_pending(&db, older_vote_id).await, [false, false]); + + // Check newer votes on other issues and repositories do not count + test_db + .seed_vote(SeedVote { + created_ago: "1 day", + issue_number: 2, + ..Default::default() + }) + .await; + test_db + .seed_vote(SeedVote { + created_ago: "1 day", + repository_full_name: OTHER_REPOFN, + ..Default::default() + }) + .await; + assert_eq!(state_steps_pending(&db, vote_id).await, [true, true]); + + // Check a step recorded as done is not pending anymore, unlike the other one + db.record_vote_close_step(vote_id, VoteCloseStep::CheckRun).await.unwrap(); + assert_eq!(state_steps_pending(&db, vote_id).await, [false, true]); + + // Check an open newer vote counts, and that it keeps counting once cancelled, while + // its cancellation is pending publication + let newer_vote_id = test_db + .seed_vote(SeedVote { + created_ago: "1 day", + ..Default::default() + }) + .await; + assert_eq!(state_steps_pending(&db, vote_id).await, [false, false]); + db.cancel_vote(REPOFN, ISSUE_NUM, USER1).await.unwrap().unwrap(); + assert_eq!(state_steps_pending(&db, vote_id).await, [false, false]); + + // Check a newer vote is not seen once deleted, as storing it is what marks the steps + // of the older votes as done + db.complete_vote_cancellation(newer_vote_id).await.unwrap(); + assert_eq!(state_steps_pending(&db, vote_id).await, [false, true]); + + // Check a closed newer vote counts + test_db + .seed_vote(SeedVote { + closed: true, + created_ago: "1 day", + ..Default::default() + }) + .await; + assert_eq!(state_steps_pending(&db, vote_id).await, [false, false]); + + // Check an unknown vote has no steps pending + let unknown_vote_id = Uuid::parse_str(VOTE_ID).unwrap(); + assert_eq!(state_steps_pending(&db, unknown_vote_id).await, [false, false]); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_reads_are_scoped_by_repository_issue_and_state() { + // Setup database + let test_db = TestDB::new().await; + let db = test_db.db(); + let closed_vote_id = test_db + .seed_vote(SeedVote { + closed: true, + created_ago: "3 days", + ..Default::default() + }) + .await; + let open_vote_id = test_db + .seed_vote(SeedVote { + created_ago: "1 day", + ..Default::default() + }) + .await; + let closed_only_vote_id = test_db + .seed_vote(SeedVote { + closed: true, + created_ago: "2 days", + issue_number: 2, + ..Default::default() + }) + .await; + let other_repo_vote_id = test_db + .seed_vote(SeedVote { + repository_full_name: OTHER_REPOFN, + ..Default::default() + }) + .await; + + // Check an issue with only a closed vote has a vote but not an open one + assert!(db.has_vote(REPOFN, 2).await.unwrap()); + assert!(!db.has_vote_open(REPOFN, 2).await.unwrap()); + assert_eq!(db.get_open_vote(REPOFN, 2).await.unwrap(), None); + + // Check an issue without votes has none + assert!(!db.has_vote(REPOFN, 3).await.unwrap()); + assert!(!db.has_vote_open(REPOFN, 3).await.unwrap()); + + // Check the open vote found is the one in the repository requested + assert!(db.has_vote_open(REPOFN, ISSUE_NUM).await.unwrap()); + let open_vote = db.get_open_vote(REPOFN, ISSUE_NUM).await.unwrap().unwrap(); + assert_eq!(open_vote.vote_id, open_vote_id); + + // Check votes are limited to the repository requested, newest first + let votes = db.list_votes(REPOFN).await.unwrap(); + let vote_ids: Vec = votes.iter().map(|vote| vote.vote_id).collect(); + assert_eq!(vote_ids, vec![open_vote_id, closed_only_vote_id, closed_vote_id]); + + // Check closed votes cannot be cancelled + assert_eq!(db.cancel_vote(REPOFN, 2, USER1).await.unwrap(), None); + assert!(db.get_vote(closed_only_vote_id).await.unwrap().is_some()); + + // Check cancelling affects only the open vote in the repository requested + let cancelled_vote = db.cancel_vote(REPOFN, ISSUE_NUM, USER1).await.unwrap().unwrap(); + assert_eq!(cancelled_vote.vote_id, open_vote_id); + assert_eq!(db.get_vote(open_vote_id).await.unwrap(), None); + assert!(db.get_vote(closed_vote_id).await.unwrap().is_some()); + let other_repo_vote = db.get_vote(other_repo_vote_id).await.unwrap().unwrap(); + assert!(!other_repo_vote.closed); + assert_eq!(other_repo_vote.cancelled_at, None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_reads_of_pending_cancellations() { + // Setup database with a closed vote and a newer vote whose cancellation is pending, + // each one with its own comment + let test_db = TestDB::new().await; + let db = test_db.db(); + let closed_vote_id = test_db + .seed_vote(SeedVote { + closed: true, + created_ago: "3 days", + vote_comment_id: COMMENT_ID2, + ..Default::default() + }) + .await; + let cancelled_vote_id = test_db + .seed_vote(SeedVote { + cfg: merge_cfg(&json!({ + "close_on_passing": true, + "periodic_status_check": "1 day" + })), + ..Default::default() + }) + .await; + db.cancel_vote(REPOFN, ISSUE_NUM, USER1).await.unwrap().unwrap(); + + // Check the audit reads leave the cancelled vote out + assert_eq!(db.get_vote(cancelled_vote_id).await.unwrap(), None); + let votes = db.list_votes(REPOFN).await.unwrap(); + let vote_ids: Vec = votes.iter().map(|vote| vote.vote_id).collect(); + assert_eq!(vote_ids, vec![closed_vote_id]); + + // Check the cancelled vote is not open, so a new vote can be created + assert!(!db.has_vote_open(REPOFN, ISSUE_NUM).await.unwrap()); + assert_eq!(db.get_open_vote(REPOFN, ISSUE_NUM).await.unwrap(), None); + assert_eq!(db.get_open_votes_with_close_on_passing().await.unwrap(), vec![]); + assert_eq!(db.get_pending_status_checks().await.unwrap(), vec![]); + + // Check the cancelled vote still exists for the other reads. The closed vote makes + // the issue have a vote anyway, so how a pending cancellation counts for that is + // checked by db_contracts_has_vote_cancelled_vote_counts_until_its_check_run_is_done + assert!(db.has_vote_with_comment(COMMENT_ID).await.unwrap()); + assert_eq!(state_steps_pending(&db, closed_vote_id).await, [false, false]); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_record_vote_close_failure_backs_off_until_no_attempts_left() { + // Setup database with a closed vote claimed to run its side effects pending + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + close_pending: true, + closed: true, + ..Default::default() + }) + .await; + let mut claimed_until = claim_vote_to_close_until(&db).await; + + // Fail all the attempts but the last one, checking the delay used for the next one + let delays = [ + 60.0, 120.0, 240.0, 480.0, 960.0, 1920.0, 3840.0, 7680.0, 15360.0, 21600.0, 21600.0, + ]; + for (expected_attempts, expected_delay) in (1..).zip(delays) { + assert_eq!( + db.record_vote_close_failure(vote_id, claimed_until).await.unwrap(), + VoteCloseFailureOutcome::RetryScheduled + ); + let (close_attempts, delay) = test_db.close_retry_state().await; + assert_eq!(close_attempts, expected_attempts); + assert_close_retry_delay(delay, expected_delay); + + // The vote is not selected until the delay is over + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); + test_db.expire_close_claims().await; + claimed_until = claim_vote_to_close_until(&db).await; + } + + // Check no more attempts are made after the last one, keeping the closed vote + assert_eq!( + db.record_vote_close_failure(vote_id, claimed_until).await.unwrap(), + VoteCloseFailureOutcome::NoAttemptsLeft + ); + test_db.expire_close_claims().await; + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert_eq!(vote.close_attempts, CLOSE_MAX_ATTEMPTS); + assert!(vote.close_pending); + assert!(vote.closed); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_record_vote_close_failure_cancelled_vote_last_attempt_abandons_the_cancellation() { + // Setup database with a cancelled pull request vote two attempts away from the + // limit, whose check run step is pending, claimed to publish its cancellation + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + close_attempts: CLOSE_MAX_ATTEMPTS - 2, + close_pending: true, + closed: true, + is_pull_request: true, + + cancelled_ago: Some("1 day"), + close_next_attempt_in: Some("-1 minute"), + ..Default::default() + }) + .await; + let claimed_until = claim_vote_to_close_until(&db).await; + + // Check the attempt before the last one is rescheduled, keeping the vote + assert_eq!( + db.record_vote_close_failure(vote_id, claimed_until).await.unwrap(), + VoteCloseFailureOutcome::RetryScheduled + ); + let vote = test_db.read_vote(vote_id).await.unwrap(); + assert_eq!(vote.close_attempts, CLOSE_MAX_ATTEMPTS - 1); + assert!(db.has_vote(REPOFN, ISSUE_NUM).await.unwrap()); + + // Fail the last attempt + test_db.expire_close_claims().await; + let claimed_until = claim_vote_to_close_until(&db).await; + assert_eq!( + db.record_vote_close_failure(vote_id, claimed_until).await.unwrap(), + VoteCloseFailureOutcome::CancellationAbandoned + ); + + // Check the vote is deleted, so the pull request has no vote and nothing is retried + assert_eq!(test_db.read_vote(vote_id).await, None); + assert!(!db.has_vote(REPOFN, ISSUE_NUM).await.unwrap()); + assert!(!db.has_vote_open(REPOFN, ISSUE_NUM).await.unwrap()); + test_db.expire_close_claims().await; + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_record_vote_close_failure_cancelled_vote_last_attempt_with_expired_claim_is_not_recorded() + { + // Setup database with a cancelled vote on its last attempt, claimed twice as the + // first claim expired + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + close_attempts: CLOSE_MAX_ATTEMPTS - 1, + close_pending: true, + closed: true, + + cancelled_ago: Some("1 day"), + close_next_attempt_in: Some("-1 minute"), + ..Default::default() + }) + .await; + let expired_claimed_until = claim_vote_to_close_until(&db).await; + test_db.expire_close_claims().await; + let claimed_until = claim_vote_to_close_until(&db).await; + + // Check the failure of the worker with the expired claim does not delete the vote + assert_eq!( + db.record_vote_close_failure(vote_id, expired_claimed_until).await.unwrap(), + VoteCloseFailureOutcome::ClaimLost + ); + let vote = test_db.read_vote(vote_id).await.unwrap(); + assert_eq!(vote.close_attempts, CLOSE_MAX_ATTEMPTS - 1); + + // Check the worker holding the claim abandons the cancellation + assert_eq!( + db.record_vote_close_failure(vote_id, claimed_until).await.unwrap(), + VoteCloseFailureOutcome::CancellationAbandoned + ); + assert_eq!(test_db.read_vote(vote_id).await, None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_record_vote_close_failure_deleted_vote_is_not_recorded() { + // Setup database + let test_db = TestDB::new().await; + + // Check nothing is recorded for a vote that does not exist anymore + let vote_id = Uuid::parse_str(VOTE_ID).unwrap(); + assert_eq!( + test_db.db().record_vote_close_failure(vote_id, OffsetDateTime::now_utc()).await.unwrap(), + VoteCloseFailureOutcome::ClaimLost + ); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_record_vote_close_failure_expired_claim_claimed_again_is_not_recorded() { + // Setup database with a finished vote claimed twice, as the first claim expired + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + ends_in: "-1 minute", + ..Default::default() + }) + .await; + let expired_claimed_until = claim_vote_to_close_until(&db).await; + test_db.expire_close_claims().await; + let claimed_until = claim_vote_to_close_until(&db).await; + + // Check the failure of the worker with the expired claim is not recorded + assert_eq!( + db.record_vote_close_failure(vote_id, expired_claimed_until).await.unwrap(), + VoteCloseFailureOutcome::ClaimLost + ); + + // Check the vote is still claimed by the worker that claimed it again + let (close_attempts, delay) = test_db.close_retry_state().await; + assert_eq!(close_attempts, 0); + assert_close_retry_delay( + delay, + f64::from(i32::try_from(CLOSE_CLAIM_DURATION_SECS).unwrap()), + ); + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); + assert_eq!( + db.record_vote_close_failure(vote_id, claimed_until).await.unwrap(), + VoteCloseFailureOutcome::RetryScheduled + ); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_record_vote_close_failure_open_vote_delay_stays_capped_with_many_attempts() { + // Setup database with a finished vote that failed far more attempts than the ones + // needed to overflow the power of the delay without a cap + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + close_attempts: 100_000, + ends_in: "-1 minute", + ..Default::default() + }) + .await; + let claimed_until = claim_vote_to_close_until(&db).await; + + // Check the failure is rescheduled with the maximum delay + assert_eq!( + db.record_vote_close_failure(vote_id, claimed_until).await.unwrap(), + VoteCloseFailureOutcome::RetryScheduled + ); + let (close_attempts, delay) = test_db.close_retry_state().await; + assert_eq!(close_attempts, 100_001); + assert_close_retry_delay(delay, CLOSE_RETRY_MAX_DELAY_SECS); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_record_vote_close_failure_open_vote_keeps_retrying_after_the_limit() { + // Setup database with a finished vote that failed all the attempts of the limit + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + close_attempts: CLOSE_MAX_ATTEMPTS, + ends_in: "-1 minute", + ..Default::default() + }) + .await; + let claimed_until = claim_vote_to_close_until(&db).await; + + // Check the failure is rescheduled with the maximum delay, keeping the vote open + assert_eq!( + db.record_vote_close_failure(vote_id, claimed_until).await.unwrap(), + VoteCloseFailureOutcome::RetryScheduled + ); + let (close_attempts, delay) = test_db.close_retry_state().await; + assert_eq!(close_attempts, CLOSE_MAX_ATTEMPTS + 1); + assert_close_retry_delay(delay, CLOSE_RETRY_MAX_DELAY_SECS); + assert!(db.has_vote_open(REPOFN, ISSUE_NUM).await.unwrap()); + + // Check the vote is claimed again once the delay is over + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); + test_db.expire_close_claims().await; + let vote = db.claim_vote_to_close().await.unwrap().unwrap(); + assert_eq!(vote.vote_id, vote_id); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_record_vote_close_failure_vote_cancelled_while_claimed_is_not_recorded() { + // Setup database with a finished vote claimed to close it, and cancelled after that + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + ends_in: "-1 minute", + ..Default::default() + }) + .await; + let closer_claimed_until = claim_vote_to_close_until(&db).await; + let cancelled_vote = db.cancel_vote(REPOFN, ISSUE_NUM, USER1).await.unwrap().unwrap(); + let cancellation_claimed_until = cancelled_vote.close_next_attempt_at.unwrap(); + assert_ne!(cancellation_claimed_until, closer_claimed_until); + + // Check the failure of the votes closer is not recorded + assert_eq!( + db.record_vote_close_failure(vote_id, closer_claimed_until).await.unwrap(), + VoteCloseFailureOutcome::ClaimLost + ); + + // Check the publication of the cancellation is still claimed by its publisher + let (close_attempts, delay) = test_db.close_retry_state().await; + assert_eq!(close_attempts, 0); + assert_close_retry_delay( + delay, + f64::from(i32::try_from(CLOSE_CLAIM_DURATION_SECS).unwrap()), + ); + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); + assert_eq!( + db.record_vote_close_failure(vote_id, cancellation_claimed_until).await.unwrap(), + VoteCloseFailureOutcome::RetryScheduled + ); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_record_vote_close_step_keeps_the_first_timestamp() { + // Setup database with a vote with all its steps recorded + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db.seed_vote(SeedVote::default()).await; + let steps = [ + VoteCloseStep::Announcement, + VoteCloseStep::CheckRun, + VoteCloseStep::Comment, + VoteCloseStep::Labels, + ]; + for step in steps { + db.record_vote_close_step(vote_id, step).await.unwrap(); + } + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + + // Record the steps again + for step in steps { + db.record_vote_close_step(vote_id, step).await.unwrap(); + } + + // Check the timestamps did not change + let vote_recorded_again = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(vote.close_announcement_posted_at.is_some()); + assert_eq!( + vote_recorded_again.close_announcement_posted_at, + vote.close_announcement_posted_at + ); + assert!(vote.close_check_run_created_at.is_some()); + assert_eq!( + vote_recorded_again.close_check_run_created_at, + vote.close_check_run_created_at + ); + assert!(vote.close_comment_posted_at.is_some()); + assert_eq!( + vote_recorded_again.close_comment_posted_at, + vote.close_comment_posted_at + ); + assert!(vote.close_labels_updated_at.is_some()); + assert_eq!( + vote_recorded_again.close_labels_updated_at, + vote.close_labels_updated_at + ); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_record_vote_close_step_sets_the_step_timestamp() { + // Setup database + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db.seed_vote(SeedVote::default()).await; + let other_vote_id = test_db + .seed_vote(SeedVote { + issue_number: 2, + ..Default::default() + }) + .await; + + // Check recording the comment step sets only its timestamp. The steps below are + // recorded one by one, each check depending on the ones recorded before it + let is_set = |timestamp: Option| timestamp.is_some(); + db.record_vote_close_step(vote_id, VoteCloseStep::Comment).await.unwrap(); + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(is_set(vote.close_comment_posted_at)); + assert!(!is_set(vote.close_announcement_posted_at)); + assert!(!is_set(vote.close_check_run_created_at)); + assert!(!is_set(vote.close_labels_updated_at)); + + // Check recording the announcement step sets its timestamp, leaving the later ones + db.record_vote_close_step(vote_id, VoteCloseStep::Announcement).await.unwrap(); + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(is_set(vote.close_announcement_posted_at)); + assert!(!is_set(vote.close_check_run_created_at)); + assert!(!is_set(vote.close_labels_updated_at)); + + // Check recording the check run step sets its timestamp, leaving the labels one + db.record_vote_close_step(vote_id, VoteCloseStep::CheckRun).await.unwrap(); + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(is_set(vote.close_check_run_created_at)); + assert!(!is_set(vote.close_labels_updated_at)); + + // Check recording the labels step sets its timestamp + db.record_vote_close_step(vote_id, VoteCloseStep::Labels).await.unwrap(); + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(is_set(vote.close_labels_updated_at)); + + // Check other votes were not updated + let other_vote = db.get_vote(other_vote_id).await.unwrap().unwrap(); + assert!(!is_set(other_vote.close_announcement_posted_at)); + assert!(!is_set(other_vote.close_check_run_created_at)); + assert!(!is_set(other_vote.close_comment_posted_at)); + assert!(!is_set(other_vote.close_labels_updated_at)); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_selection_queries_read_the_keys_of_stored_profiles() { + // Setup database and inputs + let test_db = TestDB::new().await; + let db = test_db.db(); + let cfg = CfgProfile { + duration: Duration::from_hours(240), + + close_on_passing: Some(true), + close_on_passing_min_wait: Some("1 day".to_string()), + periodic_status_check: Some("1 day".to_string()), + ..Default::default() + }; + let other_issue_input = CreateVoteInput { + issue_number: 2, + ..setup_test_create_vote_input() + }; + + // Store a vote with the features enabled, and another one with them disabled + let input = setup_test_create_vote_input(); + let vote_id = db.store_vote(COMMENT_ID, &input, &cfg).await.unwrap(); + let default_cfg = CfgProfile { + duration: Duration::from_hours(240), + ..Default::default() + }; + db.store_vote(COMMENT_ID2, &other_issue_input, &default_cfg).await.unwrap(); + + // Check no vote is selected until the periods have elapsed + assert_eq!(db.get_open_votes_with_close_on_passing().await.unwrap(), vec![]); + assert_eq!(db.get_pending_status_checks().await.unwrap(), vec![]); + + // Backdate the votes past the configured periods + test_db.backdate_votes("2 days").await; + + // Check only the vote with the features enabled is selected + let votes = db.get_open_votes_with_close_on_passing().await.unwrap(); + assert_eq!(votes.len(), 1); + assert_eq!(votes[0].vote_id, vote_id); + let inputs = db.get_pending_status_checks().await.unwrap(); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].issue_number, ISSUE_NUM); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_store_vote_allows_a_new_vote_after_closing() { + // Setup database with a closed vote + let test_db = TestDB::new().await; + let db = test_db.db(); + test_db + .seed_vote(SeedVote { + closed: true, + ..Default::default() + }) + .await; + + // Store a new vote in the same issue + let input = setup_test_create_vote_input(); + let vote_id = db.store_vote(COMMENT_ID, &input, &CfgProfile::default()).await.unwrap(); + + // Check it is the open vote + let vote = db.get_open_vote(REPOFN, ISSUE_NUM).await.unwrap().unwrap(); + assert_eq!(vote.vote_id, vote_id); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_store_vote_allows_open_votes_in_other_issues() { + // Setup database and inputs + let test_db = TestDB::new().await; + let db = test_db.db(); + let cfg = CfgProfile::default(); + let input = setup_test_create_vote_input(); + let other_issue_input = CreateVoteInput { + issue_number: ISSUE_NUM + 1, + ..setup_test_create_vote_input() + }; + let other_repo_input = CreateVoteInput { + repository_full_name: OTHER_REPOFN.to_string(), + ..setup_test_create_vote_input() + }; + + // Check all the votes are stored + db.store_vote(COMMENT_ID, &input, &cfg).await.unwrap(); + db.store_vote(COMMENT_ID, &other_issue_input, &cfg).await.unwrap(); + db.store_vote(COMMENT_ID, &other_repo_input, &cfg).await.unwrap(); + assert!(db.has_vote_open(REPOFN, ISSUE_NUM).await.unwrap()); + assert!(db.has_vote_open(REPOFN, ISSUE_NUM + 1).await.unwrap()); + assert!(db.has_vote_open(OTHER_REPOFN, ISSUE_NUM).await.unwrap()); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_store_vote_concurrent_calls_store_one_vote() { + // Setup database and input + let test_db = TestDB::new().await; + let db = test_db.db(); + let cfg = CfgProfile::default(); + let input = setup_test_create_vote_input(); + + // Store the same vote concurrently + let calls = (0..TEST_DB_POOL_SIZE).map(|_| db.store_vote(COMMENT_ID, &input, &cfg)); + let outcomes = future::join_all(calls).await; + + // Check one call stored the vote and all the others were told it is open + let (stored, rejected): (Vec<_>, Vec<_>) = outcomes.into_iter().partition(Result::is_ok); + assert_eq!(stored.len(), 1); + assert!( + rejected.iter().all(|outcome| { + outcome.as_ref().unwrap_err().downcast_ref::().is_some() + }) + ); + assert_eq!(db.list_votes(REPOFN).await.unwrap().len(), 1); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_store_vote_rejected_does_not_supersede_older_votes() { + // Setup database with a closed vote with its closing steps pending and an open vote + let test_db = TestDB::new().await; + let db = test_db.db(); + let closed_vote_id = test_db + .seed_vote(SeedVote { + close_pending: true, + closed: true, + created_ago: "3 days", + ..Default::default() + }) + .await; + test_db.seed_vote(SeedVote::default()).await; + let closed_vote = test_db.read_vote(closed_vote_id).await; + + // Try storing another vote in the same issue + let input = setup_test_create_vote_input(); + let err = db.store_vote(COMMENT_ID2, &input, &CfgProfile::default()).await.unwrap_err(); + + // Check the closed vote did not change + assert!(err.downcast_ref::().is_some()); + assert_eq!(test_db.read_vote(closed_vote_id).await, closed_vote); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_store_vote_rejects_second_open_vote() { + // Setup database with an open vote + let test_db = TestDB::new().await; + let db = test_db.db(); + let cfg = CfgProfile::default(); + let input = setup_test_create_vote_input(); + let vote_id = db.store_vote(COMMENT_ID, &input, &cfg).await.unwrap(); + + // Try storing another vote in the same issue + let err = db.store_vote(COMMENT_ID2, &input, &cfg).await.unwrap_err(); + + // Check the error can be told apart and the open vote did not change + assert!(err.downcast_ref::().is_some()); + let votes = db.list_votes(REPOFN).await.unwrap(); + assert_eq!(votes.len(), 1); + assert_eq!(votes[0].vote_comment_id, COMMENT_ID); + assert_eq!(votes[0].vote_id, vote_id); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_store_vote_round_trips_every_field() { + // Setup database and input + let test_db = TestDB::new().await; + let db = test_db.db(); + let input = setup_test_create_vote_input(); + let cfg = setup_test_full_cfg_profile(); + + // Store the vote + let vote_id = db.store_vote(COMMENT_ID, &input, &cfg).await.unwrap(); + + // Check every required field is read back from the column it was written to + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert_eq!(vote.cfg, cfg); + assert_eq!(vote.close_attempts, 0); + assert!(!vote.close_pending); + assert!(!vote.closed); + assert_eq!(vote.ends_at - vote.created_at, TimeDuration::hours(1)); + assert_eq!(vote.created_by, USER); + assert_eq!(vote.installation_id, INST_ID as i64); + assert!(vote.is_pull_request); + assert_eq!(vote.issue_id, ISSUE_ID); + assert_eq!(vote.issue_number, ISSUE_NUM); + assert_eq!(vote.repository_full_name, REPOFN); + assert_eq!(vote.vote_comment_id, COMMENT_ID); + assert_eq!(vote.vote_id, vote_id); + + // Check every optional field is read back from the column it was written to + assert_eq!(vote.cancelled_at, None); + assert_eq!(vote.cancelled_by, None); + assert_eq!(vote.checked_at, None); + assert_eq!(vote.close_announcement_posted_at, None); + assert_eq!(vote.close_check_run_created_at, None); + assert_eq!(vote.close_comment_posted_at, None); + assert_eq!(vote.close_head_sha, None); + assert_eq!(vote.close_labels_updated_at, None); + assert_eq!(vote.close_next_attempt_at, None); + assert_eq!(vote.closed_at, None); + assert_eq!(vote.issue_title.as_deref(), Some(TITLE)); + assert_eq!(vote.organization.as_deref(), Some(ORG)); + assert_eq!(vote.results, None); + + // Check the other reads return the same vote + let open_vote = db.get_open_vote(REPOFN, ISSUE_NUM).await.unwrap(); + assert_eq!(open_vote.as_ref(), Some(&vote)); + assert_eq!(db.list_votes(REPOFN).await.unwrap(), vec![vote]); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_store_vote_round_trips_pass_threshold_bases() { + // Setup cases (pass threshold base) + let bases = [ + None, + Some(PassThresholdBase::AllowedVoters), + Some(PassThresholdBase::VotesCast { + exclude_abstentions: None, + }), + Some(PassThresholdBase::VotesCast { + exclude_abstentions: Some(false), + }), + Some(PassThresholdBase::VotesCast { + exclude_abstentions: Some(true), + }), + ]; + + // Setup database + let test_db = TestDB::new().await; + let db = test_db.db(); + + // Check each base round trips + for (index, pass_threshold_base) in bases.into_iter().enumerate() { + // Store a vote using the base, each one in its own issue + let input = CreateVoteInput { + issue_number: index as i64 + 1, + ..setup_test_create_vote_input() + }; + let cfg = CfgProfile { + pass_threshold_base, + ..setup_test_full_cfg_profile() + }; + let vote_id = db.store_vote(COMMENT_ID, &input, &cfg).await.unwrap(); + + // Check the configuration is read back unchanged + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert_eq!(vote.cfg, cfg); + } +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_store_vote_superseded_steps_stay_done_after_the_new_vote_is_cancelled() { + // Setup database with a closed vote with its closing steps pending + let test_db = TestDB::new().await; + let db = test_db.db(); + let closed_vote_id = test_db + .seed_vote(SeedVote { + close_pending: true, + closed: true, + ..Default::default() + }) + .await; + + // Store a new vote in the same issue, and cancel and delete it + let input = setup_test_create_vote_input(); + let new_vote_id = db.store_vote(COMMENT_ID2, &input, &CfgProfile::default()).await.unwrap(); + db.cancel_vote(REPOFN, ISSUE_NUM, USER1).await.unwrap().unwrap(); + db.complete_vote_cancellation(new_vote_id).await.unwrap(); + + // Check the closed vote still has its check run and labels steps done, even though + // the vote that superseded them does not exist anymore + assert_eq!(test_db.read_vote(new_vote_id).await, None); + let closed_vote = db.get_vote(closed_vote_id).await.unwrap().unwrap(); + assert!(closed_vote.close_pending); + assert!(closed_vote.close_check_run_created_at.is_some()); + assert!(closed_vote.close_labels_updated_at.is_some()); + assert_eq!(state_steps_pending(&db, closed_vote_id).await, [false, false]); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_store_vote_supersedes_state_steps_of_older_votes() { + // Setup database with votes in the same issue: one with its closing steps pending, + // one with its check run done already and one closed before steps were tracked + let test_db = TestDB::new().await; + let db = test_db.db(); + let pending_vote_id = test_db + .seed_vote(SeedVote { + close_pending: true, + closed: true, + created_ago: "3 days", + ..Default::default() + }) + .await; + let check_run_done_vote_id = test_db + .seed_vote(SeedVote { + close_pending: true, + closed: true, + ..Default::default() + }) + .await; + db.record_vote_close_step(check_run_done_vote_id, VoteCloseStep::CheckRun).await.unwrap(); + let check_run_done_vote = db.get_vote(check_run_done_vote_id).await.unwrap().unwrap(); + let untracked_vote_id = test_db + .seed_vote(SeedVote { + closed: true, + created_ago: "4 days", + ..Default::default() + }) + .await; + + // Setup votes with their closing steps pending in another issue and repository + let other_issue_vote_id = test_db + .seed_vote(SeedVote { + close_pending: true, + closed: true, + issue_number: 2, + ..Default::default() + }) + .await; + let other_repo_vote_id = test_db + .seed_vote(SeedVote { + close_pending: true, + closed: true, + repository_full_name: OTHER_REPOFN, + ..Default::default() + }) + .await; + let unaffected_votes = [ + (untracked_vote_id, test_db.read_vote(untracked_vote_id).await), + (other_issue_vote_id, test_db.read_vote(other_issue_vote_id).await), + (other_repo_vote_id, test_db.read_vote(other_repo_vote_id).await), + ]; + + // Store a new vote in the issue + let input = setup_test_create_vote_input(); + let new_vote_id = db.store_vote(COMMENT_ID2, &input, &CfgProfile::default()).await.unwrap(); + + // Check only the check run and labels steps of the pending vote are marked as done + let pending_vote = db.get_vote(pending_vote_id).await.unwrap().unwrap(); + assert!(pending_vote.close_pending); + assert_eq!(pending_vote.close_announcement_posted_at, None); + assert!(pending_vote.close_check_run_created_at.is_some()); + assert_eq!(pending_vote.close_comment_posted_at, None); + assert!(pending_vote.close_labels_updated_at.is_some()); + + // Check the steps done already keep the time they were done + let vote = db.get_vote(check_run_done_vote_id).await.unwrap().unwrap(); + assert_eq!( + vote.close_check_run_created_at, + check_run_done_vote.close_check_run_created_at + ); + assert!(vote.close_labels_updated_at.is_some()); + + // Check the other votes did not change, nor did the new one + for (vote_id, vote) in unaffected_votes { + assert_eq!(test_db.read_vote(vote_id).await, vote); + } + let new_vote = db.get_vote(new_vote_id).await.unwrap().unwrap(); + assert_eq!(new_vote.close_check_run_created_at, None); + assert_eq!(new_vote.close_labels_updated_at, None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_store_vote_without_organization() { + // Setup database and input + let test_db = TestDB::new().await; + let db = test_db.db(); + let input = CreateVoteInput { + organization: None, + ..setup_test_create_vote_input() + }; + + // Store the vote + let vote_id = db.store_vote(COMMENT_ID, &input, &CfgProfile::default()).await.unwrap(); + + // Check the vote does not have an organization + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert_eq!(vote.organization, None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_undecodable_cfg_returns_errors() { + // Setup database with a finished vote without the required cfg fields + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + cfg: json!({"close_on_passing": true}), + ends_in: "-1 minute", + ..Default::default() + }) + .await; + + // Check all the reads return the decoding error instead of panicking + assert_decode_error(&db.claim_vote_to_close().await.unwrap_err(), CFG_DECODE_ERROR); + assert_decode_error( + &db.get_open_vote(REPOFN, ISSUE_NUM).await.unwrap_err(), + CFG_DECODE_ERROR, + ); + assert_decode_error( + &db.get_open_votes_with_close_on_passing().await.unwrap_err(), + CFG_DECODE_ERROR, + ); + assert_decode_error(&db.get_vote(vote_id).await.unwrap_err(), CFG_DECODE_ERROR); + assert_decode_error(&db.list_votes(REPOFN).await.unwrap_err(), CFG_DECODE_ERROR); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_undecodable_results_return_errors() { + // Setup database with a closed vote with results in an unknown format + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + closed: true, + + results: Some(json!({"unknown": true})), + ..Default::default() + }) + .await; + + // Check the reads return the decoding error instead of panicking + assert_decode_error(&db.get_vote(vote_id).await.unwrap_err(), RESULTS_DECODE_ERROR); + assert_decode_error(&db.list_votes(REPOFN).await.unwrap_err(), RESULTS_DECODE_ERROR); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_update_vote_ends_at_does_not_move_the_end_of_a_finished_vote() { + // Setup database with a finished vote waiting to be closed + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + ends_in: "-1 hour", + ..Default::default() + }) + .await; + let vote = db.get_vote(vote_id).await.unwrap(); + + // Update its ending timestamp + db.update_vote_ends_at(vote_id).await.unwrap(); + + // Check it did not change, so reactions after the end are still ignored + assert_eq!(db.get_vote(vote_id).await.unwrap(), vote); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_update_vote_ends_at_finishes_only_the_vote_provided() { + // Setup database with two open votes + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db.seed_vote(SeedVote::default()).await; + let other_vote_id = test_db + .seed_vote(SeedVote { + issue_number: 2, + ..Default::default() + }) + .await; + let other_vote = db.get_vote(other_vote_id).await.unwrap(); + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); + + // Update the ending timestamp of one of the votes + db.update_vote_ends_at(vote_id).await.unwrap(); + + // Check only that vote can be closed now, and the other one did not change + assert_eq!(db.claim_vote_to_close().await.unwrap().unwrap().vote_id, vote_id); + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); + assert_eq!(db.get_vote(other_vote_id).await.unwrap(), other_vote); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_update_vote_last_check_updates_only_the_vote_provided() { + // Setup database with two votes due a status check + let test_db = TestDB::new().await; + let db = test_db.db(); + let cfg = merge_cfg(&json!({"periodic_status_check": "1 day"})); + let vote_id = test_db + .seed_vote(SeedVote { + cfg: cfg.clone(), + ..Default::default() + }) + .await; + let other_vote_id = test_db + .seed_vote(SeedVote { + cfg, + issue_number: 2, + ..Default::default() + }) + .await; + assert_eq!(db.get_pending_status_checks().await.unwrap().len(), 2); + + // Update the last check of one of the votes + db.update_vote_last_check(vote_id).await.unwrap(); + + // Check only the other vote is still due a status check + let inputs = db.get_pending_status_checks().await.unwrap(); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].issue_number, 2); + assert!(db.get_vote(vote_id).await.unwrap().unwrap().checked_at.is_some()); + assert_eq!( + db.get_vote(other_vote_id).await.unwrap().unwrap().checked_at, + None + ); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_vote_with_json_null_results_has_no_results() { + // Setup database with a closed vote with JSON null results + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + closed: true, + + results: Some(Value::Null), + ..Default::default() + }) + .await; + assert!(!test_db.results_are_sql_null(vote_id).await); + + // Check the vote can be read + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(vote.closed); + assert_eq!(vote.results, None); + assert_eq!(db.list_votes(REPOFN).await.unwrap(), vec![vote]); +} + +// Helpers. + +/// Checks the delay until the next attempt is the one expected, allowing for the time +/// elapsed since it was set. +fn assert_close_retry_delay(delay: f64, expected: f64) { + let range = expected - CLOSE_RETRY_DELAY_TOLERANCE_SECS..=expected; + assert!(range.contains(&delay), "{delay} not in {range:?}"); +} + +/// Checks the error provided is the one returned when a stored column cannot be decoded. +fn assert_decode_error(err: &Error, expected_cause: &str) { + let err = err.downcast_ref::().expect("database error"); + assert!(err.to_string().starts_with("error deserializing column"), "{err}"); + let cause = err.source().expect("error should have a cause").to_string(); + assert!(cause.contains(expected_cause), "{cause}"); +} + +/// Claims the next vote with closing work due, which must exist, returning the time its +/// claim ends at. +async fn claim_vote_to_close_until(db: &PgDB) -> OffsetDateTime { + let vote = db.claim_vote_to_close().await.unwrap().expect("vote to close"); + vote.close_next_attempt_at.expect("claimed vote should have a claim") +} + +/// Adds the fields provided to a vote configuration profile with the required ones. +fn merge_cfg(fields: &Value) -> Value { + let mut cfg = setup_seed_cfg(); + cfg.as_object_mut().unwrap().extend(fields.as_object().unwrap().clone()); + cfg +} + +/// Sets up the input to create a vote, with a distinct value for each field. +fn setup_test_create_vote_input() -> CreateVoteInput { + CreateVoteInput { + created_by: USER.to_string(), + installation_id: INST_ID as i64, + is_automatic: false, + is_pull_request: true, + issue_id: ISSUE_ID, + issue_number: ISSUE_NUM, + issue_title: TITLE.to_string(), + repository_full_name: REPOFN.to_string(), + + opened_pr_base_branch: None, + organization: Some(ORG.to_string()), + profile_name: Some(PROFILE_NAME.to_string()), + } +} + +/// Sets up a vote configuration profile with all the optional sections set. +fn setup_test_full_cfg_profile() -> CfgProfile { + CfgProfile { + duration: Duration::from_hours(1), + pass_threshold: 66.5, + + allowed_voters: Some(AllowedVoters { + exclude_team_maintainers: Some(true), + teams: Some(vec![TEAM1.to_string()]), + users: Some(vec![USER1.to_string()]), + }), + announcements: Some(Announcements { + discussions: Some(DiscussionsAnnouncements { + category: DISCUSSIONS_CATEGORY.to_string(), + }), + }), + close_on_passing: Some(true), + close_on_passing_min_wait: Some("2 days".to_string()), + pass_threshold_base: Some(PassThresholdBase::VotesCast { + exclude_abstentions: Some(true), + }), + periodic_status_check: Some("1 week".to_string()), + } +} + +/// Checks if the check run and labels steps of the vote provided are pending, in that +/// order. +async fn state_steps_pending(db: &PgDB, vote_id: Uuid) -> [bool; 2] { + let check_run = db.is_close_state_step_pending(vote_id, VoteCloseStep::CheckRun).await.unwrap(); + let labels = db.is_close_state_step_pending(vote_id, VoteCloseStep::Labels).await.unwrap(); + [check_run, labels] +} diff --git a/src/github.rs b/src/github.rs index 35ce699..3b1d911 100644 --- a/src/github.rs +++ b/src/github.rs @@ -1,66 +1,78 @@ //! This module defines an abstraction layer over the GitHub API. -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; -use anyhow::{Context, Error, Result, bail}; +use anyhow::{Context, Error, Result, bail, format_err}; use async_trait::async_trait; use axum::http::HeaderValue; use graphql_client::GraphQLQuery; use http::StatusCode; #[cfg(test)] use mockall::automock; -use octocrab::{Octocrab, Page, models::InstallationId}; +use octocrab::{ + DefaultOctocrabBuilderConfig, NoAuth, NoSvc, NotLayerReady, Octocrab, OctocrabBuilder, Page, + map_github_error, + models::{CommentId as OctocrabCommentId, InstallationId}, + service::middleware::retry::RetryConfig, +}; +use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use thiserror::Error; +use time::{OffsetDateTime, UtcOffset, format_description::well_known::Rfc3339}; +use tracing::{debug, warn}; use crate::cfg_repo::CfgProfile; +#[cfg(test)] +mod tests; + +/// Characters escaped in a branch name used in a URL path. Slashes are kept, as the +/// GitHub API expects a branch name containing them as multiple path segments. +const BRANCH_ENCODE_SET: &AsciiSet = + &NON_ALPHANUMERIC.remove(b'-').remove(b'.').remove(b'/').remove(b'_').remove(b'~'); + +/// Number of comments requested per page when looking for a comment with a marker, +/// which is the maximum GitHub allows. +const COMMENTS_PAGE_SIZE: u8 = 100; + /// Configuration file name. const CONFIG_FILE: &str = ".gitvote.yml"; -/// Repository where the organization wide config file should be located. -const ORG_CONFIG_REPO: &str = ".github"; +/// Maximum time allowed to establish a connection with the GitHub API. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Messages of the GraphQL errors that retrying the creation of a discussion will not +/// solve: permissions not granted, and a repository or category that does not exist. +const DISCUSSION_REJECTED_MESSAGES: [&str; 2] = + ["Resource not accessible by integration", "Could not resolve to a"]; /// Name used for the check run in GitHub. const GITVOTE_CHECK_NAME: &str = "GitVote"; -/// Type alias to represent a GH trait object. -pub(crate) type DynGH = Arc; +/// Repository where the organization wide config file should be located. +const ORG_CONFIG_REPO: &str = ".github"; -/// Type alias to represent a comment id. -type CommentId = i64; +/// Characters escaped in a value used as a single URL path segment, like a team slug. +/// Slashes are escaped too, so that the value is not split into several segments. +const PATH_SEGMENT_ENCODE_SET: &AsciiSet = + &NON_ALPHANUMERIC.remove(b'-').remove(b'.').remove(b'_').remove(b'~'); -/// Type alias to represent a team slug. -pub(crate) type TeamSlug = String; +/// Maximum time a read from the GitHub API can be waiting for data. +const READ_TIMEOUT: Duration = Duration::from_secs(30); -/// Type alias to represent a username. -pub(crate) type UserName = String; +/// Number of times the retrying client sends a request again after a failure. +const REQUEST_MAX_RETRIES: usize = 3; -/// Announcement repository query. -#[derive(Debug, Clone, GraphQLQuery)] -#[graphql( - schema_path = "src/graphql/github_schema.graphql", - query_path = "src/graphql/announcement_repo_query.graphql", - response_derives = "Debug, PartialEq, Eq" -)] -pub struct AnnouncementRepoQuery; - -/// Create discussion mutation. -#[derive(Debug, Clone, GraphQLQuery)] -#[graphql( - schema_path = "src/graphql/github_schema.graphql", - query_path = "src/graphql/create_discussion.graphql", - response_derives = "Debug, PartialEq, Eq" -)] -pub struct CreateDiscussion; +/// Maximum time a write to the GitHub API can be waiting to send data. +const WRITE_TIMEOUT: Duration = Duration::from_secs(30); /// Trait that defines some operations a GH implementation must support. #[async_trait] #[allow(clippy::ref_option_ref)] #[cfg_attr(test, automock)] pub(crate) trait GH { - /// Add labels to the provided issue. + /// Adds labels to the provided issue. async fn add_labels( &self, inst_id: u64, @@ -70,7 +82,7 @@ pub(crate) trait GH { labels: &[&str], ) -> Result<()>; - /// Create a check run for the head commit in the provided pull request. + /// Creates a check run for the head commit in the provided pull request. async fn create_check_run( &self, inst_id: u64, @@ -80,7 +92,19 @@ pub(crate) trait GH { check_details: &CheckDetails, ) -> Result<()>; - /// Create a new discussion in the repository provided. + /// Creates a check run for the commit provided. + async fn create_commit_check_run( + &self, + inst_id: u64, + owner: &str, + repo: &str, + head_sha: &str, + check_details: &CheckDetails, + ) -> Result<()>; + + /// Creates a new discussion in the repository provided. If GitHub rejects it for a + /// reason that retrying will not solve the error returned is a + /// [`DiscussionRejectedError`]. async fn create_discussion( &self, inst_id: u64, @@ -91,7 +115,12 @@ pub(crate) trait GH { body: &str, ) -> Result<()>; - /// Get all users allowed to vote on a given vote. + /// Deletes the comment provided. + async fn delete_comment(&self, inst_id: u64, owner: &str, repo: &str, comment_id: i64) -> Result<()>; + + /// Returns all users allowed to vote on a given vote. Teams that do not exist are + /// skipped, and all repository collaborators are allowed to vote only when the + /// profile lists no teams or users. async fn get_allowed_voters( &self, inst_id: u64, @@ -101,11 +130,12 @@ pub(crate) trait GH { org: Option<&String>, ) -> Result>; - /// Get all repository collaborators. + /// Returns all repository collaborators. #[allow(dead_code)] async fn get_collaborators(&self, inst_id: u64, owner: &str, repo: &str) -> Result>; - /// Get reactions for the provided comment. + /// Returns the reactions for the provided comment. If the comment does not exist the + /// error returned is a [`CommentNotFoundError`]. async fn get_comment_reactions( &self, inst_id: u64, @@ -114,16 +144,21 @@ pub(crate) trait GH { comment_id: i64, ) -> Result>; - /// Get configuration file. - async fn get_config_file(&self, inst_id: u64, owner: &str, repo: &str) -> Option; + /// Returns the configuration file, if there is one in any of the supported + /// locations. + async fn get_config_file(&self, inst_id: u64, owner: &str, repo: &str) -> Result>; - /// Get pull request files. + /// Returns the pull request files. async fn get_pr_files(&self, inst_id: u64, owner: &str, repo: &str, pr_number: i64) -> Result>; - /// Get repository installation identifier. + /// Returns the head commit of the provided pull request. + async fn get_pr_head_sha(&self, inst_id: u64, owner: &str, repo: &str, pr_number: i64) -> Result; + + /// Returns the repository installation identifier. async fn get_repository_installation_id(&self, owner: &str, repo: &str) -> Result; - /// Get all members of the provided team. + /// Returns all members of the provided team. If the team does not exist the error + /// returned is a [`TeamNotFoundError`]. #[allow(dead_code)] async fn get_team_members( &self, @@ -133,11 +168,42 @@ pub(crate) trait GH { exclude_maintainers: bool, ) -> Result>; - /// Verify if the `GitVote` check is required via branch protection in the + /// Checks if this application posted a comment ending with the marker provided in + /// the repository's issue given since the time provided. Comments posted by anyone + /// else are ignored, as anyone can write the marker, and so are the ones with the + /// marker elsewhere in their body, as it may be part of some text users control. + async fn has_comment_with_marker( + &self, + inst_id: u64, + owner: &str, + repo: &str, + issue_number: i64, + marker: &str, + since: OffsetDateTime, + ) -> Result; + + /// Checks if this application created a discussion ending with the marker provided + /// in the repository's discussion category given since the time provided. + /// Discussions created by anyone else are ignored, as anyone can write the marker, + /// and so are the ones with the marker elsewhere in their body, as it may be part + /// of some text users control. If GitHub rejects the lookup for a reason that + /// retrying will not solve, like the creation of a discussion, the error returned + /// is a [`DiscussionRejectedError`]. + async fn has_discussion_with_marker( + &self, + inst_id: u64, + owner: &str, + repo: &str, + category: &str, + marker: &str, + since: OffsetDateTime, + ) -> Result; + + /// Verifies if the `GitVote` check is required via branch protection in the /// repository's branch provided. async fn is_check_required(&self, inst_id: u64, owner: &str, repo: &str, branch: &str) -> Result; - /// Post the comment provided in the repository's issue given. + /// Posts the comment provided in the repository's issue given. async fn post_comment( &self, inst_id: u64, @@ -147,7 +213,7 @@ pub(crate) trait GH { body: &str, ) -> Result; - /// Remove label from the provided issue. + /// Removes a label from the provided issue. async fn remove_label( &self, inst_id: u64, @@ -157,32 +223,390 @@ pub(crate) trait GH { label: &str, ) -> Result<()>; - /// Check if the user given is a collaborator of the provided repository. + /// Checks if the user given is a collaborator of the provided repository. async fn user_is_collaborator(&self, inst_id: u64, owner: &str, repo: &str, user: &str) -> Result; } +/// Type alias to represent the builder of a GitHub client. +pub(crate) type ClientBuilder = OctocrabBuilder; + +/// Type alias to represent a comment id. +type CommentId = i64; + +/// Type alias used by the GraphQL queries to represent the `DateTime` scalar, which is +/// an RFC 3339 timestamp. +type DateTime = String; + +/// Type alias to represent a GH trait object. +pub(crate) type DynGH = Arc; + +/// Type alias to represent a team slug. +pub(crate) type TeamSlug = String; + +/// Type alias to represent a username. +pub(crate) type UserName = String; + +/// Announcement discussions query. +#[derive(Debug, Clone, GraphQLQuery)] +#[graphql( + schema_path = "src/graphql/github_schema.graphql", + query_path = "src/graphql/announcement_discussions_query.graphql", + response_derives = "Debug, PartialEq, Eq" +)] +struct AnnouncementDiscussionsQuery; + +/// Announcement repository query. +#[derive(Debug, Clone, GraphQLQuery)] +#[graphql( + schema_path = "src/graphql/github_schema.graphql", + query_path = "src/graphql/announcement_repo_query.graphql", + response_derives = "Debug, PartialEq, Eq" +)] +struct AnnouncementRepoQuery; + +/// Repository branch as returned by the GitHub API, with only the fields needed. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct Branch { + /// Name of the branch. + name: String, + + /// Protection settings of the branch, if it is protected. + protection: Option, +} + +/// Details of a check run to create. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub(crate) struct CheckDetails { + /// Status of the check run, like `in_progress` or `completed`. pub status: String, - pub conclusion: Option, + /// Summary of the check run, also used as its title. pub summary: String, + + /// Conclusion of the check run, required when it is completed. + pub conclusion: Option, +} + +/// Issue or pull request comment included in an event. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct Comment { + /// Identifier of the comment. + pub id: i64, + + /// Body of the comment, which may be missing. + pub body: Option, +} + +/// Error returned when a comment does not exist, usually because it was deleted. +#[derive(Debug, Error)] +#[error("comment not found")] +pub(crate) struct CommentNotFoundError; + +/// Create discussion mutation. +#[derive(Debug, Clone, GraphQLQuery)] +#[graphql( + schema_path = "src/graphql/github_schema.graphql", + query_path = "src/graphql/create_discussion.graphql", + response_derives = "Debug, PartialEq, Eq" +)] +struct CreateDiscussion; + +/// Error returned when a discussion cannot be created for a reason that retrying will +/// not solve, like missing permissions or a category that does not exist. +#[derive(Debug, Error)] +#[error(transparent)] +pub(crate) struct DiscussionRejectedError( + /// Error GitHub returned when rejecting the request. + pub Error, +); + +/// Represents a GitHub event. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) enum Event { + /// Issue event. + Issue(IssueEvent), + /// Issue comment event, also sent for pull request comments. + IssueComment(IssueCommentEvent), + /// Pull request event. + PullRequest(PullRequestEvent), +} + +/// Errors that may occur while creating a new event instance. +#[derive(Debug, Error, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) enum EventError { + /// The event body could not be parsed. + #[error("invalid body: {0}")] + InvalidBody(String), + /// The event type header was not provided. + #[error("event header missing")] + MissingHeader, + /// The event type is not supported. + #[error("unsupported event")] + UnsupportedEvent, +} + +/// Pull request file as returned by the GitHub API, with only the fields needed. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct File { + /// Path of the file in the repository. + pub filename: String, } -/// GH implementation backed by the GitHub API. +/// GH implementation backed by the GitHub API. Octocrab's retry policies do not look at +/// the request, so requests are split between two clients depending on whether they can +/// be repeated. pub(crate) struct GHApi { - app_client: Octocrab, + /// Identifier of the GitHub application, which tells apart what it published. + app_id: u64, + /// Client for requests that are safe to repeat: the ones reading data, deletions and + /// adding labels, which change nothing the second time, and creating check runs, as + /// the latest one replaces the previous ones with the same name. + retrying_client: Octocrab, + /// Client for requests that must be sent only once within a call: posting comments + /// and creating discussions. A request that failed may have been processed, so + /// sending it again could leave a visible duplicate. + single_attempt_client: Octocrab, } impl GHApi { - /// Create a new `GHApi` instance. - pub(crate) fn new(app_client: Octocrab) -> Self { - Self { app_client } + /// Creates a new `GHApi` instance. + pub(crate) fn new(app_id: u64, retrying_client: Octocrab, single_attempt_client: Octocrab) -> Self { + Self { + app_id, + retrying_client, + single_attempt_client, + } } } +/// GitHub application as returned by the GitHub API, with only the fields needed. +#[derive(Debug, Deserialize)] +struct GitHubAppResponse { + /// Identifier of the application. + id: u64, +} + +/// GitHub application installation an event was delivered to. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct Installation { + /// Identifier of the installation. + pub id: i64, +} + +/// Issue included in an event, which may be a pull request. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct Issue { + /// Identifier of the issue. + pub id: i64, + /// Number of the issue in the repository. + pub number: i64, + /// Title of the issue. + pub title: String, + + /// Body of the issue, which may be missing. + pub body: Option, + /// Pull request details, only present when the issue is a pull request. + pub pull_request: Option, +} + +/// Issue comment event payload. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct IssueCommentEvent { + /// Action performed on the comment. + pub action: IssueCommentEventAction, + /// Comment the event refers to. + pub comment: Comment, + /// Installation the event was delivered to. + pub installation: Installation, + /// Issue or pull request the comment belongs to. + pub issue: Issue, + /// Repository the issue belongs to. + pub repository: Repository, + /// User who triggered the event. + pub sender: User, + + /// Organization the repository belongs to, if any. + pub organization: Option, +} + +/// Actions of an issue comment event. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum IssueCommentEventAction { + /// A comment was created. + Created, + /// Any other action, which is not handled. + #[serde(other)] + Other, +} + +/// Issue comment as returned by the GitHub API, with only the fields needed to look for +/// a marker in the comments posted by this application. +#[derive(Debug, Deserialize)] +struct IssueCommentResponse { + /// Body of the comment, which may be missing. + body: Option, + /// Application the comment was posted by, if any. + performed_via_github_app: Option, +} + +/// Issue event payload. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct IssueEvent { + /// Action performed on the issue. + pub action: IssueEventAction, + /// Installation the event was delivered to. + pub installation: Installation, + /// Issue the event refers to. + pub issue: Issue, + /// Repository the issue belongs to. + pub repository: Repository, + /// User who triggered the event. + pub sender: User, + + /// Organization the repository belongs to, if any. + pub organization: Option, +} + +/// Actions of an issue event. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum IssueEventAction { + /// An issue was opened. + Opened, + /// Any other action, which is not handled. + #[serde(other)] + Other, +} + +/// Organization included in an event. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct Organization { + /// Login of the organization. + pub login: String, +} + +/// Branch protection settings as returned by the GitHub API. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct Protection { + /// Status checks required to pass before merging, if any. + required_status_checks: Option, +} + +/// Pull request included in an event. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct PullRequest { + /// Branch the pull request is meant to be merged into. + pub base: PullRequestBase, + /// Identifier of the pull request. + pub id: i64, + /// Number of the pull request in the repository. + pub number: i64, + /// Title of the pull request. + pub title: String, + + /// Body of the pull request, which may be missing. + pub body: Option, +} + +/// Base branch of a pull request. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct PullRequestBase { + /// Name of the base branch. + #[serde(rename = "ref")] + pub reference: String, +} + +/// Pull request event payload. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct PullRequestEvent { + /// Action performed on the pull request. + pub action: PullRequestEventAction, + /// Installation the event was delivered to. + pub installation: Installation, + /// Pull request the event refers to. + pub pull_request: PullRequest, + /// Repository the pull request belongs to. + pub repository: Repository, + /// User who triggered the event. + pub sender: User, + + /// Organization the repository belongs to, if any. + pub organization: Option, +} + +/// Actions of a pull request event. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum PullRequestEventAction { + /// A pull request was opened. + Opened, + /// New commits were pushed to a pull request. + Synchronize, + // Serde requires the catch-all variant to be the last one + /// Any other action, which is not handled. + #[serde(other)] + Other, +} + +/// Pull request details included in an issue that is a pull request. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct PullRequestInIssue { + /// API URL of the pull request. + pub url: String, +} + +/// Reaction to a comment made by a user. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct Reaction { + /// Content of the reaction, like `+1` or `heart`. + pub content: String, + /// Time the reaction was created, as an RFC 3339 timestamp. + pub created_at: String, + /// User who reacted. + pub user: User, +} + +/// Reaction as returned by the GitHub API, where the user may be null. +#[derive(Debug, Deserialize)] +struct ReactionResponse { + /// Content of the reaction. + content: String, + /// Time the reaction was created, as an RFC 3339 timestamp. + created_at: String, + + /// User who reacted, missing when the account no longer exists. + user: Option, +} + +/// Repository included in an event. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct Repository { + /// Full name of the repository, in the `owner/repo` format. + pub full_name: String, +} + +/// Status checks required by a branch protection rule. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct RequiredStatusCheck { + /// Names of the required checks. + contexts: Vec, +} + +/// Error returned when a team does not exist, or it is not visible to the installation. +#[derive(Debug, Error)] +#[error("team not found")] +struct TeamNotFoundError; + +/// GitHub user. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct User { + /// Login of the user. + pub login: UserName, +} + #[async_trait] impl GH for GHApi { - /// [`GH::add_labels`] + /// [`GH::add_labels`]. async fn add_labels( &self, inst_id: u64, @@ -191,13 +615,13 @@ impl GH for GHApi { issue_number: i64, labels: &[&str], ) -> Result<()> { - let client = self.app_client.installation(InstallationId(inst_id))?; + let client = self.retrying_client.installation(InstallationId(inst_id))?; let labels = labels.iter().map(ToString::to_string).collect::>(); client.issues(owner, repo).add_labels(issue_number as u64, &labels).await?; Ok(()) } - /// [`GH::create_check_run`] + /// [`GH::create_check_run`]. async fn create_check_run( &self, inst_id: u64, @@ -206,27 +630,28 @@ impl GH for GHApi { issue_number: i64, check_details: &CheckDetails, ) -> Result<()> { - let client = self.app_client.installation(InstallationId(inst_id))?; - let pr = client.pulls(owner, repo).get(issue_number as u64).await?; - let head_sha = pr.head.context("pull request response missing head")?.sha; - let url = format!("/repos/{owner}/{repo}/check-runs"); - let mut body = json!({ - "name": GITVOTE_CHECK_NAME, - "head_sha": head_sha, - "status": check_details.status, - "output": { - "title": check_details.summary, - "summary": check_details.summary, - } - }); - if let Some(conclusion) = &check_details.conclusion { - body["conclusion"] = json!(conclusion); - } - let _: Value = client.post(url, Some(&body)).await?; - Ok(()) + // Get the head commit of the pull request + let client = self.retrying_client.installation(InstallationId(inst_id))?; + let head_sha = fetch_pr_head_sha(&client, owner, repo, issue_number).await?; + + // Create the check run + post_check_run(&client, owner, repo, &head_sha, check_details).await } - /// [`GH::create_discussion`] + /// [`GH::create_commit_check_run`]. + async fn create_commit_check_run( + &self, + inst_id: u64, + owner: &str, + repo: &str, + head_sha: &str, + check_details: &CheckDetails, + ) -> Result<()> { + let client = self.retrying_client.installation(InstallationId(inst_id))?; + post_check_run(&client, owner, repo, head_sha, check_details).await + } + + /// [`GH::create_discussion`]. async fn create_discussion( &self, inst_id: u64, @@ -236,26 +661,12 @@ impl GH for GHApi { title: &str, body: &str, ) -> Result<()> { - let client = self.app_client.installation(InstallationId(inst_id))?; - // Fetch some repository details needed to create a discussion - let response: announcement_repo_query::ResponseData = client - .graphql(&AnnouncementRepoQuery::build_query( - announcement_repo_query::Variables { - owner: owner.to_string(), - repo: repo.to_string(), - category: category.to_string(), - }, - )) - .await?; - let Some((repository_id, category_id)) = response.repository.and_then(|r| { - let discussion_category = r.discussion_category?; - Some((r.id, discussion_category.id)) - }) else { - bail!("something went wrong while fetching repository details for announcement") - }; + let client = self.retrying_client.installation(InstallationId(inst_id))?; + let (repository_id, category_id) = fetch_announcement_repo(&client, owner, repo, category).await?; // Create discussion + let client = self.single_attempt_client.installation(InstallationId(inst_id))?; let _: create_discussion::ResponseData = client .graphql(&CreateDiscussion::build_query(create_discussion::Variables { repository_id, @@ -264,12 +675,20 @@ impl GH for GHApi { body: body.to_string(), })) .await + .map_err(map_discussion_error) .context("error creating announcement discussion")?; Ok(()) } - /// [`GH::get_allowed_voters`] + /// [`GH::delete_comment`]. + async fn delete_comment(&self, inst_id: u64, owner: &str, repo: &str, comment_id: i64) -> Result<()> { + let client = self.retrying_client.installation(InstallationId(inst_id))?; + client.issues(owner, repo).delete_comment(OctocrabCommentId(comment_id as u64)).await?; + Ok(()) + } + + /// [`GH::get_allowed_voters`]. async fn get_allowed_voters( &self, inst_id: u64, @@ -278,9 +697,18 @@ impl GH for GHApi { repo: &str, org: Option<&String>, ) -> Result> { - let mut allowed_voters: Vec = vec![]; + // All repository collaborators are allowed to vote if the profile lists no + // teams or users, so a list that ends up empty must not widen the electorate + let lists_voters = cfg.allowed_voters.as_ref().is_some_and(|voters| { + voters.teams.as_ref().is_some_and(|teams| !teams.is_empty()) + || voters.users.as_ref().is_some_and(|users| !users.is_empty()) + }); + if !lists_voters { + return self.get_collaborators(inst_id, owner, repo).await; + } // Get allowed voters from configuration + let mut allowed_voters: Vec = vec![]; if let Some(cfg_allowed_voters) = &cfg.allowed_voters { // Teams if let Some(org) = org @@ -288,13 +716,22 @@ impl GH for GHApi { { let exclude_maintainers = cfg_allowed_voters.exclude_team_maintainers.unwrap_or(false); for team in teams { - if let Ok(members) = - self.get_team_members(inst_id, org.as_str(), team, exclude_maintainers).await - { - for user in members { - if !allowed_voters.iter().any(|voter| voter.eq_ignore_ascii_case(&user)) { - allowed_voters.push(user.clone()); + // Skip teams that do not exist, any other failure must be retried + // as the electorate would be incomplete + let members = + match self.get_team_members(inst_id, org.as_str(), team, exclude_maintainers).await { + Ok(members) => members, + Err(err) if err.is::() => { + warn!(org, team, "skipping allowed voters team not found"); + continue; } + Err(err) => return Err(err), + }; + + // Add the members not added yet (usernames are case insensitive) + for user in members { + if !allowed_voters.iter().any(|voter| voter.eq_ignore_ascii_case(&user)) { + allowed_voters.push(user.clone()); } } } @@ -311,25 +748,20 @@ impl GH for GHApi { } } - // If no allowed voters can be found in the configuration, all - // repository collaborators are allowed to vote - if allowed_voters.is_empty() { - return self.get_collaborators(inst_id, owner, repo).await; - } - Ok(allowed_voters) } - /// [`GH::get_collaborators`] + /// [`GH::get_collaborators`]. async fn get_collaborators(&self, inst_id: u64, owner: &str, repo: &str) -> Result> { - let client = self.app_client.installation(InstallationId(inst_id))?; + // Get all the pages of collaborators + let client = self.retrying_client.installation(InstallationId(inst_id))?; let url = format!("/repos/{owner}/{repo}/collaborators"); let first_page: Page = client.get(url, None::<&()>).await?; - let collaborators = client.all_pages(first_page).await?.into_iter().map(|u| u.login).collect(); + let collaborators = client.all_pages(first_page).await?.into_iter().map(|user| user.login).collect(); Ok(collaborators) } - /// [`GH::get_comment_reactions`] + /// [`GH::get_comment_reactions`]. async fn get_comment_reactions( &self, inst_id: u64, @@ -337,56 +769,93 @@ impl GH for GHApi { repo: &str, comment_id: i64, ) -> Result> { - let client = self.app_client.installation(InstallationId(inst_id))?; + // Get the installation token first: a 404 on it says nothing about the comment + let client = self.retrying_client.installation(InstallationId(inst_id))?; + client.installation_token().await?; + + // Get all the reactions, telling apart a comment that does not exist let url = format!("/repos/{owner}/{repo}/issues/comments/{comment_id}/reactions"); - let first_page: Page = client.get(url, None::<&()>).await?; - let reactions = client.all_pages(first_page).await?; + let reactions = async { + let first_page: Page = client.get(url, None::<&()>).await?; + client.all_pages(first_page).await + }; + let reactions = reactions.await.map_err(|err| map_not_found_error(err, CommentNotFoundError))?; + + // Skip reactions without a user, as they cannot be counted as a vote + let reactions = reactions + .into_iter() + .filter_map(|reaction| { + let Some(user) = reaction.user else { + debug!(comment_id, "skipping reaction without user"); + return None; + }; + Some(Reaction { + content: reaction.content, + created_at: reaction.created_at, + user, + }) + }) + .collect(); Ok(reactions) } - /// [`GH::get_config_file`] - async fn get_config_file(&self, inst_id: u64, owner: &str, repo: &str) -> Option { - let Ok(client) = self.app_client.installation(InstallationId(inst_id)) else { - return None; - }; + /// [`GH::get_config_file`]. + async fn get_config_file(&self, inst_id: u64, owner: &str, repo: &str) -> Result> { + // Get the installation token first, as a 404 on it says nothing about the file + let client = self.retrying_client.installation(InstallationId(inst_id))?; + client.installation_token().await?; // Check possible locations for configuration file in order of precedence let config_path = format!(".github/{CONFIG_FILE}"); let candidates = [ - // Check repository root. + // Check repository root (repo, CONFIG_FILE), - // Check repository `.github` directory. + // Check repository `.github` directory (repo, config_path.as_str()), - // Check organization-wide `.github` repository. + // Check organization-wide `.github` repository (ORG_CONFIG_REPO, CONFIG_FILE), ]; for (repo, path) in candidates { - if let Ok(resp) = client.repos(owner, repo).get_content().path(path).send().await - && resp.items.len() == 1 - { - return resp.items[0].decoded_content(); + match client.repos(owner, repo).get_content().path(path).send().await { + Ok(resp) if resp.items.len() == 1 => return Ok(resp.items[0].decoded_content()), + Ok(_) => {} + Err(err) => { + // Only a 404 proves the file is not in this location, moving on + // after any other failure could apply a lower precedence config + let err = Error::from(err); + if !is_not_found_error(&err) { + return Err(err.context(format!("error getting {owner}/{repo}/{path}"))); + } + } } } - None + Ok(None) } - /// [`GH::get_pr_files`] + /// [`GH::get_pr_files`]. async fn get_pr_files(&self, inst_id: u64, owner: &str, repo: &str, pr_number: i64) -> Result> { - let client = self.app_client.installation(InstallationId(inst_id))?; + // Get all the pages of files + let client = self.retrying_client.installation(InstallationId(inst_id))?; let url = format!("/repos/{owner}/{repo}/pulls/{pr_number}/files"); let first_page: Page = client.get(url, None::<&()>).await?; let files: Vec = client.all_pages(first_page).await?; Ok(files) } - /// [`GH::get_repository_installation_id`] + /// [`GH::get_pr_head_sha`]. + async fn get_pr_head_sha(&self, inst_id: u64, owner: &str, repo: &str, pr_number: i64) -> Result { + let client = self.retrying_client.installation(InstallationId(inst_id))?; + fetch_pr_head_sha(&client, owner, repo, pr_number).await + } + + /// [`GH::get_repository_installation_id`]. async fn get_repository_installation_id(&self, owner: &str, repo: &str) -> Result { - let inst = self.app_client.apps().get_repository_installation(owner, repo).await?; - Ok(inst.id.0) + let installation = self.retrying_client.apps().get_repository_installation(owner, repo).await?; + Ok(installation.id.0) } - /// [`GH::get_team_members`] + /// [`GH::get_team_members`]. async fn get_team_members( &self, inst_id: u64, @@ -394,37 +863,143 @@ impl GH for GHApi { team: &str, exclude_maintainers: bool, ) -> Result> { - let client = self.app_client.installation(InstallationId(inst_id))?; + // Get the installation token first, as a 404 on it says nothing about the team + let client = self.retrying_client.installation(InstallationId(inst_id))?; + client.installation_token().await?; + + // Get all the members, telling apart a team that does not exist. The slug is + // encoded, so that an invalid one is not found rather than an invalid request + let team = utf8_percent_encode(team, PATH_SEGMENT_ENCODE_SET); let url = format!("/orgs/{org}/teams/{team}/members"); - let first_page: Page = client - .get( - url, - Some(&serde_json::json!({ - "role": if exclude_maintainers { "member" } else { "all" }, - })), - ) - .await?; - let members: Vec = - client.all_pages(first_page).await?.into_iter().map(|u| u.login).collect(); - Ok(members) + let params = json!({"role": if exclude_maintainers { "member" } else { "all" }}); + let members = async { + let first_page: Page = client.get(url, Some(¶ms)).await?; + client.all_pages(first_page).await + }; + let members = members.await.map_err(|err| map_not_found_error(err, TeamNotFoundError))?; + Ok(members.into_iter().map(|member| member.login).collect()) } - /// [`GH::is_check_required`] + /// [`GH::has_comment_with_marker`]. + async fn has_comment_with_marker( + &self, + inst_id: u64, + owner: &str, + repo: &str, + issue_number: i64, + marker: &str, + since: OffsetDateTime, + ) -> Result { + // Get the first page of the comments updated since the time provided. It is sent + // in UTC with whole seconds, rounding it down so that no comment is left out + let client = self.retrying_client.installation(InstallationId(inst_id))?; + let since = since.to_offset(UtcOffset::UTC).replace_nanosecond(0)?.format(&Rfc3339)?; + let url = format!("/repos/{owner}/{repo}/issues/{issue_number}/comments"); + let params = json!({"per_page": COMMENTS_PAGE_SIZE, "since": since}); + let mut page: Page = + client.get(url, Some(¶ms)).await.context("error listing issue comments")?; + + // Look for the comments posted by this application that end with the marker, + // moving on to the next page until one is found + loop { + // Check the comments in the page + let has_marker = page.items.iter().any(|comment| { + let is_app_comment = + comment.performed_via_github_app.as_ref().is_some_and(|app| app.id == self.app_id); + is_app_comment && comment.body.as_deref().is_some_and(|body| ends_with_marker(body, marker)) + }); + if has_marker { + return Ok(true); + } + + // Move on to the next page, if any + match client.get_page(&page.next).await.context("error listing issue comments")? { + Some(next_page) => page = next_page, + None => return Ok(false), + } + } + } + + /// [`GH::has_discussion_with_marker`]. + async fn has_discussion_with_marker( + &self, + inst_id: u64, + owner: &str, + repo: &str, + category: &str, + marker: &str, + since: OffsetDateTime, + ) -> Result { + // Get the category the discussions are in + let client = self.retrying_client.installation(InstallationId(inst_id))?; + let (_, category_id) = fetch_announcement_repo(&client, owner, repo, category).await?; + + // Look for the discussions this application created that end with the marker, + // from the newest one, until the ones created before the time provided are + // reached + let mut after = None; + loop { + // Get the next page of discussions + let response: announcement_discussions_query::ResponseData = client + .graphql(&AnnouncementDiscussionsQuery::build_query( + announcement_discussions_query::Variables { + owner: owner.to_string(), + repo: repo.to_string(), + category_id: category_id.clone(), + after, + }, + )) + .await + .map_err(map_discussion_error) + .context("error listing announcement discussions")?; + let discussions = response + .repository + .context("repository not found listing announcement discussions")? + .discussions; + + // Check the discussions in the page + for discussion in discussions.nodes.into_iter().flatten().flatten() { + let created_at = OffsetDateTime::parse(&discussion.created_at, &Rfc3339) + .context("invalid creation time of announcement discussion")?; + if created_at < since { + return Ok(false); + } + if discussion.viewer_did_author && ends_with_marker(&discussion.body, marker) { + return Ok(true); + } + } + + // Move on to the next page, if any + if !discussions.page_info.has_next_page { + return Ok(false); + } + let cursor = discussions + .page_info + .end_cursor + .context("next page of announcement discussions without cursor")?; + after = Some(cursor); + } + } + + /// [`GH::is_check_required`]. async fn is_check_required(&self, inst_id: u64, owner: &str, repo: &str, branch: &str) -> Result { - let client = self.app_client.installation(InstallationId(inst_id))?; + // Get the branch, encoding its name for the URL path + let client = self.retrying_client.installation(InstallationId(inst_id))?; + let branch = utf8_percent_encode(branch, BRANCH_ENCODE_SET); let url = format!("/repos/{owner}/{repo}/branches/{branch}"); let branch: Branch = client.get(url, None::<&()>).await?; - let is_check_required = if let Some(required_checks) = - branch.protection.and_then(|protection| protection.required_status_checks) - { - required_checks.contexts.iter().any(|context| context == GITVOTE_CHECK_NAME) - } else { - false - }; + + // Check if the GitVote check is among the ones its protection requires + let is_check_required = branch + .protection + .and_then(|protection| protection.required_status_checks) + .is_some_and(|required_checks| { + required_checks.contexts.iter().any(|context| context == GITVOTE_CHECK_NAME) + }); Ok(is_check_required) } - /// [`GH::post_comment`] + /// [`GH::post_comment`]. async fn post_comment( &self, inst_id: u64, @@ -432,13 +1007,13 @@ impl GH for GHApi { repo: &str, issue_number: i64, body: &str, - ) -> Result { - let client = self.app_client.installation(InstallationId(inst_id))?; + ) -> Result { + let client = self.single_attempt_client.installation(InstallationId(inst_id))?; let comment = client.issues(owner, repo).create_comment(issue_number as u64, body).await?; Ok(comment.id.0 as i64) } - /// [`GH::remove_label`] + /// [`GH::remove_label`]. async fn remove_label( &self, inst_id: u64, @@ -447,7 +1022,8 @@ impl GH for GHApi { issue_number: i64, label: &str, ) -> Result<()> { - let client = self.app_client.installation(InstallationId(inst_id))?; + // Remove the label, a label the issue does not have is already removed + let client = self.retrying_client.installation(InstallationId(inst_id))?; match client.issues(owner, repo).remove_label(issue_number as u64, label).await { Ok(_) => Ok(()), Err(octocrab::Error::GitHub { source, backtrace: _ }) @@ -459,42 +1035,42 @@ impl GH for GHApi { } } - /// [`GH::user_is_collaborator`] + /// [`GH::user_is_collaborator`]. async fn user_is_collaborator(&self, inst_id: u64, owner: &str, repo: &str, user: &str) -> Result { - let client = self.app_client.installation(InstallationId(inst_id))?; + // Ask GitHub whether the user is a collaborator + let client = self.retrying_client.installation(InstallationId(inst_id))?; let url = format!("/repos/{owner}/{repo}/collaborators/{user}"); let resp = client._get(url).await?; - if resp.status() == StatusCode::NO_CONTENT { - return Ok(true); + + // Only a 404 means the user is not a collaborator + match resp.status() { + StatusCode::NO_CONTENT => Ok(true), + StatusCode::NOT_FOUND => Ok(false), + status => { + map_github_error(resp).await?; + bail!("unexpected status checking if user is collaborator: {status}") + } } - Ok(false) } } -/// Represents a GitHub event. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) enum Event { - Issue(IssueEvent), - IssueComment(IssueCommentEvent), - PullRequest(PullRequestEvent), -} - impl TryFrom<(Option<&HeaderValue>, &[u8])> for Event { type Error = EventError; + /// Parses the event body provided according to the event type in the header. fn try_from((event_name, event_body): (Option<&HeaderValue>, &[u8])) -> Result { match event_name { Some(event_name) => match event_name.as_bytes() { - b"issues" => { - let event: IssueEvent = serde_json::from_slice(event_body) - .map_err(|err| EventError::InvalidBody(err.to_string()))?; - Ok(Event::Issue(event)) - } b"issue_comment" => { let event: IssueCommentEvent = serde_json::from_slice(event_body) .map_err(|err| EventError::InvalidBody(err.to_string()))?; Ok(Event::IssueComment(event)) } + b"issues" => { + let event: IssueEvent = serde_json::from_slice(event_body) + .map_err(|err| EventError::InvalidBody(err.to_string()))?; + Ok(Event::Issue(event)) + } b"pull_request" => { let event: PullRequestEvent = serde_json::from_slice(event_body) .map_err(|err| EventError::InvalidBody(err.to_string()))?; @@ -507,172 +1083,141 @@ impl TryFrom<(Option<&HeaderValue>, &[u8])> for Event { } } -/// Errors that may occur while creating a new event instance. -#[derive(Debug, Error, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) enum EventError { - #[error("event header missing")] - MissingHeader, - #[error("unsupported event")] - UnsupportedEvent, - #[error("invalid body: {0}")] - InvalidBody(String), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct IssueEvent { - pub action: IssueEventAction, - pub installation: Installation, - pub issue: Issue, - pub repository: Repository, - pub organization: Option, - pub sender: User, -} +// Helpers. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub(crate) enum IssueEventAction { - Opened, - #[serde(other)] - Other, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct IssueCommentEvent { - pub action: IssueCommentEventAction, - pub comment: Comment, - pub installation: Installation, - pub issue: Issue, - pub repository: Repository, - pub organization: Option, - pub sender: User, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub(crate) enum IssueCommentEventAction { - Created, - #[serde(other)] - Other, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct PullRequestEvent { - pub action: PullRequestEventAction, - pub installation: Installation, - pub pull_request: PullRequest, - pub repository: Repository, - pub organization: Option, - pub sender: User, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum PullRequestEventAction { - Opened, - Synchronize, - #[serde(other)] - Other, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct Comment { - pub id: i64, - pub body: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct User { - pub login: UserName, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct Installation { - pub id: i64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct Issue { - pub id: i64, - pub number: i64, - pub title: String, - pub body: Option, - pub pull_request: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct Repository { - pub full_name: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct Organization { - pub login: String, +/// Checks if the provided error is a 404 error from GitHub. The status is what matters, +/// as the message varies (for example, it is different for empty repositories). +pub(crate) fn is_not_found_error(err: &Error) -> bool { + if let Some(octocrab::Error::GitHub { source, backtrace: _ }) = err.downcast_ref::() + && source.status_code == StatusCode::NOT_FOUND + { + return true; + } + false } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct PullRequest { - pub id: i64, - pub number: i64, - pub title: String, - pub body: Option, - pub base: PullRequestBase, +/// Returns the builder of the GitHub client for requests that are safe to repeat. +pub(crate) fn retrying_client_builder() -> ClientBuilder { + client_builder().add_retry_config(RetryConfig::Simple(REQUEST_MAX_RETRIES)) } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct PullRequestBase { - #[serde(rename = "ref")] - pub reference: String, +/// Returns the builder of the GitHub client for requests that must be sent only once. +pub(crate) fn single_attempt_client_builder() -> ClientBuilder { + client_builder().add_retry_config(RetryConfig::None) } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct PullRequestInIssue { - pub url: String, +/// Splits a repository's full name and returns the owner and the repo name as a tuple. +/// Any parts after the repo name are ignored. +pub(crate) fn split_full_name(full_name: &str) -> (&str, &str) { + let mut parts = full_name.split('/'); + let owner = parts.next().expect("split always returns at least one part"); + let repo = parts.next().expect("repository full names from GitHub have the owner/repo format"); + (owner, repo) } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct Reaction { - pub user: User, - pub content: String, - pub created_at: String, +/// Returns the builder of a GitHub client, with the timeouts for API requests set. +fn client_builder() -> ClientBuilder { + Octocrab::builder() + .set_connect_timeout(Some(CONNECT_TIMEOUT)) + .set_read_timeout(Some(READ_TIMEOUT)) + .set_write_timeout(Some(WRITE_TIMEOUT)) } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct Branch { - pub name: String, - pub protection: Option, +/// Returns whether the body provided ends with the marker given, ignoring trailing +/// whitespace. The templates render the marker last, after any text users control, like +/// an issue title, so a marker found anywhere else in the body does not count. +fn ends_with_marker(body: &str, marker: &str) -> bool { + body.trim_end().ends_with(marker) } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct Protection { - pub required_status_checks: Option, +/// Returns the identifiers of the repository provided and of its discussion category +/// given. If either does not exist, or GitHub rejects the query for a reason that +/// retrying will not solve, the error returned is a [`DiscussionRejectedError`]. +async fn fetch_announcement_repo( + client: &Octocrab, + owner: &str, + repo: &str, + category: &str, +) -> Result<(String, String)> { + // Query the repository and its discussion category + let response: announcement_repo_query::ResponseData = client + .graphql(&AnnouncementRepoQuery::build_query( + announcement_repo_query::Variables { + owner: owner.to_string(), + repo: repo.to_string(), + category: category.to_string(), + }, + )) + .await + .map_err(map_discussion_error)?; + + // Both of them must exist + let Some((repository_id, category_id)) = response.repository.and_then(|repository| { + let discussion_category = repository.discussion_category?; + Some((repository.id, discussion_category.id)) + }) else { + return Err(DiscussionRejectedError(format_err!( + "something went wrong while fetching repository details for announcement" + )) + .into()); + }; + Ok((repository_id, category_id)) } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct RequiredStatusCheck { - pub contexts: Vec, +/// Returns the head commit of the pull request provided. +async fn fetch_pr_head_sha(client: &Octocrab, owner: &str, repo: &str, pr_number: i64) -> Result { + let pr = client.pulls(owner, repo).get(pr_number as u64).await?; + Ok(pr.head.context("pull request response missing head")?.sha) } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct File { - pub filename: String, +/// Replaces an error creating a discussion that retrying will not solve with a +/// [`DiscussionRejectedError`]. Anything else may be transient, so it is left as is. +fn map_discussion_error(err: octocrab::Error) -> Error { + if let octocrab::Error::Graphql { source, backtrace: _ } = &err + && source + .0 + .iter() + .any(|error| DISCUSSION_REJECTED_MESSAGES.iter().any(|message| error.message.contains(message))) + { + return DiscussionRejectedError(err.into()).into(); + } + err.into() } -/// Helper function that splits a repository's full name and returns the owner -/// and the repo name as a tuple. -pub(crate) fn split_full_name(full_name: &str) -> (&str, &str) { - let mut parts = full_name.split('/'); - (parts.next().unwrap(), parts.next().unwrap()) +/// Replaces a 404 error from GitHub with the error provided. +fn map_not_found_error(err: octocrab::Error, not_found_err: impl Into) -> Error { + let err = Error::from(err); + if is_not_found_error(&err) { + not_found_err.into() + } else { + err + } } -/// Check if the provided error is a "Not Found" error from GitHub. -pub(crate) fn is_not_found_error(err: &Error) -> bool { - if let Some(octocrab::Error::GitHub { source, backtrace: _ }) = err.downcast_ref::() - && source.message == "Not Found" - { - return true; +/// Creates a check run for the commit provided. +async fn post_check_run( + client: &Octocrab, + owner: &str, + repo: &str, + head_sha: &str, + check_details: &CheckDetails, +) -> Result<()> { + // Prepare the check run + let url = format!("/repos/{owner}/{repo}/check-runs"); + let mut body = json!({ + "head_sha": head_sha, + "name": GITVOTE_CHECK_NAME, + "output": { + "summary": check_details.summary, + "title": check_details.summary, + }, + "status": check_details.status, + }); + if let Some(conclusion) = &check_details.conclusion { + body["conclusion"] = json!(conclusion); } - false -} -#[cfg(test)] -mod tests; + // Create it + let _: Value = client.post(url, Some(&body)).await?; + Ok(()) +} diff --git a/src/github/tests.rs b/src/github/tests.rs index 06a501f..8edae9f 100644 --- a/src/github/tests.rs +++ b/src/github/tests.rs @@ -1,1070 +1,22 @@ -use std::{fs, path::Path, sync::LazyLock}; - -use anyhow::format_err; -use aws_lc_rs::{ - encoding::AsDer, - rsa::{KeyPair, KeySize}, -}; -use axum::http::{Method, StatusCode}; -use base64::{Engine, engine::general_purpose::STANDARD}; -use jsonwebtoken::EncodingKey; -use octocrab::models::AppId; -use serde_json::{Value, json}; - -use crate::{ - cfg_repo::{AllowedVoters, CfgProfile}, - testutil::*, -}; - +use self::helpers::*; use super::*; -/// Private key used to authenticate as the test GitHub application. -static APP_PRIVATE_KEY: LazyLock = LazyLock::new(|| { - let key_pair = KeyPair::generate(KeySize::Rsa2048).unwrap(); - let der = key_pair.as_der().unwrap(); - let pem = format!( - "-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n", - STANDARD.encode(der.as_ref()) - ); - EncodingKey::from_rsa_pem(pem.as_bytes()).unwrap() -}); - -#[test] -fn event_try_from_invalid_body() { - // Setup event header - let header = HeaderValue::from_static("issues"); - - // Parse event and check the error - assert!(matches!( - Event::try_from((Some(&header), b"{}".as_slice())), - Err(EventError::InvalidBody(_)) - )); -} - -#[test] -fn event_try_from_issue_comment_event() { - // Setup event payload - let header = HeaderValue::from_static("issue_comment"); - let body = fs::read(Path::new(TESTDATA_PATH).join("event-cmd.json")).unwrap(); - - // Parse event and check the result - let mut expected_event = setup_test_issue_comment_event(); - expected_event.action = IssueCommentEventAction::Created; - expected_event.comment.body = Some("/vote".to_string()); - assert_eq!( - Event::try_from((Some(&header), body.as_slice())).unwrap(), - Event::IssueComment(expected_event) - ); -} - -#[test] -fn event_try_from_issue_event() { - // Setup event payload - let header = HeaderValue::from_static("issues"); - let body = json!({ - "action": "opened", - "installation": {"id": INST_ID}, - "issue": { - "id": ISSUE_ID, - "number": ISSUE_NUM, - "title": TITLE, - "body": "/vote", - "pull_request": {"url": "https://api.github.com/repos/org/repo/pulls/1"} - }, - "repository": {"full_name": REPOFN}, - "sender": {"login": USER} - }); - - // Parse event and check the result - let mut expected_event = setup_test_issue_event(); - expected_event.action = IssueEventAction::Opened; - expected_event.issue.body = Some("/vote".to_string()); - expected_event.issue.pull_request = Some(PullRequestInIssue { - url: "https://api.github.com/repos/org/repo/pulls/1".to_string(), - }); - expected_event.organization = None; - assert_eq!( - Event::try_from((Some(&header), body.to_string().as_bytes())).unwrap(), - Event::Issue(expected_event) - ); -} - -#[test] -fn event_try_from_issue_event_unknown_action() { - // Setup event payload - let header = HeaderValue::from_static("issues"); - let mut body = serde_json::to_value(setup_test_issue_event()).unwrap(); - body["action"] = json!("edited"); - - // Parse event and check the action - let Event::Issue(event) = Event::try_from((Some(&header), body.to_string().as_bytes())).unwrap() else { - panic!("expected an issue event"); - }; - assert_eq!(event.action, IssueEventAction::Other); -} - -#[test] -fn event_try_from_missing_header() { - assert_eq!( - Event::try_from((None, b"{}".as_slice())), - Err(EventError::MissingHeader) - ); -} - -#[test] -fn event_try_from_pr_event() { - // Setup event payload - let header = HeaderValue::from_static("pull_request"); - let body = json!({ - "action": "synchronize", - "installation": {"id": INST_ID}, - "pull_request": { - "id": ISSUE_ID, - "number": ISSUE_NUM, - "title": TITLE, - "body": null, - "base": {"ref": BRANCH} - }, - "repository": {"full_name": REPOFN}, - "organization": {"login": ORG}, - "sender": {"login": USER} - }); - - // Parse event and check the result - let mut expected_event = setup_test_pr_event(); - expected_event.action = PullRequestEventAction::Synchronize; - assert_eq!( - Event::try_from((Some(&header), body.to_string().as_bytes())).unwrap(), - Event::PullRequest(expected_event) - ); -} - -#[test] -fn event_try_from_pr_event_unknown_action() { - // Setup event payload - let header = HeaderValue::from_static("pull_request"); - let mut body = serde_json::to_value(setup_test_pr_event()).unwrap(); - body["action"] = json!("closed"); - - // Parse event and check the action - let Event::PullRequest(event) = Event::try_from((Some(&header), body.to_string().as_bytes())).unwrap() - else { - panic!("expected a pull request event"); - }; - assert_eq!(event.action, PullRequestEventAction::Other); -} - -#[test] -fn event_try_from_unsupported_event() { - // Setup event header - let header = HeaderValue::from_static("push"); - - // Parse event and check the error - assert_eq!( - Event::try_from((Some(&header), b"{}".as_slice())), - Err(EventError::UnsupportedEvent) - ); -} - -#[tokio::test] -async fn gh_api_add_labels() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::POST, - "/repos/org/repo/issues/1/labels", - StatusCode::OK, - json!([]), - ); - let gh = setup_test_gh_api(&api); - - // Add labels - gh.add_labels(INST_ID, ORG, REPO, ISSUE_NUM, &["gitvote", "gitvote/open"]).await.unwrap(); - - // Check the request sent - let requests = api.requests(&Method::POST, "/repos/org/repo/issues/1/labels"); - assert_eq!(requests.len(), 1); - assert_eq!( - requests[0].body, - Some(json!({"labels": ["gitvote", "gitvote/open"]})) - ); -} - -#[tokio::test] -async fn gh_api_create_check_run_completed() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond(Method::GET, "/repos/org/repo/pulls/1", StatusCode::OK, pr_json()); - api.respond( - Method::POST, - "/repos/org/repo/check-runs", - StatusCode::CREATED, - json!({}), - ); - let gh = setup_test_gh_api(&api); - - // Create check run - let check_details = CheckDetails { - status: "completed".to_string(), - conclusion: Some("success".to_string()), - summary: "Vote passed".to_string(), - }; - gh.create_check_run(INST_ID, ORG, REPO, ISSUE_NUM, &check_details).await.unwrap(); - - // Check the request sent - let requests = api.requests(&Method::POST, "/repos/org/repo/check-runs"); - assert_eq!(requests.len(), 1); - assert_eq!( - requests[0].body, - Some(json!({ - "name": "GitVote", - "head_sha": "abc123", - "status": "completed", - "conclusion": "success", - "output": { - "title": "Vote passed", - "summary": "Vote passed" - } - })) - ); -} - -#[tokio::test] -async fn gh_api_create_check_run_in_progress() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond(Method::GET, "/repos/org/repo/pulls/1", StatusCode::OK, pr_json()); - api.respond( - Method::POST, - "/repos/org/repo/check-runs", - StatusCode::CREATED, - json!({}), - ); - let gh = setup_test_gh_api(&api); - - // Create check run - let check_details = CheckDetails { - status: "in_progress".to_string(), - conclusion: None, - summary: "Vote open".to_string(), - }; - gh.create_check_run(INST_ID, ORG, REPO, ISSUE_NUM, &check_details).await.unwrap(); - - // Check the request sent does not include a conclusion - let requests = api.requests(&Method::POST, "/repos/org/repo/check-runs"); - assert_eq!(requests.len(), 1); - assert_eq!( - requests[0].body, - Some(json!({ - "name": "GitVote", - "head_sha": "abc123", - "status": "in_progress", - "output": { - "title": "Vote open", - "summary": "Vote open" - } - })) - ); -} - -#[tokio::test] -async fn gh_api_create_check_run_pr_without_head() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::GET, - "/repos/org/repo/pulls/1", - StatusCode::OK, - json!({"number": 1}), - ); - let gh = setup_test_gh_api(&api); - - // Create check run - let check_details = CheckDetails { - status: "in_progress".to_string(), - conclusion: None, - summary: "Vote open".to_string(), - }; - let err = gh.create_check_run(INST_ID, ORG, REPO, ISSUE_NUM, &check_details).await.unwrap_err(); - - // Check the error returned and that no check run was created - assert_eq!(err.to_string(), "pull request response missing head"); - assert_eq!(api.requests(&Method::POST, "/repos/org/repo/check-runs"), vec![]); -} - -#[tokio::test] -async fn gh_api_create_discussion() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::POST, - "/graphql", - StatusCode::OK, - json!({"data": {"repository": {"id": "R_1", "discussionCategory": {"id": "C_1"}}}}), - ); - api.respond( - Method::POST, - "/graphql", - StatusCode::OK, - json!({"data": {"createDiscussion": {"discussion": {"id": "D_1"}}}}), - ); - let gh = setup_test_gh_api(&api); - - // Create discussion - gh.create_discussion(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, "title", "body") - .await - .unwrap(); - - // Check the repository query and the create discussion mutation sent - let requests = api.requests(&Method::POST, "/graphql"); - assert_eq!(requests.len(), 2); - assert_eq!( - requests[0].body.as_ref().unwrap()["variables"], - json!({"owner": ORG, "repo": REPO, "category": DISCUSSIONS_CATEGORY}) - ); - assert_eq!( - requests[1].body.as_ref().unwrap()["variables"], - json!({"repositoryId": "R_1", "categoryId": "C_1", "title": "title", "body": "body"}) - ); -} - -#[tokio::test] -async fn gh_api_create_discussion_category_not_found() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::POST, - "/graphql", - StatusCode::OK, - json!({"data": {"repository": {"id": "R_1", "discussionCategory": null}}}), - ); - let gh = setup_test_gh_api(&api); - - // Create discussion - let err = gh - .create_discussion(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, "title", "body") - .await - .unwrap_err(); - - // Check the error returned and that only the repository query was sent - assert_eq!( - err.to_string(), - "something went wrong while fetching repository details for announcement" - ); - let requests = api.requests(&Method::POST, "/graphql"); - assert_eq!(requests.len(), 1); - assert_eq!( - requests[0].body.as_ref().unwrap()["variables"], - json!({"owner": ORG, "repo": REPO, "category": DISCUSSIONS_CATEGORY}) - ); -} - -#[tokio::test] -async fn gh_api_create_discussion_error_creating_discussion() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::POST, - "/graphql", - StatusCode::OK, - json!({"data": {"repository": {"id": "R_1", "discussionCategory": {"id": "C_1"}}}}), - ); - api.respond( - Method::POST, - "/graphql", - StatusCode::OK, - json!({"data": null, "errors": [{"message": "Resource not accessible by integration"}]}), - ); - let gh = setup_test_gh_api(&api); - - // Create discussion and check the error is propagated - let err = gh - .create_discussion(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, "title", "body") - .await - .unwrap_err(); - assert_eq!(err.to_string(), "error creating announcement discussion"); - assert_eq!(api.requests(&Method::POST, "/graphql").len(), 2); -} - -#[tokio::test] -async fn gh_api_get_allowed_voters_deduplicates_users_case_insensitively() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::GET, - "/orgs/org/teams/team1/members", - StatusCode::OK, - json!([{"login": USER1}]), - ); - let gh = setup_test_gh_api(&api); - - // Get allowed voters - let cfg = CfgProfile { - allowed_voters: Some(AllowedVoters { - teams: Some(vec![TEAM1.to_string()]), - users: Some(vec![ - USER1.to_uppercase(), - USER2.to_string(), - USER2.to_uppercase(), - ]), - ..Default::default() - }), - ..Default::default() - }; - let allowed_voters = - gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); - - // Check allowed voters are deduplicated keeping the first spelling seen - assert_eq!(allowed_voters, vec![USER1.to_string(), USER2.to_string()]); -} - -#[tokio::test] -async fn gh_api_get_allowed_voters_excluding_team_maintainers() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::GET, - "/orgs/org/teams/team1/members", - StatusCode::OK, - json!([{"login": USER1}]), - ); - let gh = setup_test_gh_api(&api); - - // Get allowed voters - let cfg = CfgProfile { - allowed_voters: Some(AllowedVoters { - teams: Some(vec![TEAM1.to_string()]), - exclude_team_maintainers: Some(true), - ..Default::default() - }), - ..Default::default() - }; - let allowed_voters = - gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); - - // Check allowed voters and team members role requested - assert_eq!(allowed_voters, vec![USER1.to_string()]); - let requests = api.requests(&Method::GET, "/orgs/org/teams/team1/members"); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].query, Some("role=member".to_string())); -} - -#[tokio::test] -async fn gh_api_get_allowed_voters_falls_back_to_collaborators() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::GET, - "/repos/org/repo/collaborators", - StatusCode::OK, - json!([{"login": USER1}, {"login": USER2}]), - ); - let gh = setup_test_gh_api(&api); - - // Get allowed voters - let cfg = CfgProfile::default(); - let allowed_voters = - gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); - - // Check all collaborators are allowed to vote - assert_eq!(allowed_voters, vec![USER1.to_string(), USER2.to_string()]); -} - -#[tokio::test] -async fn gh_api_get_allowed_voters_falls_back_to_collaborators_when_teams_are_empty() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::GET, - "/orgs/org/teams/team1/members", - StatusCode::OK, - json!([]), - ); - api.respond( - Method::GET, - "/repos/org/repo/collaborators", - StatusCode::OK, - json!([{"login": USER1}]), - ); - let gh = setup_test_gh_api(&api); - - // Get allowed voters - let cfg = CfgProfile { - allowed_voters: Some(AllowedVoters { - teams: Some(vec![TEAM1.to_string()]), - ..Default::default() - }), - ..Default::default() - }; - let allowed_voters = - gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); - - // Check all collaborators are allowed to vote - assert_eq!(allowed_voters, vec![USER1.to_string()]); -} - -#[tokio::test] -async fn gh_api_get_allowed_voters_ignores_team_errors() { - // Setup mock GitHub API (team members request is not found) - let api = MockGitHubApi::start().await; - let gh = setup_test_gh_api(&api); - - // Get allowed voters - let cfg = CfgProfile { - allowed_voters: Some(AllowedVoters { - teams: Some(vec![TEAM1.to_string()]), - users: Some(vec![USER2.to_string()]), - ..Default::default() - }), - ..Default::default() - }; - let allowed_voters = - gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); - - // Check only configured users are allowed to vote - assert_eq!(allowed_voters, vec![USER2.to_string()]); - assert_eq!( - api.requests(&Method::GET, "/orgs/org/teams/team1/members").len(), - 1 - ); -} - -#[tokio::test] -async fn gh_api_get_allowed_voters_ignores_teams_without_org() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - let gh = setup_test_gh_api(&api); - - // Get allowed voters - let cfg = CfgProfile { - allowed_voters: Some(AllowedVoters { - teams: Some(vec![TEAM1.to_string()]), - users: Some(vec![USER2.to_string()]), - ..Default::default() - }), - ..Default::default() - }; - let allowed_voters = gh.get_allowed_voters(INST_ID, &cfg, OWNER, REPO, None).await.unwrap(); - - // Check teams were not requested - assert_eq!(allowed_voters, vec![USER2.to_string()]); - assert_eq!( - api.requests(&Method::GET, "/orgs/owner/teams/team1/members"), - vec![] - ); -} - -#[tokio::test] -async fn gh_api_get_allowed_voters_teams_and_users() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::GET, - "/orgs/org/teams/team1/members", - StatusCode::OK, - json!([{"login": USER1}, {"login": USER2}]), - ); - api.respond( - Method::GET, - "/orgs/org/teams/team2/members", - StatusCode::OK, - json!([{"login": USER2}, {"login": USER3}]), - ); - let gh = setup_test_gh_api(&api); - - // Get allowed voters - let cfg = CfgProfile { - allowed_voters: Some(AllowedVoters { - teams: Some(vec![TEAM1.to_string(), "team2".to_string()]), - users: Some(vec![USER3.to_string(), USER4.to_string()]), - ..Default::default() - }), - ..Default::default() - }; - let allowed_voters = - gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); - - // Check allowed voters are deduplicated and all team roles were requested - assert_eq!( - allowed_voters, - vec![ - USER1.to_string(), - USER2.to_string(), - USER3.to_string(), - USER4.to_string() - ] - ); - let requests = api.requests(&Method::GET, "/orgs/org/teams/team1/members"); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].query, Some("role=all".to_string())); -} - -#[tokio::test] -async fn gh_api_get_collaborators_all_pages() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond_with_next_page( - Method::GET, - "/repos/org/repo/collaborators", - json!([{"login": USER1}]), - "/collaborators-page-2", - ); - api.respond( - Method::GET, - "/collaborators-page-2", - StatusCode::OK, - json!([{"login": USER2}]), - ); - let gh = setup_test_gh_api(&api); - - // Get collaborators and check all pages were collected - assert_eq!( - gh.get_collaborators(INST_ID, ORG, REPO).await.unwrap(), - vec![USER1.to_string(), USER2.to_string()] - ); -} - -#[tokio::test] -async fn gh_api_get_comment_reactions() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::GET, - "/repos/org/repo/issues/comments/1234/reactions", - StatusCode::OK, - json!([{"user": {"login": USER1}, "content": "+1", "created_at": TIMESTAMP}]), - ); - let gh = setup_test_gh_api(&api); - - // Get reactions and check the result - assert_eq!( - gh.get_comment_reactions(INST_ID, ORG, REPO, COMMENT_ID).await.unwrap(), - vec![Reaction { - user: User { - login: USER1.to_string() - }, - content: "+1".to_string(), - created_at: TIMESTAMP.to_string(), - }] - ); -} - -#[tokio::test] -async fn gh_api_get_config_file_from_github_directory() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::GET, - "/repos/org/repo/contents/.github/.gitvote.yml", - StatusCode::OK, - content_json(".github/.gitvote.yml", "github-dir"), - ); - let gh = setup_test_gh_api(&api); - - // Get config file and check the content returned - assert_eq!( - gh.get_config_file(INST_ID, ORG, REPO).await, - Some("github-dir".to_string()) - ); - assert_eq!( - api.requests(&Method::GET, "/repos/org/.github/contents/.gitvote.yml"), - vec![] - ); -} - -#[tokio::test] -async fn gh_api_get_config_file_from_org_repository() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::GET, - "/repos/org/.github/contents/.gitvote.yml", - StatusCode::OK, - content_json(".gitvote.yml", "org-repo"), - ); - let gh = setup_test_gh_api(&api); - - // Get config file and check the content returned - assert_eq!( - gh.get_config_file(INST_ID, ORG, REPO).await, - Some("org-repo".to_string()) - ); -} - -#[tokio::test] -async fn gh_api_get_config_file_from_repository_root() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::GET, - "/repos/org/repo/contents/.gitvote.yml", - StatusCode::OK, - content_json(".gitvote.yml", "repo-root"), - ); - api.respond( - Method::GET, - "/repos/org/repo/contents/.github/.gitvote.yml", - StatusCode::OK, - content_json(".github/.gitvote.yml", "github-dir"), - ); - let gh = setup_test_gh_api(&api); - - // Get config file and check the repository root takes precedence - assert_eq!( - gh.get_config_file(INST_ID, ORG, REPO).await, - Some("repo-root".to_string()) - ); - assert_eq!( - api.requests(&Method::GET, "/repos/org/repo/contents/.github/.gitvote.yml"), - vec![] - ); -} - -#[tokio::test] -async fn gh_api_get_config_file_not_found() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - let gh = setup_test_gh_api(&api); - - // Get config file and check all locations were tried - assert_eq!(gh.get_config_file(INST_ID, ORG, REPO).await, None); - assert_eq!( - api.requests(&Method::GET, "/repos/org/repo/contents/.gitvote.yml").len(), - 1 - ); - assert_eq!( - api.requests(&Method::GET, "/repos/org/repo/contents/.github/.gitvote.yml").len(), - 1 - ); - assert_eq!( - api.requests(&Method::GET, "/repos/org/.github/contents/.gitvote.yml").len(), - 1 - ); -} - -#[tokio::test] -async fn gh_api_get_pr_files() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::GET, - "/repos/org/repo/pulls/1/files", - StatusCode::OK, - json!([{"filename": "README.md"}, {"filename": "src/main.rs"}]), - ); - let gh = setup_test_gh_api(&api); - - // Get pull request files and check the result - assert_eq!( - gh.get_pr_files(INST_ID, ORG, REPO, ISSUE_NUM).await.unwrap(), - vec![ - File { - filename: "README.md".to_string() - }, - File { - filename: "src/main.rs".to_string() - } - ] - ); -} - -#[tokio::test] -async fn gh_api_get_repository_installation_id() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::GET, - "/repos/org/repo/installation", - StatusCode::OK, - json!({"id": INST_ID, "account": author_json(ORG), "permissions": {}, "events": []}), - ); - let gh = setup_test_gh_api(&api); - - // Get installation id and check the result - assert_eq!( - gh.get_repository_installation_id(ORG, REPO).await.unwrap(), - INST_ID - ); -} - -#[tokio::test] -async fn gh_api_get_repository_installation_id_not_found() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - let gh = setup_test_gh_api(&api); - - // Get installation id and check a not found error is returned - let err = gh.get_repository_installation_id(ORG, REPO).await.unwrap_err(); - assert!(is_not_found_error(&err)); -} - -#[tokio::test] -async fn gh_api_is_check_required() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::GET, - "/repos/org/repo/branches/main", - StatusCode::OK, - json!({ - "name": BRANCH, - "protection": {"required_status_checks": {"contexts": ["ci", "GitVote"]}} - }), - ); - let gh = setup_test_gh_api(&api); - - // Check the GitVote check is required - assert!(gh.is_check_required(INST_ID, ORG, REPO, BRANCH).await.unwrap()); -} - -#[tokio::test] -async fn gh_api_is_check_required_other_checks_required() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::GET, - "/repos/org/repo/branches/main", - StatusCode::OK, - json!({ - "name": BRANCH, - "protection": {"required_status_checks": {"contexts": ["ci"]}} - }), - ); - let gh = setup_test_gh_api(&api); - - // Check the GitVote check is not required - assert!(!gh.is_check_required(INST_ID, ORG, REPO, BRANCH).await.unwrap()); -} - -#[tokio::test] -async fn gh_api_is_check_required_unprotected_branch() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::GET, - "/repos/org/repo/branches/main", - StatusCode::OK, - json!({"name": BRANCH, "protection": null}), - ); - let gh = setup_test_gh_api(&api); - - // Check the GitVote check is not required - assert!(!gh.is_check_required(INST_ID, ORG, REPO, BRANCH).await.unwrap()); -} - -#[tokio::test] -async fn gh_api_post_comment() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::POST, - "/repos/org/repo/issues/1/comments", - StatusCode::CREATED, - json!({ - "id": COMMENT_ID2, - "node_id": "IC_1", - "url": "https://api.github.com/repos/org/repo/issues/comments/5678", - "html_url": "https://github.com/org/repo/issues/1#issuecomment-5678", - "body": "comment", - "user": author_json(USER), - "created_at": TIMESTAMP - }), - ); - let gh = setup_test_gh_api(&api); - - // Post comment and check the comment id returned - assert_eq!( - gh.post_comment(INST_ID, ORG, REPO, ISSUE_NUM, "comment").await.unwrap(), - COMMENT_ID2 - ); - let requests = api.requests(&Method::POST, "/repos/org/repo/issues/1/comments"); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].body, Some(json!({"body": "comment"}))); -} - -#[tokio::test] -async fn gh_api_remove_label() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::DELETE, - "/repos/org/repo/issues/1/labels/gitvote%2Fopen", - StatusCode::OK, - json!([]), - ); - let gh = setup_test_gh_api(&api); - - // Remove label - gh.remove_label(INST_ID, ORG, REPO, ISSUE_NUM, "gitvote/open").await.unwrap(); - - // Check the request sent - assert_eq!( - api.requests(&Method::DELETE, "/repos/org/repo/issues/1/labels/gitvote%2Fopen").len(), - 1 - ); -} - -#[tokio::test] -async fn gh_api_remove_label_error() { - // Setup mock GitHub API (label request is not found) - let api = MockGitHubApi::start().await; - let gh = setup_test_gh_api(&api); - - // Remove label and check the error is propagated - let err = gh.remove_label(INST_ID, ORG, REPO, ISSUE_NUM, "gitvote/open").await.unwrap_err(); - assert!(is_not_found_error(&err)); -} - -#[tokio::test] -async fn gh_api_remove_label_that_does_not_exist() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::DELETE, - "/repos/org/repo/issues/1/labels/gitvote%2Fopen", - StatusCode::NOT_FOUND, - json!({"message": "Label does not exist"}), - ); - let gh = setup_test_gh_api(&api); - - // Remove label and check the error is ignored - gh.remove_label(INST_ID, ORG, REPO, ISSUE_NUM, "gitvote/open").await.unwrap(); -} - -#[tokio::test] -async fn gh_api_user_is_collaborator() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::GET, - "/repos/org/repo/collaborators/user", - StatusCode::NO_CONTENT, - Value::Null, - ); - let gh = setup_test_gh_api(&api); - - // Check the user is a collaborator - assert!(gh.user_is_collaborator(INST_ID, ORG, REPO, USER).await.unwrap()); -} - -#[tokio::test] -async fn gh_api_user_is_not_collaborator() { - // Setup mock GitHub API (collaborator request is not found) - let api = MockGitHubApi::start().await; - let gh = setup_test_gh_api(&api); - - // Check the user is not a collaborator - assert!(!gh.user_is_collaborator(INST_ID, ORG, REPO, USER).await.unwrap()); -} - -#[tokio::test] -async fn is_not_found_error_github_not_found() { - assert!(is_not_found_error(&setup_test_not_found_error().await)); -} - -#[tokio::test] -async fn is_not_found_error_other_github_error() { - // Setup mock GitHub API - let api = MockGitHubApi::start().await; - api.respond( - Method::GET, - "/invalid", - StatusCode::UNPROCESSABLE_ENTITY, - json!({"message": "Validation Failed"}), - ); - let client = Octocrab::builder().base_uri(api.base_uri()).unwrap().build().unwrap(); - - // Get GitHub error and check it is not a not found error - let err: Error = client.get::("/invalid", None).await.unwrap_err().into(); - assert!(!is_not_found_error(&err)); -} - -#[test] -fn is_not_found_error_other_error() { - assert!(!is_not_found_error(&format_err!("Not Found"))); -} - -#[test] -fn split_full_name_extra_parts_are_ignored() { - assert_eq!(split_full_name("org/repo/extra"), ("org", "repo")); -} - -#[test] -fn split_full_name_owner_and_repo() { - assert_eq!(split_full_name(REPOFN), (ORG, REPO)); -} - -// Helpers. - -/// Build a GitHub user JSON object for the login provided. -fn author_json(login: &str) -> Value { - let url = format!("https://api.github.com/users/{login}"); - json!({ - "login": login, - "id": 1, - "node_id": "U_1", - "avatar_url": url, - "gravatar_id": "", - "url": url, - "html_url": url, - "followers_url": url, - "following_url": url, - "gists_url": url, - "starred_url": url, - "subscriptions_url": url, - "organizations_url": url, - "repos_url": url, - "events_url": url, - "received_events_url": url, - "type": "User", - "site_admin": false - }) -} - -/// Build a GitHub file content JSON object for the path and content provided. -fn content_json(path: &str, content: &str) -> Value { - let url = format!("https://api.github.com/repos/org/repo/contents/{path}"); - json!({ - "name": ".gitvote.yml", - "path": path, - "sha": "sha", - "encoding": "base64", - "content": STANDARD.encode(content), - "size": content.len(), - "url": url, - "html_url": null, - "git_url": null, - "download_url": null, - "type": "file", - "_links": {"self": url}, - "license": null - }) -} - -/// Build a GitHub pull request JSON object. -fn pr_json() -> Value { - json!({ - "number": ISSUE_NUM, - "head": {"ref": "feature", "sha": "abc123"} - }) -} - -/// Setup a `GHApi` instance that uses the mock GitHub API provided. -fn setup_test_gh_api(api: &MockGitHubApi) -> GHApi { - // Setup installation token response - api.respond( - Method::POST, - &format!("/app/installations/{INST_ID}/access_tokens"), - StatusCode::CREATED, - json!({"token": "installation-token", "permissions": {}}), - ); - - // Setup application client - let app_client = Octocrab::builder() - .base_uri(api.base_uri()) - .unwrap() - .app(AppId(1), APP_PRIVATE_KEY.clone()) - .build() - .unwrap(); - GHApi::new(app_client) -} +mod check_runs; +mod collaborators; +mod comment_reactions; +mod comments; +mod config_file; +mod discussions; +mod event; +mod helpers; +mod installations; +mod labels; +mod module_functions; +mod pull_requests; +mod requests; + +/// Marker looked up in the tests. +const MARKER: &str = ""; + +/// Identifier of the test GitHub application. +const TEST_APP_ID: u64 = 1; diff --git a/src/github/tests/check_runs.rs b/src/github/tests/check_runs.rs new file mode 100644 index 0000000..9d2c798 --- /dev/null +++ b/src/github/tests/check_runs.rs @@ -0,0 +1,298 @@ +use axum::http::{Method, StatusCode}; +use serde_json::json; + +use crate::testutil::*; + +use super::*; + +/// Path of the requests creating check runs in the test repository. +const CHECK_RUNS_PATH: &str = "/repos/org/repo/check-runs"; + +#[tokio::test] +async fn gh_api_create_check_run_completed() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond(Method::GET, "/repos/org/repo/pulls/1", StatusCode::OK, pr_json()); + api.respond( + Method::POST, + "/repos/org/repo/check-runs", + StatusCode::CREATED, + json!({}), + ); + let gh = setup_test_gh_api(&api); + + // Create check run + let check_details = CheckDetails { + status: "completed".to_string(), + summary: "Vote passed".to_string(), + + conclusion: Some("success".to_string()), + }; + gh.create_check_run(INST_ID, ORG, REPO, ISSUE_NUM, &check_details).await.unwrap(); + + // Check the request sent + let requests = api.requests(&Method::POST, "/repos/org/repo/check-runs"); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].body, + Some(json!({ + "head_sha": "abc123", + "name": "GitVote", + "output": { + "summary": "Vote passed", + "title": "Vote passed" + }, + "status": "completed", + "conclusion": "success" + })) + ); +} + +#[tokio::test] +async fn gh_api_create_check_run_in_progress() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond(Method::GET, "/repos/org/repo/pulls/1", StatusCode::OK, pr_json()); + api.respond( + Method::POST, + "/repos/org/repo/check-runs", + StatusCode::CREATED, + json!({}), + ); + let gh = setup_test_gh_api(&api); + + // Create check run + let check_details = CheckDetails { + status: "in_progress".to_string(), + summary: "Vote open".to_string(), + + conclusion: None, + }; + gh.create_check_run(INST_ID, ORG, REPO, ISSUE_NUM, &check_details).await.unwrap(); + + // Check the request sent does not include a conclusion + let requests = api.requests(&Method::POST, "/repos/org/repo/check-runs"); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].body, + Some(json!({ + "head_sha": "abc123", + "name": "GitVote", + "output": { + "summary": "Vote open", + "title": "Vote open" + }, + "status": "in_progress" + })) + ); +} + +#[tokio::test] +async fn gh_api_create_check_run_is_retried_after_server_error() { + for status in [StatusCode::BAD_GATEWAY, StatusCode::TOO_MANY_REQUESTS] { + // Setup mock GitHub API, which accepts the check run when it is sent again + let api = MockGitHubApi::start().await; + api.respond(Method::GET, "/repos/org/repo/pulls/1", StatusCode::OK, pr_json()); + api.respond( + Method::POST, + CHECK_RUNS_PATH, + status, + json!({"message": "Failed"}), + ); + api.respond(Method::POST, CHECK_RUNS_PATH, StatusCode::CREATED, json!({})); + let gh = setup_test_gh_api(&api); + + // Create check run, checking the failure is not returned + let check_details = CheckDetails { + status: "in_progress".to_string(), + summary: "Vote open".to_string(), + + conclusion: None, + }; + gh.create_check_run(INST_ID, ORG, REPO, ISSUE_NUM, &check_details).await.unwrap(); + + // Check the check run was sent again + assert_eq!(api.requests(&Method::POST, CHECK_RUNS_PATH).len(), 2, "{status}"); + } +} + +#[tokio::test] +async fn gh_api_create_check_run_is_retried_after_token_server_error() { + // Setup mock GitHub API, whose first installation token request fails + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + &installation_token_path(), + StatusCode::BAD_GATEWAY, + json!({"message": "Failed"}), + ); + api.respond(Method::GET, "/repos/org/repo/pulls/1", StatusCode::OK, pr_json()); + api.respond(Method::POST, CHECK_RUNS_PATH, StatusCode::CREATED, json!({})); + let gh = setup_test_gh_api(&api); + + // Create check run + let check_details = CheckDetails { + status: "in_progress".to_string(), + summary: "Vote open".to_string(), + + conclusion: None, + }; + gh.create_check_run(INST_ID, ORG, REPO, ISSUE_NUM, &check_details).await.unwrap(); + + // Check the token was requested again, and that one token is used for the whole call + assert_eq!(api.requests(&Method::POST, &installation_token_path()).len(), 2); + assert_eq!(api.requests(&Method::POST, CHECK_RUNS_PATH).len(), 1); +} + +#[tokio::test] +async fn gh_api_create_check_run_pr_without_head() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/pulls/1", + StatusCode::OK, + json!({"number": 1}), + ); + let gh = setup_test_gh_api(&api); + + // Create check run + let check_details = CheckDetails { + status: "in_progress".to_string(), + summary: "Vote open".to_string(), + + conclusion: None, + }; + let err = gh.create_check_run(INST_ID, ORG, REPO, ISSUE_NUM, &check_details).await.unwrap_err(); + + // Check the error returned and that no check run was created + assert_eq!(err.to_string(), "pull request response missing head"); + assert_eq!(api.requests(&Method::POST, "/repos/org/repo/check-runs"), vec![]); +} + +#[tokio::test] +async fn gh_api_create_commit_check_run() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + "/repos/org/repo/check-runs", + StatusCode::CREATED, + json!({}), + ); + let gh = setup_test_gh_api(&api); + + // Create check run + let check_details = CheckDetails { + status: "completed".to_string(), + summary: "Vote passed".to_string(), + + conclusion: Some("success".to_string()), + }; + gh.create_commit_check_run(INST_ID, ORG, REPO, "def456", &check_details).await.unwrap(); + + // Check the request sent uses the commit provided, without reading the pull request + let requests = api.requests(&Method::POST, "/repos/org/repo/check-runs"); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].body, + Some(json!({ + "head_sha": "def456", + "name": "GitVote", + "output": { + "summary": "Vote passed", + "title": "Vote passed" + }, + "status": "completed", + "conclusion": "success" + })) + ); + assert_eq!(api.requests(&Method::GET, "/repos/org/repo/pulls/1"), vec![]); +} + +#[tokio::test] +async fn gh_api_is_check_required() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/branches/main", + StatusCode::OK, + json!({ + "name": BRANCH, + "protection": {"required_status_checks": {"contexts": ["ci", "GitVote"]}} + }), + ); + let gh = setup_test_gh_api(&api); + + // Check the GitVote check is required + assert!(gh.is_check_required(INST_ID, ORG, REPO, BRANCH).await.unwrap()); +} + +#[tokio::test] +async fn gh_api_is_check_required_encodes_branch() { + // Setup cases (branch, expected path) + let test_cases = [ + ("fix#1", "/repos/org/repo/branches/fix%231"), + ("fix?1", "/repos/org/repo/branches/fix%3F1"), + ("100%", "/repos/org/repo/branches/100%25"), + ("feat+añadir", "/repos/org/repo/branches/feat%2Ba%C3%B1adir"), + ("release/1.0", "/repos/org/repo/branches/release/1.0"), + ("user_a/fix-1~", "/repos/org/repo/branches/user_a/fix-1~"), + ]; + for (branch, path) in test_cases { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + path, + StatusCode::OK, + json!({ + "name": branch, + "protection": {"required_status_checks": {"contexts": ["GitVote"]}} + }), + ); + let gh = setup_test_gh_api(&api); + + // Check the branch requested is the one provided + assert!( + gh.is_check_required(INST_ID, ORG, REPO, branch).await.unwrap(), + "{branch}" + ); + } +} + +#[tokio::test] +async fn gh_api_is_check_required_other_checks_required() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/branches/main", + StatusCode::OK, + json!({ + "name": BRANCH, + "protection": {"required_status_checks": {"contexts": ["ci"]}} + }), + ); + let gh = setup_test_gh_api(&api); + + // Check the GitVote check is not required + assert!(!gh.is_check_required(INST_ID, ORG, REPO, BRANCH).await.unwrap()); +} + +#[tokio::test] +async fn gh_api_is_check_required_unprotected_branch() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/branches/main", + StatusCode::OK, + json!({"name": BRANCH, "protection": null}), + ); + let gh = setup_test_gh_api(&api); + + // Check the GitVote check is not required + assert!(!gh.is_check_required(INST_ID, ORG, REPO, BRANCH).await.unwrap()); +} diff --git a/src/github/tests/collaborators.rs b/src/github/tests/collaborators.rs new file mode 100644 index 0000000..63fc6e3 --- /dev/null +++ b/src/github/tests/collaborators.rs @@ -0,0 +1,618 @@ +use axum::http::{Method, StatusCode}; +use serde_json::{Value, json}; + +use crate::{ + cfg_repo::{AllowedVoters, CfgProfile}, + testutil::*, +}; + +use super::*; + +#[tokio::test] +async fn gh_api_get_allowed_voters_all_team_pages() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond_with_next_page( + Method::GET, + "/orgs/org/teams/team1/members", + json!([{"login": USER1}]), + "/members-page-2", + ); + api.respond( + Method::GET, + "/members-page-2", + StatusCode::OK, + json!([{"login": USER2}]), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = setup_test_teams_cfg(None); + let allowed_voters = + gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); + + // Check the members of all pages are allowed to vote + assert_eq!(allowed_voters, vec![USER1.to_string(), USER2.to_string()]); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_deduplicates_users_case_insensitively() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/orgs/org/teams/team1/members", + StatusCode::OK, + json!([{"login": USER1}]), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = CfgProfile { + allowed_voters: Some(AllowedVoters { + teams: Some(vec![TEAM1.to_string()]), + users: Some(vec![ + USER1.to_uppercase(), + USER2.to_string(), + USER2.to_uppercase(), + ]), + ..Default::default() + }), + ..Default::default() + }; + let allowed_voters = + gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); + + // Check allowed voters are deduplicated keeping the first spelling seen + assert_eq!(allowed_voters, vec![USER1.to_string(), USER2.to_string()]); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_does_not_fall_back_to_collaborators_when_teams_are_empty() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/orgs/org/teams/team1/members", + StatusCode::OK, + json!([]), + ); + api.respond( + Method::GET, + "/repos/org/repo/collaborators", + StatusCode::OK, + json!([{"login": USER1}]), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = setup_test_teams_cfg(None); + let allowed_voters = + gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); + + // Check the electorate was not widened to all collaborators + assert_eq!(allowed_voters, Vec::::new()); + assert_eq!( + api.requests(&Method::GET, "/repos/org/repo/collaborators"), + vec![] + ); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_does_not_fall_back_to_collaborators_when_teams_are_not_found() { + // Setup mock GitHub API (team members request is not found) + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/collaborators", + StatusCode::OK, + json!([{"login": USER1}]), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = setup_test_teams_cfg(None); + let allowed_voters = + gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); + + // Check the electorate was not widened to all collaborators + assert_eq!(allowed_voters, Vec::::new()); + assert_eq!( + api.requests(&Method::GET, "/repos/org/repo/collaborators"), + vec![] + ); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_does_not_fall_back_to_collaborators_with_only_users() { + for teams in [None, Some(vec![])] { + // Setup mock GitHub API, with collaborators other than the users listed + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/collaborators", + StatusCode::OK, + json!([{"login": USER3}]), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters of a profile listing users and no teams + let cfg = CfgProfile { + allowed_voters: Some(AllowedVoters { + teams: teams.clone(), + users: Some(vec![USER1.to_string(), USER2.to_string()]), + ..Default::default() + }), + ..Default::default() + }; + let allowed_voters = + gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); + + // Check only the users listed are allowed to vote, without asking for the + // collaborators + assert_eq!( + allowed_voters, + vec![USER1.to_string(), USER2.to_string()], + "{teams:?}" + ); + assert_eq!( + api.requests(&Method::GET, "/repos/org/repo/collaborators"), + vec![], + "{teams:?}" + ); + } +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_does_not_fall_back_to_collaborators_without_org() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/owner/repo/collaborators", + StatusCode::OK, + json!([{"login": USER1}]), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = setup_test_teams_cfg(None); + let allowed_voters = gh.get_allowed_voters(INST_ID, &cfg, OWNER, REPO, None).await.unwrap(); + + // Check the electorate was not widened to all collaborators + assert_eq!(allowed_voters, Vec::::new()); + assert_eq!( + api.requests(&Method::GET, "/repos/owner/repo/collaborators"), + vec![] + ); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_error_getting_installation_token() { + // Setup mock GitHub API, which returns a 404 for the first installation token + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + &installation_token_path(), + StatusCode::NOT_FOUND, + json!({"message": "Not Found"}), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters and check the failure is not taken for a missing team + let cfg = setup_test_teams_cfg(Some(vec![USER2.to_string()])); + let err = gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap_err(); + assert!(is_not_found_error(&err)); + assert!(!err.is::()); + + // Check the team members were not requested + assert_eq!( + api.requests(&Method::GET, "/orgs/org/teams/team1/members"), + vec![] + ); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_error_getting_next_team_page() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond_with_next_page( + Method::GET, + "/orgs/org/teams/team1/members", + json!([{"login": USER1}]), + "/members-page-2", + ); + api.respond( + Method::GET, + "/members-page-2", + StatusCode::FORBIDDEN, + json!({"message": "Forbidden"}), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters and check an incomplete team is an error + let cfg = setup_test_teams_cfg(None); + let err = gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap_err(); + assert!(format!("{err:#}").contains("Forbidden")); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_error_getting_team_members() { + // Setup cases (team members status, users allowed besides the team) + let test_cases = [ + (StatusCode::UNAUTHORIZED, None), + (StatusCode::FORBIDDEN, None), + (StatusCode::FORBIDDEN, Some(vec![USER2.to_string()])), + (StatusCode::INTERNAL_SERVER_ERROR, None), + (StatusCode::BAD_GATEWAY, None), + (StatusCode::BAD_GATEWAY, Some(vec![USER2.to_string()])), + ]; + for (status, users) in test_cases { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/orgs/org/teams/team1/members", + status, + json!({"message": "Team lookup failed"}), + ); + api.respond( + Method::GET, + "/repos/org/repo/collaborators", + StatusCode::OK, + json!([{"login": USER1}]), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = setup_test_teams_cfg(users.clone()); + let err = gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap_err(); + + // Check the failure is propagated without using the collaborators instead + assert!( + format!("{err:#}").contains("Team lookup failed"), + "{status}, {users:?}: {err:#}" + ); + assert_eq!( + api.requests(&Method::GET, "/repos/org/repo/collaborators"), + vec![], + "{status}, {users:?}" + ); + } +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_excluding_team_maintainers() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/orgs/org/teams/team1/members", + StatusCode::OK, + json!([{"login": USER1}]), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = CfgProfile { + allowed_voters: Some(AllowedVoters { + exclude_team_maintainers: Some(true), + teams: Some(vec![TEAM1.to_string()]), + ..Default::default() + }), + ..Default::default() + }; + let allowed_voters = + gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); + + // Check allowed voters and team members role requested + assert_eq!(allowed_voters, vec![USER1.to_string()]); + let requests = api.requests(&Method::GET, "/orgs/org/teams/team1/members"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].query, Some("role=member".to_string())); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_falls_back_to_collaborators() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/collaborators", + StatusCode::OK, + json!([{"login": USER1}, {"login": USER2}]), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = CfgProfile::default(); + let allowed_voters = + gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); + + // Check all collaborators are allowed to vote + assert_eq!(allowed_voters, vec![USER1.to_string(), USER2.to_string()]); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_falls_back_to_collaborators_when_lists_are_empty() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/collaborators", + StatusCode::OK, + json!([{"login": USER1}]), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = CfgProfile { + allowed_voters: Some(AllowedVoters { + teams: Some(vec![]), + users: Some(vec![]), + ..Default::default() + }), + ..Default::default() + }; + let allowed_voters = + gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); + + // Check all collaborators are allowed to vote + assert_eq!(allowed_voters, vec![USER1.to_string()]); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_ignores_teams_without_org() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = CfgProfile { + allowed_voters: Some(AllowedVoters { + teams: Some(vec![TEAM1.to_string()]), + users: Some(vec![USER2.to_string()]), + ..Default::default() + }), + ..Default::default() + }; + let allowed_voters = gh.get_allowed_voters(INST_ID, &cfg, OWNER, REPO, None).await.unwrap(); + + // Check teams were not requested + assert_eq!(allowed_voters, vec![USER2.to_string()]); + assert_eq!( + api.requests(&Method::GET, "/orgs/owner/teams/team1/members"), + vec![] + ); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_skips_teams_not_found() { + // Setup mock GitHub API (the first team members request is not found) + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/orgs/org/teams/team2/members", + StatusCode::OK, + json!([{"login": USER1}]), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = CfgProfile { + allowed_voters: Some(AllowedVoters { + teams: Some(vec![TEAM1.to_string(), "team2".to_string()]), + users: Some(vec![USER2.to_string()]), + ..Default::default() + }), + ..Default::default() + }; + let allowed_voters = + gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); + + // Check the other team and the configured users are allowed to vote + assert_eq!(allowed_voters, vec![USER1.to_string(), USER2.to_string()]); + assert_eq!( + api.requests(&Method::GET, "/orgs/org/teams/team1/members").len(), + 1 + ); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_skips_teams_with_invalid_slug() { + // Setup mock GitHub API (the encoded slug of the first team is not found) + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/orgs/org/teams/team2/members", + StatusCode::OK, + json!([{"login": USER1}]), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = CfgProfile { + allowed_voters: Some(AllowedVoters { + teams: Some(vec!["Core Maintainers/x".to_string(), "team2".to_string()]), + users: Some(vec![USER2.to_string()]), + ..Default::default() + }), + ..Default::default() + }; + let allowed_voters = + gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); + + // Check the slug was requested encoded and the other team and users are allowed + assert_eq!(allowed_voters, vec![USER1.to_string(), USER2.to_string()]); + assert_eq!( + api.requests(&Method::GET, "/orgs/org/teams/Core%20Maintainers%2Fx/members").len(), + 1 + ); +} + +#[tokio::test] +async fn gh_api_get_allowed_voters_teams_and_users() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/orgs/org/teams/team1/members", + StatusCode::OK, + json!([{"login": USER1}, {"login": USER2}]), + ); + api.respond( + Method::GET, + "/orgs/org/teams/team2/members", + StatusCode::OK, + json!([{"login": USER2}, {"login": USER3}]), + ); + let gh = setup_test_gh_api(&api); + + // Get allowed voters + let cfg = CfgProfile { + allowed_voters: Some(AllowedVoters { + teams: Some(vec![TEAM1.to_string(), "team2".to_string()]), + users: Some(vec![USER3.to_string(), USER4.to_string()]), + ..Default::default() + }), + ..Default::default() + }; + let allowed_voters = + gh.get_allowed_voters(INST_ID, &cfg, ORG, REPO, Some(&ORG.to_string())).await.unwrap(); + + // Check allowed voters are deduplicated and all team roles were requested + assert_eq!( + allowed_voters, + vec![ + USER1.to_string(), + USER2.to_string(), + USER3.to_string(), + USER4.to_string() + ] + ); + let requests = api.requests(&Method::GET, "/orgs/org/teams/team1/members"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].query, Some("role=all".to_string())); +} + +#[tokio::test] +async fn gh_api_get_collaborators_all_pages() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond_with_next_page( + Method::GET, + "/repos/org/repo/collaborators", + json!([{"login": USER1}]), + "/collaborators-page-2", + ); + api.respond( + Method::GET, + "/collaborators-page-2", + StatusCode::OK, + json!([{"login": USER2}]), + ); + let gh = setup_test_gh_api(&api); + + // Get collaborators and check all pages were collected + assert_eq!( + gh.get_collaborators(INST_ID, ORG, REPO).await.unwrap(), + vec![USER1.to_string(), USER2.to_string()] + ); +} + +#[tokio::test] +async fn gh_api_user_is_collaborator() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/collaborators/user", + StatusCode::NO_CONTENT, + Value::Null, + ); + let gh = setup_test_gh_api(&api); + + // Check the user is a collaborator + assert!(gh.user_is_collaborator(INST_ID, ORG, REPO, USER).await.unwrap()); +} + +#[tokio::test] +async fn gh_api_user_is_collaborator_error() { + // Setup cases (status, expected error detail, expected requests) + let test_cases = [ + (StatusCode::OK, "unexpected status", 1), + (StatusCode::UNAUTHORIZED, "Lookup failed", 1), + (StatusCode::FORBIDDEN, "Lookup failed", 1), + (StatusCode::INTERNAL_SERVER_ERROR, "Lookup failed", 4), + (StatusCode::BAD_GATEWAY, "Lookup failed", 4), + ]; + for (status, expected_err, expected_requests) in test_cases { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/collaborators/user", + status, + json!({"message": "Lookup failed"}), + ); + let gh = setup_test_gh_api(&api); + + // Check the failure is not taken for the user not being a collaborator + let err = gh.user_is_collaborator(INST_ID, ORG, REPO, USER).await.unwrap_err(); + assert!(format!("{err:#}").contains(expected_err), "{status}: {err:#}"); + + // Check the requests sent, as the client retries server errors + assert_eq!( + api.requests(&Method::GET, "/repos/org/repo/collaborators/user").len(), + expected_requests, + "{status}" + ); + } +} + +#[tokio::test] +async fn gh_api_user_is_collaborator_token_not_found() { + // Setup mock GitHub API, which returns a 404 for the first installation token + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + &installation_token_path(), + StatusCode::NOT_FOUND, + json!({"message": "Not Found"}), + ); + let gh = setup_test_gh_api(&api); + + // Check the failure is not taken for the user not being a collaborator + let err = gh.user_is_collaborator(INST_ID, ORG, REPO, USER).await.unwrap_err(); + assert!(is_not_found_error(&err)); + + // Check the collaborator was not requested + assert_eq!( + api.requests(&Method::GET, "/repos/org/repo/collaborators/user"), + vec![] + ); +} + +#[tokio::test] +async fn gh_api_user_is_not_collaborator() { + // Setup mock GitHub API (collaborator request is not found) + let api = MockGitHubApi::start().await; + let gh = setup_test_gh_api(&api); + + // Check the user is not a collaborator + assert!(!gh.user_is_collaborator(INST_ID, ORG, REPO, USER).await.unwrap()); +} + +// Helpers. + +/// Sets up a configuration profile listing `TEAM1` and the users provided. +fn setup_test_teams_cfg(users: Option>) -> CfgProfile { + CfgProfile { + allowed_voters: Some(AllowedVoters { + teams: Some(vec![TEAM1.to_string()]), + users, + ..Default::default() + }), + ..Default::default() + } +} diff --git a/src/github/tests/comment_reactions.rs b/src/github/tests/comment_reactions.rs new file mode 100644 index 0000000..3c966ab --- /dev/null +++ b/src/github/tests/comment_reactions.rs @@ -0,0 +1,176 @@ +use axum::http::{Method, StatusCode}; +use serde_json::json; + +use crate::testutil::*; + +use super::*; + +#[tokio::test] +async fn gh_api_get_comment_reactions() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/issues/comments/3003/reactions", + StatusCode::OK, + json!([{"content": "+1", "created_at": TIMESTAMP, "user": {"login": USER1}}]), + ); + let gh = setup_test_gh_api(&api); + + // Get reactions and check the result + assert_eq!( + gh.get_comment_reactions(INST_ID, ORG, REPO, COMMENT_ID).await.unwrap(), + vec![Reaction { + content: "+1".to_string(), + created_at: TIMESTAMP.to_string(), + user: User { + login: USER1.to_string() + }, + }] + ); + + // Check the installation token was only requested once + let token_path = installation_token_path(); + assert_eq!(api.requests(&Method::POST, &token_path).len(), 1); +} + +#[tokio::test] +async fn gh_api_get_comment_reactions_all_pages() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond_with_next_page( + Method::GET, + "/repos/org/repo/issues/comments/3003/reactions", + json!([{"content": "+1", "created_at": TIMESTAMP, "user": {"login": USER1}}]), + "/reactions-page-2", + ); + api.respond( + Method::GET, + "/reactions-page-2", + StatusCode::OK, + json!([{"content": "-1", "created_at": TIMESTAMP, "user": {"login": USER2}}]), + ); + let gh = setup_test_gh_api(&api); + + // Get reactions and check all pages were collected in order + assert_eq!( + gh.get_comment_reactions(INST_ID, ORG, REPO, COMMENT_ID).await.unwrap(), + vec![ + Reaction { + content: "+1".to_string(), + created_at: TIMESTAMP.to_string(), + user: User { + login: USER1.to_string() + }, + }, + Reaction { + content: "-1".to_string(), + created_at: TIMESTAMP.to_string(), + user: User { + login: USER2.to_string() + }, + } + ] + ); +} + +#[tokio::test] +async fn gh_api_get_comment_reactions_comment_not_found() { + // Setup mock GitHub API, which returns a 404 for the reactions + let api = MockGitHubApi::start().await; + let gh = setup_test_gh_api(&api); + + // Get reactions and check the comment is reported as not found + let err = gh.get_comment_reactions(INST_ID, ORG, REPO, COMMENT_ID).await.unwrap_err(); + assert!(err.is::()); +} + +#[tokio::test] +async fn gh_api_get_comment_reactions_comment_not_found_on_next_page() { + // Setup mock GitHub API, which returns a 404 for the second page of reactions + let api = MockGitHubApi::start().await; + api.respond_with_next_page( + Method::GET, + "/repos/org/repo/issues/comments/3003/reactions", + json!([{"content": "+1", "created_at": TIMESTAMP, "user": {"login": USER1}}]), + "/reactions-page-2", + ); + let gh = setup_test_gh_api(&api); + + // Get reactions and check the comment is reported as not found + let err = gh.get_comment_reactions(INST_ID, ORG, REPO, COMMENT_ID).await.unwrap_err(); + assert!(err.is::()); +} + +#[tokio::test] +async fn gh_api_get_comment_reactions_other_error() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/issues/comments/3003/reactions", + StatusCode::FORBIDDEN, + json!({"message": "Forbidden"}), + ); + let gh = setup_test_gh_api(&api); + + // Get reactions and check the error is the one returned by GitHub, which does not + // say the comment does not exist + let err = gh.get_comment_reactions(INST_ID, ORG, REPO, COMMENT_ID).await.unwrap_err(); + assert!(format!("{err:#}").contains("Forbidden"), "{err:#}"); + assert!(!err.is::()); + + // Check the reactions were requested just once + let reactions_path = "/repos/org/repo/issues/comments/3003/reactions"; + assert_eq!(api.requests(&Method::GET, reactions_path).len(), 1); +} + +#[tokio::test] +async fn gh_api_get_comment_reactions_skips_reactions_without_user() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/issues/comments/3003/reactions", + StatusCode::OK, + json!([ + {"content": "-1", "created_at": TIMESTAMP, "user": null}, + {"content": "+1", "created_at": TIMESTAMP, "user": {"login": USER1}} + ]), + ); + let gh = setup_test_gh_api(&api); + + // Get reactions and check only the one with a user is returned + assert_eq!( + gh.get_comment_reactions(INST_ID, ORG, REPO, COMMENT_ID).await.unwrap(), + vec![Reaction { + content: "+1".to_string(), + created_at: TIMESTAMP.to_string(), + user: User { + login: USER1.to_string() + }, + }] + ); +} + +#[tokio::test] +async fn gh_api_get_comment_reactions_token_not_found() { + // Setup mock GitHub API, which returns a 404 for the first installation token + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + &installation_token_path(), + StatusCode::NOT_FOUND, + json!({"message": "Not Found"}), + ); + let gh = setup_test_gh_api(&api); + + // Get reactions and check the error does not say the comment does not exist + let err = gh.get_comment_reactions(INST_ID, ORG, REPO, COMMENT_ID).await.unwrap_err(); + assert!(is_not_found_error(&err)); + assert!(!err.is::()); + + // Check the reactions were not requested + let reactions_path = "/repos/org/repo/issues/comments/3003/reactions"; + assert_eq!(api.requests(&Method::GET, reactions_path).len(), 0); +} diff --git a/src/github/tests/comments.rs b/src/github/tests/comments.rs new file mode 100644 index 0000000..a024a54 --- /dev/null +++ b/src/github/tests/comments.rs @@ -0,0 +1,389 @@ +use axum::http::{Method, StatusCode}; +use serde_json::{Value, json}; + +use crate::testutil::*; + +use super::*; + +/// Path of the requests creating comments in the test issue. +const COMMENTS_PATH: &str = "/repos/org/repo/issues/1/comments"; + +#[tokio::test] +async fn gh_api_delete_comment() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::DELETE, + "/repos/org/repo/issues/comments/3003", + StatusCode::NO_CONTENT, + json!(null), + ); + let gh = setup_test_gh_api(&api); + + // Delete comment and check the request + gh.delete_comment(INST_ID, ORG, REPO, COMMENT_ID).await.unwrap(); + assert_eq!( + api.requests(&Method::DELETE, "/repos/org/repo/issues/comments/3003").len(), + 1 + ); +} + +#[tokio::test] +async fn gh_api_delete_comment_error() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::DELETE, + "/repos/org/repo/issues/comments/3003", + StatusCode::FORBIDDEN, + json!({"message": "Forbidden"}), + ); + let gh = setup_test_gh_api(&api); + + // Delete comment and check the error is propagated + let err = gh.delete_comment(INST_ID, ORG, REPO, COMMENT_ID).await.unwrap_err(); + assert!(format!("{err:#}").contains("Forbidden")); +} + +#[tokio::test] +async fn gh_api_delete_comment_is_retried_after_server_error() { + // Setup mock GitHub API, which accepts the deletion when it is sent again + let path = "/repos/org/repo/issues/comments/3003"; + let api = MockGitHubApi::start().await; + api.respond( + Method::DELETE, + path, + StatusCode::BAD_GATEWAY, + json!({"message": "Failed"}), + ); + api.respond(Method::DELETE, path, StatusCode::NO_CONTENT, json!(null)); + let gh = setup_test_gh_api(&api); + + // Check the failure is not returned and the deletion was sent again + gh.delete_comment(INST_ID, ORG, REPO, COMMENT_ID).await.unwrap(); + assert_eq!(api.requests(&Method::DELETE, path).len(), 2); +} + +#[tokio::test] +async fn gh_api_has_comment_with_marker() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + COMMENTS_PATH, + StatusCode::OK, + json!([ + issue_comment_json("comment", Some(TEST_APP_ID)), + issue_comment_json(&format!("results\n{MARKER}\n"), Some(TEST_APP_ID)), + ]), + ); + let gh = setup_test_gh_api(&api); + + // Check the comment is found, ignoring the whitespace after the marker + let since = OffsetDateTime::parse("2022-11-30T10:00:00.5Z", &Rfc3339).unwrap(); + assert!(gh.has_comment_with_marker(INST_ID, ORG, REPO, ISSUE_NUM, MARKER, since).await.unwrap()); + + // Check the comments were listed since the time provided, in whole seconds + let requests = api.requests(&Method::GET, COMMENTS_PATH); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].authorization, + Some(format!("Bearer {INSTALLATION_TOKEN}")) + ); + assert_eq!( + requests[0].query.as_deref(), + Some("per_page=100&since=2022-11-30T10%3A00%3A00Z") + ); +} + +#[tokio::test] +async fn gh_api_has_comment_with_marker_all_pages() { + // Setup mock GitHub API, with the comment on the second page + let api = MockGitHubApi::start().await; + api.respond_with_next_page( + Method::GET, + COMMENTS_PATH, + json!([issue_comment_json("comment", Some(TEST_APP_ID))]), + "/comments-page-2", + ); + api.respond( + Method::GET, + "/comments-page-2", + StatusCode::OK, + json!([issue_comment_json(MARKER, Some(TEST_APP_ID))]), + ); + let gh = setup_test_gh_api(&api); + + // Check the comment is found + let since = OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(); + assert!(gh.has_comment_with_marker(INST_ID, ORG, REPO, ISSUE_NUM, MARKER, since).await.unwrap()); + + // Check the second page was requested once + assert_eq!(api.requests(&Method::GET, "/comments-page-2").len(), 1); +} + +#[tokio::test] +async fn gh_api_has_comment_with_marker_error_on_next_page() { + // Setup mock GitHub API, which fails on the second page + let api = MockGitHubApi::start().await; + api.respond_with_next_page( + Method::GET, + COMMENTS_PATH, + json!([issue_comment_json("comment", Some(TEST_APP_ID))]), + "/comments-page-2", + ); + api.respond( + Method::GET, + "/comments-page-2", + StatusCode::FORBIDDEN, + json!({"message": "Forbidden"}), + ); + let gh = setup_test_gh_api(&api); + + // Check the failure is not taken for the comment not being found + let since = OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(); + let err = gh + .has_comment_with_marker(INST_ID, ORG, REPO, ISSUE_NUM, MARKER, since) + .await + .unwrap_err(); + assert_eq!(err.to_string(), "error listing issue comments"); + assert!(format!("{err:#}").contains("Forbidden"), "{err:#}"); +} + +#[tokio::test] +async fn gh_api_has_comment_with_marker_errors() { + // Setup cases (status, expected requests) + let test_cases = [ + (StatusCode::FORBIDDEN, 1), + (StatusCode::NOT_FOUND, 1), + (StatusCode::BAD_GATEWAY, 4), + ]; + for (status, expected_requests) in test_cases { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + COMMENTS_PATH, + status, + json!({"message": "Lookup failed"}), + ); + let gh = setup_test_gh_api(&api); + + // Check the failure is not taken for the comment not being found + let since = OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(); + let err = gh + .has_comment_with_marker(INST_ID, ORG, REPO, ISSUE_NUM, MARKER, since) + .await + .unwrap_err(); + assert_eq!(err.to_string(), "error listing issue comments", "{status}"); + assert!(format!("{err:#}").contains("Lookup failed"), "{status}: {err:#}"); + + // Check the requests sent, as the client retries server errors + assert_eq!( + api.requests(&Method::GET, COMMENTS_PATH).len(), + expected_requests, + "{status}" + ); + } +} + +#[tokio::test] +async fn gh_api_has_comment_with_marker_ignores_comments_not_posted_by_the_app() { + // Setup mock GitHub API, with the marker in comments posted by a user and by another + // application + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + COMMENTS_PATH, + StatusCode::OK, + json!([ + issue_comment_json(MARKER, None), + issue_comment_json(MARKER, Some(TEST_APP_ID + 1)), + ]), + ); + let gh = setup_test_gh_api(&api); + + // Check the comment is not found + let since = OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(); + assert!(!gh.has_comment_with_marker(INST_ID, ORG, REPO, ISSUE_NUM, MARKER, since).await.unwrap()); +} + +#[tokio::test] +async fn gh_api_has_comment_with_marker_ignores_marker_not_at_the_end() { + // Setup mock GitHub API, with the marker in the middle of a comment of the app, as + // when an issue title with the marker is quoted in the vote created comment + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + COMMENTS_PATH, + StatusCode::OK, + json!([issue_comment_json( + &format!("Vote created for `{MARKER}`\n\nPlease vote"), + Some(TEST_APP_ID) + )]), + ); + let gh = setup_test_gh_api(&api); + + // Check the comment is not found + let since = OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(); + assert!(!gh.has_comment_with_marker(INST_ID, ORG, REPO, ISSUE_NUM, MARKER, since).await.unwrap()); + + // Check the comments were listed once + assert_eq!(api.requests(&Method::GET, COMMENTS_PATH).len(), 1); +} + +#[tokio::test] +async fn gh_api_has_comment_with_marker_not_found() { + // Setup mock GitHub API, with comments of the app without the marker or without body + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + COMMENTS_PATH, + StatusCode::OK, + json!([ + issue_comment_json("comment", Some(TEST_APP_ID)), + {"body": null, "performed_via_github_app": {"id": TEST_APP_ID}}, + ]), + ); + let gh = setup_test_gh_api(&api); + + // Check the comment is not found + let since = OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(); + assert!(!gh.has_comment_with_marker(INST_ID, ORG, REPO, ISSUE_NUM, MARKER, since).await.unwrap()); + + // Check the comments were listed once + assert_eq!(api.requests(&Method::GET, COMMENTS_PATH).len(), 1); +} + +#[tokio::test] +async fn gh_api_has_comment_with_marker_stops_at_the_page_with_it() { + // Setup mock GitHub API, with the comment on the first page of two + let api = MockGitHubApi::start().await; + api.respond_with_next_page( + Method::GET, + COMMENTS_PATH, + json!([issue_comment_json(MARKER, Some(TEST_APP_ID))]), + "/comments-page-2", + ); + let gh = setup_test_gh_api(&api); + + // Check the comment is found without getting the second page + let since = OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(); + assert!(gh.has_comment_with_marker(INST_ID, ORG, REPO, ISSUE_NUM, MARKER, since).await.unwrap()); + assert_eq!(api.requests(&Method::GET, "/comments-page-2"), vec![]); +} + +#[tokio::test] +async fn gh_api_post_comment() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + "/repos/org/repo/issues/1/comments", + StatusCode::CREATED, + comment_json(), + ); + let gh = setup_test_gh_api(&api); + + // Post comment and check the comment id returned + assert_eq!( + gh.post_comment(INST_ID, ORG, REPO, ISSUE_NUM, "comment").await.unwrap(), + COMMENT_ID2 + ); + + // Check the comment sent + let requests = api.requests(&Method::POST, "/repos/org/repo/issues/1/comments"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].body, Some(json!({"body": "comment"}))); +} + +#[tokio::test] +async fn gh_api_post_comment_is_not_resent_after_server_error() { + // Setup cases (status) + let test_cases = [ + StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::BAD_GATEWAY, + StatusCode::TOO_MANY_REQUESTS, + ]; + for status in test_cases { + // Setup mock GitHub API, which would accept the comment if it was sent again + let api = MockGitHubApi::start().await; + api.respond(Method::POST, COMMENTS_PATH, status, json!({"message": "Failed"})); + api.respond(Method::POST, COMMENTS_PATH, StatusCode::CREATED, comment_json()); + let gh = setup_test_gh_api(&api); + + // Check the failure returned is the one from GitHub, and the comment was sent + // just once + let err = gh.post_comment(INST_ID, ORG, REPO, ISSUE_NUM, "comment").await.unwrap_err(); + assert!(format!("{err:#}").contains("Failed"), "{status}: {err:#}"); + assert_eq!(api.requests(&Method::POST, COMMENTS_PATH).len(), 1, "{status}"); + } +} + +#[tokio::test(start_paused = true)] +async fn gh_api_post_comment_is_not_resent_after_timeout() { + // Setup mock GitHub API, which would accept the comment if it was sent again + let api = MockGitHubApi::start().await; + api.stall(Method::POST, COMMENTS_PATH); + api.respond(Method::POST, COMMENTS_PATH, StatusCode::CREATED, comment_json()); + let gh = setup_test_gh_api_with_short_timeouts(&api); + + // Check the failure returned is the timeout, and the comment was sent just once + let err = gh.post_comment(INST_ID, ORG, REPO, ISSUE_NUM, "comment").await.unwrap_err(); + assert_timeout_error(&err); + assert_eq!(api.requests(&Method::POST, COMMENTS_PATH).len(), 1); +} + +#[tokio::test] +async fn gh_api_post_comment_uses_installation_token() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + "/repos/org/repo/issues/1/comments", + StatusCode::FORBIDDEN, + json!({"message": "Forbidden"}), + ); + let gh = setup_test_gh_api(&api); + + // Post comment, whose response is not relevant here + gh.post_comment(INST_ID, ORG, REPO, ISSUE_NUM, "comment").await.unwrap_err(); + + // Check the token was requested with the application JWT + let token_path = installation_token_path(); + let token_requests = api.requests(&Method::POST, &token_path); + assert_eq!(token_requests.len(), 1); + let authorization = token_requests[0].authorization.as_ref().unwrap(); + assert!(authorization.starts_with("Bearer ey")); + + // Check the comment was posted with the installation token + let requests = api.requests(&Method::POST, "/repos/org/repo/issues/1/comments"); + assert_eq!( + requests[0].authorization, + Some(format!("Bearer {INSTALLATION_TOKEN}")) + ); +} + +// Helpers. + +/// Builds a GitHub comment JSON object. +fn comment_json() -> Value { + json!({ + "id": COMMENT_ID2, + "node_id": "IC_1", + "url": "https://api.github.com/repos/org/repo/issues/comments/5678", + "html_url": "https://github.com/org/repo/issues/1#issuecomment-5678", + "body": "comment", + "user": author_json(USER), + "created_at": TIMESTAMP + }) +} + +/// Builds a GitHub issue comment JSON object with the body provided, posted by the +/// application given (if any), including only the fields the client reads. +fn issue_comment_json(body: &str, app_id: Option) -> Value { + json!({ + "body": body, + "performed_via_github_app": app_id.map(|id| json!({"id": id, "slug": "gitvote"})) + }) +} diff --git a/src/github/tests/config_file.rs b/src/github/tests/config_file.rs new file mode 100644 index 0000000..526b7b3 --- /dev/null +++ b/src/github/tests/config_file.rs @@ -0,0 +1,263 @@ +use axum::http::{Method, StatusCode}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use serde_json::{Value, json}; + +use crate::testutil::*; + +use super::*; + +#[tokio::test] +async fn gh_api_get_config_file_error_stops_the_lookup() { + // Setup cases (failing path, status, expected location in the error) + let test_cases = [ + ( + "/repos/org/repo/contents/.gitvote.yml", + StatusCode::FORBIDDEN, + "org/repo/.gitvote.yml", + ), + ( + "/repos/org/repo/contents/.gitvote.yml", + StatusCode::INTERNAL_SERVER_ERROR, + "org/repo/.gitvote.yml", + ), + ( + "/repos/org/repo/contents/.github/.gitvote.yml", + StatusCode::SERVICE_UNAVAILABLE, + "org/repo/.github/.gitvote.yml", + ), + ( + "/repos/org/.github/contents/.gitvote.yml", + StatusCode::SERVICE_UNAVAILABLE, + "org/.github/.gitvote.yml", + ), + ]; + for (failing_path, status, location) in test_cases { + // Setup mock GitHub API, keeping the org config when it is not the one failing + let api = MockGitHubApi::start().await; + let org_path = "/repos/org/.github/contents/.gitvote.yml"; + if failing_path != org_path { + api.respond( + Method::GET, + org_path, + StatusCode::OK, + content_json(".gitvote.yml", "org-repo"), + ); + } + api.respond( + Method::GET, + failing_path, + status, + json!({"message": "Lookup failed"}), + ); + let gh = setup_test_gh_api(&api); + + // Get config file and check the failure is not taken for a missing file + let err = gh.get_config_file(INST_ID, ORG, REPO).await.unwrap_err(); + assert_eq!( + err.to_string(), + format!("error getting {location}"), + "{failing_path}" + ); + assert!(format!("{err:#}").contains("Lookup failed"), "{failing_path}"); + + // Check the org config was not used instead + if failing_path != org_path { + assert_eq!(api.requests(&Method::GET, org_path), vec![], "{failing_path}"); + } + } +} + +#[tokio::test] +async fn gh_api_get_config_file_from_github_directory() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/contents/.github/.gitvote.yml", + StatusCode::OK, + content_json(".github/.gitvote.yml", "github-dir"), + ); + let gh = setup_test_gh_api(&api); + + // Get config file and check the content returned + assert_eq!( + gh.get_config_file(INST_ID, ORG, REPO).await.unwrap(), + Some("github-dir".to_string()) + ); + + // Check the org repository was not requested + assert_eq!( + api.requests(&Method::GET, "/repos/org/.github/contents/.gitvote.yml"), + vec![] + ); +} + +#[tokio::test] +async fn gh_api_get_config_file_from_org_repository() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/.github/contents/.gitvote.yml", + StatusCode::OK, + content_json(".gitvote.yml", "org-repo"), + ); + let gh = setup_test_gh_api(&api); + + // Get config file and check the content returned + assert_eq!( + gh.get_config_file(INST_ID, ORG, REPO).await.unwrap(), + Some("org-repo".to_string()) + ); +} + +#[tokio::test] +async fn gh_api_get_config_file_from_repository_root() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/contents/.gitvote.yml", + StatusCode::OK, + content_json(".gitvote.yml", "repo-root"), + ); + api.respond( + Method::GET, + "/repos/org/repo/contents/.github/.gitvote.yml", + StatusCode::OK, + content_json(".github/.gitvote.yml", "github-dir"), + ); + let gh = setup_test_gh_api(&api); + + // Get config file and check the repository root takes precedence + assert_eq!( + gh.get_config_file(INST_ID, ORG, REPO).await.unwrap(), + Some("repo-root".to_string()) + ); + + // Check the .github directory was not requested + assert_eq!( + api.requests(&Method::GET, "/repos/org/repo/contents/.github/.gitvote.yml"), + vec![] + ); +} + +#[tokio::test] +async fn gh_api_get_config_file_moves_to_next_location_on_any_404() { + // Setup mock GitHub API, using the 404 GitHub returns for empty repositories + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/contents/.gitvote.yml", + StatusCode::NOT_FOUND, + json!({"message": "This repository is empty."}), + ); + api.respond( + Method::GET, + "/repos/org/.github/contents/.gitvote.yml", + StatusCode::OK, + content_json(".gitvote.yml", "org-repo"), + ); + let gh = setup_test_gh_api(&api); + + // Get config file and check the content returned + assert_eq!( + gh.get_config_file(INST_ID, ORG, REPO).await.unwrap(), + Some("org-repo".to_string()) + ); +} + +#[tokio::test] +async fn gh_api_get_config_file_not_found() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + let gh = setup_test_gh_api(&api); + + // Get config file and check all locations were tried + assert_eq!(gh.get_config_file(INST_ID, ORG, REPO).await.unwrap(), None); + assert_eq!( + api.requests(&Method::GET, "/repos/org/repo/contents/.gitvote.yml").len(), + 1 + ); + assert_eq!( + api.requests(&Method::GET, "/repos/org/repo/contents/.github/.gitvote.yml").len(), + 1 + ); + assert_eq!( + api.requests(&Method::GET, "/repos/org/.github/contents/.gitvote.yml").len(), + 1 + ); +} + +#[tokio::test] +async fn gh_api_get_config_file_skips_directories() { + // Setup mock GitHub API, listing a directory where the file is expected + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/contents/.gitvote.yml", + StatusCode::OK, + json!([ + content_json(".gitvote.yml/first", "first"), + content_json(".gitvote.yml/second", "second") + ]), + ); + api.respond( + Method::GET, + "/repos/org/repo/contents/.github/.gitvote.yml", + StatusCode::OK, + content_json(".github/.gitvote.yml", "github-dir"), + ); + let gh = setup_test_gh_api(&api); + + // Check the next location is used + assert_eq!( + gh.get_config_file(INST_ID, ORG, REPO).await.unwrap(), + Some("github-dir".to_string()) + ); +} + +#[tokio::test] +async fn gh_api_get_config_file_token_not_found() { + // Setup mock GitHub API, which returns a 404 for the first installation token + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + &installation_token_path(), + StatusCode::NOT_FOUND, + json!({"message": "Not Found"}), + ); + let gh = setup_test_gh_api(&api); + + // Get config file and check the failure is not taken for a missing file + let err = gh.get_config_file(INST_ID, ORG, REPO).await.unwrap_err(); + assert!(is_not_found_error(&err)); + + // Check the config file was not requested + assert_eq!( + api.requests(&Method::GET, "/repos/org/repo/contents/.gitvote.yml"), + vec![] + ); +} + +// Helpers. + +/// Builds a GitHub file content JSON object for the path and content provided. +fn content_json(path: &str, content: &str) -> Value { + let url = format!("https://api.github.com/repos/org/repo/contents/{path}"); + json!({ + "name": ".gitvote.yml", + "path": path, + "sha": "sha", + "encoding": "base64", + "content": STANDARD.encode(content), + "size": content.len(), + "url": url, + "html_url": null, + "git_url": null, + "download_url": null, + "type": "file", + "_links": {"self": url}, + "license": null + }) +} diff --git a/src/github/tests/discussions.rs b/src/github/tests/discussions.rs new file mode 100644 index 0000000..898c5e9 --- /dev/null +++ b/src/github/tests/discussions.rs @@ -0,0 +1,543 @@ +use axum::http::{Method, StatusCode}; +use serde_json::{Value, json}; + +use crate::testutil::*; + +use super::*; + +#[tokio::test] +async fn gh_api_create_discussion() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + json!({"data": {"repository": {"id": "R_1", "discussionCategory": {"id": "C_1"}}}}), + ); + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + json!({"data": {"createDiscussion": {"discussion": {"id": "D_1"}}}}), + ); + let gh = setup_test_gh_api(&api); + + // Create discussion + gh.create_discussion(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, "title", "body") + .await + .unwrap(); + + // Check the repository query and the create discussion mutation sent + let requests = api.requests(&Method::POST, "/graphql"); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[0].body.as_ref().unwrap()["variables"], + json!({"owner": ORG, "repo": REPO, "category": DISCUSSIONS_CATEGORY}) + ); + assert_eq!( + requests[1].body.as_ref().unwrap()["variables"], + json!({"repositoryId": "R_1", "categoryId": "C_1", "title": "title", "body": "body"}) + ); +} + +#[tokio::test] +async fn gh_api_create_discussion_category_not_found() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + json!({"data": {"repository": {"id": "R_1", "discussionCategory": null}}}), + ); + let gh = setup_test_gh_api(&api); + + // Create discussion + let err = gh + .create_discussion(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, "title", "body") + .await + .unwrap_err(); + + // Check the error returned and that only the repository query was sent + assert_eq!( + err.to_string(), + "something went wrong while fetching repository details for announcement" + ); + assert!(err.is::()); + let requests = api.requests(&Method::POST, "/graphql"); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].body.as_ref().unwrap()["variables"], + json!({"owner": ORG, "repo": REPO, "category": DISCUSSIONS_CATEGORY}) + ); +} + +#[tokio::test] +async fn gh_api_create_discussion_error_creating_discussion() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + json!({"data": {"repository": {"id": "R_1", "discussionCategory": {"id": "C_1"}}}}), + ); + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + json!({"data": null, "errors": [{"message": "Resource not accessible by integration"}]}), + ); + let gh = setup_test_gh_api(&api); + + // Create discussion and check the error is propagated + let err = gh + .create_discussion(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, "title", "body") + .await + .unwrap_err(); + assert_eq!(err.to_string(), "error creating announcement discussion"); + assert!(err.is::()); + + // Check the mutation was sent once, after the repository query + assert_eq!(api.requests(&Method::POST, "/graphql").len(), 2); +} + +#[tokio::test] +async fn gh_api_create_discussion_is_not_resent_after_server_error() { + for status in [StatusCode::BAD_GATEWAY, StatusCode::TOO_MANY_REQUESTS] { + // Setup mock GitHub API, which would accept the mutation if it was sent again + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + json!({"data": {"repository": {"id": "R_1", "discussionCategory": {"id": "C_1"}}}}), + ); + api.respond(Method::POST, "/graphql", status, json!({"message": "Failed"})); + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + json!({"data": {"createDiscussion": {"discussion": {"id": "D_1"}}}}), + ); + let gh = setup_test_gh_api(&api); + + // Check the failure is returned, as a retryable one + let err = gh + .create_discussion(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, "title", "body") + .await + .unwrap_err(); + assert!(format!("{err:#}").contains("Failed"), "{status}: {err:#}"); + assert!(!err.is::(), "{status}"); + + // Check the mutation was sent just once, after the repository query + assert_eq!(api.requests(&Method::POST, "/graphql").len(), 2, "{status}"); + } +} + +#[tokio::test] +async fn gh_api_create_discussion_rejected_errors() { + // Setup cases (response, expected error detail, rejected) + let test_cases = [ + // Missing permissions on the repository query + ( + json!({ + "data": {"repository": {"id": "R_1", "discussionCategory": null}}, + "errors": [{ + "type": "FORBIDDEN", + "path": ["repository", "discussionCategory"], + "message": "Resource not accessible by integration" + }] + }), + "Resource not accessible by integration", + true, + ), + // Category not found + ( + json!({ + "data": {"repository": {"id": "R_1", "discussionCategory": null}}, + "errors": [{ + "type": "NOT_FOUND", + "path": ["repository", "discussionCategory"], + "message": "Could not resolve to a DiscussionCategory with the slug 'x'." + }] + }), + "Could not resolve to a DiscussionCategory", + true, + ), + // Repository not found + ( + json!({ + "data": {"repository": null}, + "errors": [{ + "type": "NOT_FOUND", + "path": ["repository"], + "message": "Could not resolve to a Repository with the name 'org/repo'." + }] + }), + "Could not resolve to a Repository", + true, + ), + // Rate limited, which is reported with a successful status + ( + json!({ + "errors": [{ + "type": "RATE_LIMITED", + "message": "API rate limit exceeded for installation." + }] + }), + "API rate limit exceeded for installation.", + false, + ), + // Something failed on GitHub + ( + json!({ + "data": null, + "errors": [{"message": "Something went wrong while executing your query."}] + }), + "Something went wrong while executing your query.", + false, + ), + ]; + for (response, expected_detail, is_rejected) in test_cases { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond(Method::POST, "/graphql", StatusCode::OK, response.clone()); + let gh = setup_test_gh_api(&api); + + // Check the error reported and how it is classified + let err = gh + .create_discussion(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, "title", "body") + .await + .unwrap_err(); + assert!( + format!("{err:#}").contains(expected_detail), + "{response}: {err:#}" + ); + assert_eq!(err.is::(), is_rejected, "{response}"); + + // Check the discussion was not created after the failed repository query + assert_eq!(api.requests(&Method::POST, "/graphql").len(), 1, "{response}"); + } +} + +#[tokio::test] +async fn gh_api_create_discussion_server_error_on_repository_query_is_retried() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + "/graphql", + StatusCode::BAD_GATEWAY, + json!({"message": "Bad Gateway"}), + ); + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + json!({"data": {"repository": {"id": "R_1", "discussionCategory": {"id": "C_1"}}}}), + ); + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + json!({"data": {"createDiscussion": {"discussion": {"id": "D_1"}}}}), + ); + let gh = setup_test_gh_api(&api); + + // Check the discussion is created, as the query only reads data + gh.create_discussion(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, "title", "body") + .await + .unwrap(); + assert_eq!(api.requests(&Method::POST, "/graphql").len(), 3); +} + +#[tokio::test] +async fn gh_api_has_discussion_with_marker() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + respond_with_announcement_repo(&api); + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + discussions_page_json( + &[ + discussion_json("announcement", "2022-11-30T10:02:00Z", true), + discussion_json(&format!("results\n{MARKER}\n"), "2022-11-30T10:01:00Z", true), + ], + None, + ), + ); + let gh = setup_test_gh_api(&api); + + // Check the discussion is found, ignoring the whitespace after the marker + let since = OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(); + assert!( + gh.has_discussion_with_marker(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, MARKER, since) + .await + .unwrap() + ); + + // Check the repository query and the discussions query sent + let requests = api.requests(&Method::POST, "/graphql"); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[0].body.as_ref().unwrap()["variables"], + json!({"owner": ORG, "repo": REPO, "category": DISCUSSIONS_CATEGORY}) + ); + assert_eq!( + requests[1].body.as_ref().unwrap()["variables"], + json!({"owner": ORG, "repo": REPO, "categoryId": "C_1", "after": null}) + ); +} + +#[tokio::test] +async fn gh_api_has_discussion_with_marker_all_pages() { + // Setup mock GitHub API, with the discussion on the second page + let api = MockGitHubApi::start().await; + respond_with_announcement_repo(&api); + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + discussions_page_json( + &[discussion_json("announcement", "2022-11-30T10:02:00Z", true)], + Some("cursor-1"), + ), + ); + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + discussions_page_json(&[discussion_json(MARKER, "2022-11-30T10:01:00Z", true)], None), + ); + let gh = setup_test_gh_api(&api); + + // Check the discussion is found + let since = OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(); + assert!( + gh.has_discussion_with_marker(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, MARKER, since) + .await + .unwrap() + ); + + // Check the second page was requested after the first one + let requests = api.requests(&Method::POST, "/graphql"); + assert_eq!(requests.len(), 3); + assert_eq!( + requests[2].body.as_ref().unwrap()["variables"], + json!({"owner": ORG, "repo": REPO, "categoryId": "C_1", "after": "cursor-1"}) + ); +} + +#[tokio::test] +async fn gh_api_has_discussion_with_marker_category_not_found() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + json!({"data": {"repository": {"id": "R_1", "discussionCategory": null}}}), + ); + let gh = setup_test_gh_api(&api); + + // Check the lookup is rejected, as the creation of the discussion would be + let since = OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(); + let err = gh + .has_discussion_with_marker(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, MARKER, since) + .await + .unwrap_err(); + assert!(err.is::()); + + // Check the discussions were not listed after the repository query + assert_eq!(api.requests(&Method::POST, "/graphql").len(), 1); +} + +#[tokio::test] +async fn gh_api_has_discussion_with_marker_errors() { + // Setup cases (response, error, error detail, rejected) + let test_cases = [ + // Missing permissions + ( + json!({ + "data": null, + "errors": [{"type": "FORBIDDEN", "message": "Resource not accessible by integration"}] + }), + "error listing announcement discussions", + "Resource not accessible by integration", + true, + ), + // Rate limited, which is reported with a successful status + ( + json!({ + "errors": [{"type": "RATE_LIMITED", "message": "API rate limit exceeded for installation."}] + }), + "error listing announcement discussions", + "API rate limit exceeded for installation.", + false, + ), + // Repository missing from a response without errors + ( + json!({"data": {"repository": null}}), + "repository not found listing announcement discussions", + "repository not found listing announcement discussions", + false, + ), + // Discussion with an invalid creation time + ( + discussions_page_json(&[discussion_json(MARKER, "yesterday", true)], None), + "invalid creation time of announcement discussion", + "the 'year' component could not be parsed", + false, + ), + // Next page without cursor + ( + json!({"data": {"repository": {"discussions": { + "nodes": [], + "pageInfo": {"endCursor": null, "hasNextPage": true} + }}}}), + "next page of announcement discussions without cursor", + "next page of announcement discussions without cursor", + false, + ), + ]; + for (response, expected_err, expected_detail, is_rejected) in test_cases { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + respond_with_announcement_repo(&api); + api.respond(Method::POST, "/graphql", StatusCode::OK, response.clone()); + let gh = setup_test_gh_api(&api); + + // Check the failure is not taken for the discussion not being found, and how it + // is classified + let since = OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(); + let err = gh + .has_discussion_with_marker(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, MARKER, since) + .await + .unwrap_err(); + assert_eq!(err.to_string(), expected_err, "{response}"); + assert!( + format!("{err:#}").contains(expected_detail), + "{response}: {err:#}" + ); + assert_eq!(err.is::(), is_rejected, "{response}"); + + // Check the failure happened listing the discussions, after the repository query + assert_eq!(api.requests(&Method::POST, "/graphql").len(), 2, "{response}"); + } +} + +#[tokio::test] +async fn gh_api_has_discussion_with_marker_ignores_discussions_not_created_by_the_app() { + // Setup mock GitHub API, with the marker in a discussion created by someone else + let api = MockGitHubApi::start().await; + respond_with_announcement_repo(&api); + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + discussions_page_json(&[discussion_json(MARKER, "2022-11-30T10:01:00Z", false)], None), + ); + let gh = setup_test_gh_api(&api); + + // Check the discussion is not found + let since = OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(); + assert!( + !gh.has_discussion_with_marker(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, MARKER, since) + .await + .unwrap() + ); +} + +#[tokio::test] +async fn gh_api_has_discussion_with_marker_ignores_marker_not_at_the_end() { + // Setup mock GitHub API, with the marker in the middle of a discussion of the app, + // as when an issue title with the marker is quoted in another vote's announcement + let api = MockGitHubApi::start().await; + respond_with_announcement_repo(&api); + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + discussions_page_json( + &[discussion_json( + &format!("The vote for \"**{MARKER}**\" is now closed.\n\nResults"), + "2022-11-30T10:01:00Z", + true, + )], + None, + ), + ); + let gh = setup_test_gh_api(&api); + + // Check the discussion is not found + let since = OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(); + assert!( + !gh.has_discussion_with_marker(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, MARKER, since) + .await + .unwrap() + ); + + // Check the discussions were listed once, after the repository query + assert_eq!(api.requests(&Method::POST, "/graphql").len(), 2); +} + +#[tokio::test] +async fn gh_api_has_discussion_with_marker_stops_at_older_discussions() { + // Setup mock GitHub API, with a discussion created before the time provided followed + // by more pages, whose discussions are older + let api = MockGitHubApi::start().await; + respond_with_announcement_repo(&api); + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + discussions_page_json( + &[ + discussion_json("announcement", "2022-11-30T10:01:00Z", true), + discussion_json(MARKER, "2022-11-30T09:59:59Z", true), + ], + Some("cursor-1"), + ), + ); + let gh = setup_test_gh_api(&api); + + // Check the discussion is not found without getting the next page + let since = OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(); + assert!( + !gh.has_discussion_with_marker(INST_ID, ORG, REPO, DISCUSSIONS_CATEGORY, MARKER, since) + .await + .unwrap() + ); + assert_eq!(api.requests(&Method::POST, "/graphql").len(), 2); +} + +// Helpers. + +/// Builds a GitHub discussion JSON object as returned by the announcement discussions +/// query. +fn discussion_json(body: &str, created_at: &str, viewer_did_author: bool) -> Value { + json!({"body": body, "createdAt": created_at, "viewerDidAuthor": viewer_did_author}) +} + +/// Builds a response of the announcement discussions query with the discussions +/// provided, and the cursor of the next page if there is one. +fn discussions_page_json(discussions: &[Value], next_cursor: Option<&str>) -> Value { + json!({"data": {"repository": {"discussions": { + "nodes": discussions, + "pageInfo": {"endCursor": next_cursor, "hasNextPage": next_cursor.is_some()} + }}}}) +} + +/// Registers the response to the announcement repository query in the mock GitHub API. +fn respond_with_announcement_repo(api: &MockGitHubApi) { + api.respond( + Method::POST, + "/graphql", + StatusCode::OK, + json!({"data": {"repository": {"id": "R_1", "discussionCategory": {"id": "C_1"}}}}), + ); +} diff --git a/src/github/tests/event.rs b/src/github/tests/event.rs new file mode 100644 index 0000000..8a15c5e --- /dev/null +++ b/src/github/tests/event.rs @@ -0,0 +1,128 @@ +use std::{fs, mem::discriminant, path::Path}; + +use serde_json::json; + +use crate::testutil::*; + +use super::*; + +#[test] +fn event_try_from_errors() { + // Setup cases (event header, expected error) + let test_cases = [ + (Some("issues"), EventError::InvalidBody(String::new())), + (None, EventError::MissingHeader), + (Some("push"), EventError::UnsupportedEvent), + ]; + for (header, expected_err) in test_cases { + // Parse event and check the error variant, as serde sets the body error detail + let header = header.map(HeaderValue::from_static); + let err = Event::try_from((header.as_ref(), b"{}".as_slice())).unwrap_err(); + assert_eq!( + discriminant(&err), + discriminant(&expected_err), + "{header:?}: {err:?}" + ); + } +} + +#[test] +fn event_try_from_issue_comment_event() { + // Setup event payload + let header = HeaderValue::from_static("issue_comment"); + let body = fs::read(Path::new(TESTDATA_PATH).join("event-cmd.json")).unwrap(); + + // Parse event and check the result + let mut expected_event = setup_test_issue_comment_event(); + expected_event.action = IssueCommentEventAction::Created; + expected_event.comment.body = Some("/vote".to_string()); + assert_eq!( + Event::try_from((Some(&header), body.as_slice())).unwrap(), + Event::IssueComment(expected_event) + ); +} + +#[test] +fn event_try_from_issue_event() { + // Setup event payload + let header = HeaderValue::from_static("issues"); + let body = json!({ + "action": "opened", + "installation": {"id": INST_ID}, + "issue": { + "id": ISSUE_ID, + "number": ISSUE_NUM, + "title": TITLE, + "body": "/vote", + "pull_request": {"url": "https://api.github.com/repos/org/repo/pulls/1"} + }, + "repository": {"full_name": REPOFN}, + "sender": {"login": USER} + }); + + // Parse event and check the result + let mut expected_event = setup_test_issue_event(); + expected_event.action = IssueEventAction::Opened; + expected_event.issue.body = Some("/vote".to_string()); + expected_event.issue.pull_request = Some(PullRequestInIssue { + url: "https://api.github.com/repos/org/repo/pulls/1".to_string(), + }); + expected_event.organization = None; + assert_eq!( + Event::try_from((Some(&header), body.to_string().as_bytes())).unwrap(), + Event::Issue(expected_event) + ); +} + +#[test] +fn event_try_from_pr_event() { + // Setup event payload + let header = HeaderValue::from_static("pull_request"); + let body = json!({ + "action": "synchronize", + "installation": {"id": INST_ID}, + "pull_request": { + "base": {"ref": BRANCH}, + "id": ISSUE_ID, + "number": ISSUE_NUM, + "title": TITLE, + "body": null + }, + "repository": {"full_name": REPOFN}, + "sender": {"login": USER}, + "organization": {"login": ORG} + }); + + // Parse event and check the result + let mut expected_event = setup_test_pr_event(); + expected_event.action = PullRequestEventAction::Synchronize; + assert_eq!( + Event::try_from((Some(&header), body.to_string().as_bytes())).unwrap(), + Event::PullRequest(expected_event) + ); +} + +#[test] +fn event_try_from_unknown_action() { + // Setup cases (event header, payload with an action not handled, expected event) + let mut issue_event = setup_test_issue_event(); + let mut issue_body = serde_json::to_value(&issue_event).unwrap(); + issue_body["action"] = json!("edited"); + issue_event.action = IssueEventAction::Other; + let mut pr_event = setup_test_pr_event(); + let mut pr_body = serde_json::to_value(&pr_event).unwrap(); + pr_body["action"] = json!("closed"); + pr_event.action = PullRequestEventAction::Other; + let test_cases = [ + ("issues", issue_body, Event::Issue(issue_event)), + ("pull_request", pr_body, Event::PullRequest(pr_event)), + ]; + for (header, body, expected_event) in test_cases { + // Parse event and check the action is taken as other + let header = HeaderValue::from_static(header); + assert_eq!( + Event::try_from((Some(&header), body.to_string().as_bytes())).unwrap(), + expected_event + ); + } +} diff --git a/src/github/tests/helpers.rs b/src/github/tests/helpers.rs new file mode 100644 index 0000000..467ffd4 --- /dev/null +++ b/src/github/tests/helpers.rs @@ -0,0 +1,141 @@ +use std::{io, sync::LazyLock, time::Duration}; + +use aws_lc_rs::{ + encoding::AsDer, + rsa::{KeyPair, KeySize}, +}; +use axum::http::{Method, StatusCode}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use jsonwebtoken::EncodingKey; +use octocrab::models::AppId; +use serde_json::{Value, json}; + +use crate::testutil::*; + +use super::*; + +/// Read timeout used in the tests. They run on paused time, so it only elapses once +/// nothing else can make progress. +const TEST_READ_TIMEOUT: Duration = Duration::from_millis(500); + +/// Private key used to authenticate as the test GitHub application. +static APP_PRIVATE_KEY: LazyLock = LazyLock::new(|| { + // Generate a key pair + let key_pair = KeyPair::generate(KeySize::Rsa2048).unwrap(); + let der = key_pair.as_der().unwrap(); + + // Encode it as the PEM the GitHub client expects + let pem = format!( + "-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n", + STANDARD.encode(der.as_ref()) + ); + EncodingKey::from_rsa_pem(pem.as_bytes()).unwrap() +}); + +/// Checks the error provided was caused by a timeout of the GitHub client. +pub(super) fn assert_timeout_error(err: &anyhow::Error) { + let is_timeout = |source: &(dyn std::error::Error + 'static)| { + source + .downcast_ref::() + .is_some_and(|err| err.kind() == io::ErrorKind::TimedOut) + }; + assert!(err.chain().any(is_timeout), "{err:?}"); +} + +/// Builds a GitHub user JSON object for the login provided. +pub(super) fn author_json(login: &str) -> Value { + let url = format!("https://api.github.com/users/{login}"); + json!({ + "login": login, + "id": 1, + "node_id": "U_1", + "avatar_url": url, + "gravatar_id": "", + "url": url, + "html_url": url, + "followers_url": url, + "following_url": url, + "gists_url": url, + "starred_url": url, + "subscriptions_url": url, + "organizations_url": url, + "repos_url": url, + "events_url": url, + "received_events_url": url, + "type": "User", + "site_admin": false + }) +} + +/// Builds a GitHub client from the builder provided that uses the base URI given. +pub(super) fn build_test_client(builder: ClientBuilder, base_uri: &str) -> Octocrab { + builder + .base_uri(base_uri) + .unwrap() + .app(AppId(TEST_APP_ID), APP_PRIVATE_KEY.clone()) + .build() + .unwrap() +} + +/// Returns the path of the requests creating an installation token for the test +/// installation. +pub(super) fn installation_token_path() -> String { + format!("/app/installations/{INST_ID}/access_tokens") +} + +/// Builds a GitHub pull request JSON object. +pub(super) fn pr_json() -> Value { + json!({ + "number": ISSUE_NUM, + "head": {"ref": "feature", "sha": HEAD_SHA} + }) +} + +/// Sets up a `GHApi` instance that uses the mock GitHub API provided. +pub(super) fn setup_test_gh_api(api: &MockGitHubApi) -> GHApi { + respond_with_installation_token(api); + setup_test_gh_api_for_uri(api.base_uri()) +} + +/// Sets up a `GHApi` instance that uses the base URI provided. +pub(super) fn setup_test_gh_api_for_uri(base_uri: &str) -> GHApi { + // Install the crypto provider, which fails when another test already installed it + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + // Setup application clients + GHApi::new( + TEST_APP_ID, + build_test_client(retrying_client_builder(), base_uri), + build_test_client(single_attempt_client_builder(), base_uri), + ) +} + +/// Sets up a `GHApi` instance that uses the mock GitHub API provided, whose requests +/// time out after `TEST_READ_TIMEOUT`. +pub(super) fn setup_test_gh_api_with_short_timeouts(api: &MockGitHubApi) -> GHApi { + // Setup installation token response + respond_with_installation_token(api); + + // Setup application clients with short read timeouts + GHApi::new( + TEST_APP_ID, + build_test_client( + retrying_client_builder().set_read_timeout(Some(TEST_READ_TIMEOUT)), + api.base_uri(), + ), + build_test_client( + single_attempt_client_builder().set_read_timeout(Some(TEST_READ_TIMEOUT)), + api.base_uri(), + ), + ) +} + +/// Registers the response to the installation token requests in the mock GitHub API. +fn respond_with_installation_token(api: &MockGitHubApi) { + api.respond( + Method::POST, + &installation_token_path(), + StatusCode::CREATED, + json!({"token": INSTALLATION_TOKEN, "permissions": {}}), + ); +} diff --git a/src/github/tests/installations.rs b/src/github/tests/installations.rs new file mode 100644 index 0000000..dc9cd98 --- /dev/null +++ b/src/github/tests/installations.rs @@ -0,0 +1,59 @@ +use axum::http::{Method, StatusCode}; +use serde_json::json; + +use crate::testutil::*; + +use super::*; + +#[tokio::test] +async fn gh_api_get_repository_installation_id() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/installation", + StatusCode::OK, + json!({"id": INST_ID, "account": author_json(ORG), "permissions": {}, "events": []}), + ); + let gh = setup_test_gh_api(&api); + + // Get installation id and check the result + assert_eq!( + gh.get_repository_installation_id(ORG, REPO).await.unwrap(), + INST_ID + ); +} + +#[tokio::test] +async fn gh_api_get_repository_installation_id_not_found() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + let gh = setup_test_gh_api(&api); + + // Get installation id and check a not found error is returned + let err = gh.get_repository_installation_id(ORG, REPO).await.unwrap_err(); + assert!(is_not_found_error(&err)); +} + +#[tokio::test] +async fn gh_api_get_repository_installation_id_uses_app_jwt() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/installation", + StatusCode::OK, + json!({"id": INST_ID, "account": author_json(ORG), "permissions": {}, "events": []}), + ); + let gh = setup_test_gh_api(&api); + + // Get installation id + gh.get_repository_installation_id(ORG, REPO).await.unwrap(); + + // Check the application JWT was used and no installation token was requested + let requests = api.requests(&Method::GET, "/repos/org/repo/installation"); + let authorization = requests[0].authorization.as_ref().unwrap(); + assert!(authorization.starts_with("Bearer ey")); + let token_path = installation_token_path(); + assert_eq!(api.requests(&Method::POST, &token_path), vec![]); +} diff --git a/src/github/tests/labels.rs b/src/github/tests/labels.rs new file mode 100644 index 0000000..1c42eb5 --- /dev/null +++ b/src/github/tests/labels.rs @@ -0,0 +1,135 @@ +use axum::http::{Method, StatusCode}; +use serde_json::json; + +use crate::testutil::*; + +use super::*; + +/// Path of the requests adding labels to the test issue. +const LABELS_PATH: &str = "/repos/org/repo/issues/1/labels"; + +#[tokio::test] +async fn gh_api_add_labels() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + "/repos/org/repo/issues/1/labels", + StatusCode::OK, + json!([]), + ); + let gh = setup_test_gh_api(&api); + + // Add labels + gh.add_labels(INST_ID, ORG, REPO, ISSUE_NUM, &["gitvote", "gitvote/open"]).await.unwrap(); + + // Check the request sent + let requests = api.requests(&Method::POST, "/repos/org/repo/issues/1/labels"); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].body, + Some(json!({"labels": ["gitvote", "gitvote/open"]})) + ); +} + +#[tokio::test] +async fn gh_api_add_labels_is_retried_after_server_error() { + for status in [StatusCode::BAD_GATEWAY, StatusCode::TOO_MANY_REQUESTS] { + // Setup mock GitHub API, which accepts the labels when they are sent again + let api = MockGitHubApi::start().await; + api.respond(Method::POST, LABELS_PATH, status, json!({"message": "Failed"})); + api.respond(Method::POST, LABELS_PATH, StatusCode::OK, json!([])); + let gh = setup_test_gh_api(&api); + + // Check the failure is not returned and the labels were sent again + gh.add_labels(INST_ID, ORG, REPO, ISSUE_NUM, &["gitvote"]).await.unwrap(); + assert_eq!(api.requests(&Method::POST, LABELS_PATH).len(), 2, "{status}"); + } +} + +#[tokio::test] +async fn gh_api_add_labels_is_retried_after_token_server_error() { + // Setup mock GitHub API, whose first installation token request fails + let api = MockGitHubApi::start().await; + api.respond( + Method::POST, + &installation_token_path(), + StatusCode::BAD_GATEWAY, + json!({"message": "Failed"}), + ); + api.respond(Method::POST, LABELS_PATH, StatusCode::OK, json!([])); + let gh = setup_test_gh_api(&api); + + // Check the token was requested again and the labels were sent + gh.add_labels(INST_ID, ORG, REPO, ISSUE_NUM, &["gitvote"]).await.unwrap(); + assert_eq!(api.requests(&Method::POST, &installation_token_path()).len(), 2); + assert_eq!(api.requests(&Method::POST, LABELS_PATH).len(), 1); +} + +#[tokio::test] +async fn gh_api_remove_label() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::DELETE, + "/repos/org/repo/issues/1/labels/gitvote%2Fopen", + StatusCode::OK, + json!([]), + ); + let gh = setup_test_gh_api(&api); + + // Remove label + gh.remove_label(INST_ID, ORG, REPO, ISSUE_NUM, "gitvote/open").await.unwrap(); + + // Check the request sent + assert_eq!( + api.requests(&Method::DELETE, "/repos/org/repo/issues/1/labels/gitvote%2Fopen").len(), + 1 + ); +} + +#[tokio::test] +async fn gh_api_remove_label_error() { + // Setup mock GitHub API (label request is not found) + let api = MockGitHubApi::start().await; + let gh = setup_test_gh_api(&api); + + // Remove label and check the error is propagated + let err = gh.remove_label(INST_ID, ORG, REPO, ISSUE_NUM, "gitvote/open").await.unwrap_err(); + assert!(is_not_found_error(&err)); +} + +#[tokio::test] +async fn gh_api_remove_label_is_retried_after_server_error() { + // Setup mock GitHub API, which accepts the deletion when it is sent again + let path = "/repos/org/repo/issues/1/labels/gitvote%2Fopen"; + let api = MockGitHubApi::start().await; + api.respond( + Method::DELETE, + path, + StatusCode::BAD_GATEWAY, + json!({"message": "Failed"}), + ); + api.respond(Method::DELETE, path, StatusCode::OK, json!([])); + let gh = setup_test_gh_api(&api); + + // Check the failure is not returned and the deletion was sent again + gh.remove_label(INST_ID, ORG, REPO, ISSUE_NUM, "gitvote/open").await.unwrap(); + assert_eq!(api.requests(&Method::DELETE, path).len(), 2); +} + +#[tokio::test] +async fn gh_api_remove_label_that_does_not_exist() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::DELETE, + "/repos/org/repo/issues/1/labels/gitvote%2Fopen", + StatusCode::NOT_FOUND, + json!({"message": "Label does not exist"}), + ); + let gh = setup_test_gh_api(&api); + + // Remove label and check the error is ignored + gh.remove_label(INST_ID, ORG, REPO, ISSUE_NUM, "gitvote/open").await.unwrap(); +} diff --git a/src/github/tests/module_functions.rs b/src/github/tests/module_functions.rs new file mode 100644 index 0000000..1b79fc7 --- /dev/null +++ b/src/github/tests/module_functions.rs @@ -0,0 +1,61 @@ +use anyhow::format_err; +use axum::http::{Method, StatusCode}; +use serde_json::{Value, json}; + +use crate::testutil::*; + +use super::*; + +#[tokio::test] +async fn is_not_found_error_github_not_found() { + assert!(is_not_found_error(&setup_test_not_found_error().await)); +} + +#[tokio::test] +async fn is_not_found_error_github_not_found_other_message() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/empty", + StatusCode::NOT_FOUND, + json!({"message": "This repository is empty."}), + ); + let client = Octocrab::builder().base_uri(api.base_uri()).unwrap().build().unwrap(); + + // Get GitHub error and check it is a not found error + let err: Error = client.get::("/empty", None).await.unwrap_err().into(); + assert!(is_not_found_error(&err)); +} + +#[test] +fn is_not_found_error_other_error() { + assert!(!is_not_found_error(&format_err!("Not Found"))); +} + +#[tokio::test] +async fn is_not_found_error_other_github_error() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/invalid", + StatusCode::UNPROCESSABLE_ENTITY, + json!({"message": "Validation Failed"}), + ); + let client = Octocrab::builder().base_uri(api.base_uri()).unwrap().build().unwrap(); + + // Get GitHub error and check it is not a not found error + let err: Error = client.get::("/invalid", None).await.unwrap_err().into(); + assert!(!is_not_found_error(&err)); +} + +#[test] +fn split_full_name_extra_parts_are_ignored() { + assert_eq!(split_full_name("org/repo/extra"), ("org", "repo")); +} + +#[test] +fn split_full_name_owner_and_repo() { + assert_eq!(split_full_name(REPOFN), (ORG, REPO)); +} diff --git a/src/github/tests/pull_requests.rs b/src/github/tests/pull_requests.rs new file mode 100644 index 0000000..2165a3a --- /dev/null +++ b/src/github/tests/pull_requests.rs @@ -0,0 +1,93 @@ +use axum::http::{Method, StatusCode}; +use serde_json::json; + +use crate::testutil::*; + +use super::*; + +#[tokio::test] +async fn gh_api_get_pr_files() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/pulls/1/files", + StatusCode::OK, + json!([{"filename": "README.md"}, {"filename": "src/main.rs"}]), + ); + let gh = setup_test_gh_api(&api); + + // Get pull request files and check the result + assert_eq!( + gh.get_pr_files(INST_ID, ORG, REPO, ISSUE_NUM).await.unwrap(), + vec![ + File { + filename: "README.md".to_string() + }, + File { + filename: "src/main.rs".to_string() + } + ] + ); +} + +#[tokio::test] +async fn gh_api_get_pr_files_all_pages() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond_with_next_page( + Method::GET, + "/repos/org/repo/pulls/1/files", + json!([{"filename": "README.md"}]), + "/files-page-2", + ); + api.respond( + Method::GET, + "/files-page-2", + StatusCode::OK, + json!([{"filename": "src/main.rs"}]), + ); + let gh = setup_test_gh_api(&api); + + // Get pull request files and check all pages were collected + assert_eq!( + gh.get_pr_files(INST_ID, ORG, REPO, ISSUE_NUM).await.unwrap(), + vec![ + File { + filename: "README.md".to_string() + }, + File { + filename: "src/main.rs".to_string() + } + ] + ); +} + +#[tokio::test] +async fn gh_api_get_pr_head_sha() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond(Method::GET, "/repos/org/repo/pulls/1", StatusCode::OK, pr_json()); + let gh = setup_test_gh_api(&api); + + // Get the head commit and check it is the one in the pull request + let head_sha = gh.get_pr_head_sha(INST_ID, ORG, REPO, ISSUE_NUM).await.unwrap(); + assert_eq!(head_sha, HEAD_SHA); +} + +#[tokio::test] +async fn gh_api_get_pr_head_sha_pr_without_head() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + api.respond( + Method::GET, + "/repos/org/repo/pulls/1", + StatusCode::OK, + json!({"number": 1}), + ); + let gh = setup_test_gh_api(&api); + + // Get the head commit and check the error returned + let err = gh.get_pr_head_sha(INST_ID, ORG, REPO, ISSUE_NUM).await.unwrap_err(); + assert_eq!(err.to_string(), "pull request response missing head"); +} diff --git a/src/github/tests/requests.rs b/src/github/tests/requests.rs new file mode 100644 index 0000000..9a0d47e --- /dev/null +++ b/src/github/tests/requests.rs @@ -0,0 +1,155 @@ +use std::{io, net::TcpStream, time::Duration}; + +use axum::http::{Method, StatusCode}; +use serde_json::{Value, json}; +use tokio::{ + net::{TcpListener, TcpSocket}, + time::{self, Instant}, +}; + +use crate::testutil::*; + +use super::*; + +/// Time after which a connection to a listener is considered not to be progressing. +const CONNECT_PROBE_TIMEOUT: Duration = Duration::from_millis(250); + +/// Size of a request body that cannot fit in the socket buffers of a connection. +const LARGE_BODY_SIZE: usize = 16 * 1024 * 1024; + +/// Local address that makes the system assign a free port to the listener using it. +const LISTENER_ADDR: &str = "127.0.0.1:0"; + +/// Maximum time a request that timed out can take over the timeouts that apply to it. +const REQUEST_TIMEOUT_MARGIN: Duration = Duration::from_secs(1); + +/// Maximum time to wait for a stalled request to fail by itself. Tests run on paused +/// time, so it is not waited for in practice. +const STALLED_REQUEST_LIMIT: Duration = Duration::from_secs(600); + +#[tokio::test] +async fn gh_api_read_request_is_retried_after_server_error() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + let path = "/repos/org/repo/collaborators/user"; + api.respond( + Method::GET, + path, + StatusCode::BAD_GATEWAY, + json!({"message": "Bad Gateway"}), + ); + api.respond(Method::GET, path, StatusCode::NO_CONTENT, Value::Null); + let gh = setup_test_gh_api(&api); + + // Check the request succeeds after being sent again + assert!(gh.user_is_collaborator(INST_ID, ORG, REPO, USER).await.unwrap()); + assert_eq!(api.requests(&Method::GET, path).len(), 2); +} + +#[tokio::test(start_paused = true)] +async fn gh_api_read_request_is_retried_after_timeout() { + // Setup mock GitHub API + let api = MockGitHubApi::start().await; + let path = "/repos/org/repo/collaborators/user"; + api.stall(Method::GET, path); + api.respond(Method::GET, path, StatusCode::NO_CONTENT, Value::Null); + let gh = setup_test_gh_api_with_short_timeouts(&api); + + // Check the request succeeds after being sent again + assert!(gh.user_is_collaborator(INST_ID, ORG, REPO, USER).await.unwrap()); + assert_eq!(api.requests(&Method::GET, path).len(), 2); +} + +#[tokio::test(start_paused = true)] +async fn gh_api_request_times_out_when_the_server_does_not_accept_connections() { + // Setup a server that does not take more connections + let (listener, _connections) = setup_test_full_listener(); + let base_uri = format!("http://{}", listener.local_addr().unwrap()); + + // Setup GitHub client + let gh = setup_test_gh_api_for_uri(&base_uri); + + // Run a request and check it fails by itself after four attempts of ten seconds + let started_at = Instant::now(); + let request = gh.get_repository_installation_id(ORG, REPO); + let err = time::timeout(STALLED_REQUEST_LIMIT, request).await.expect("request should time out"); + assert_timeout_error(&err.unwrap_err()); + assert_timeouts_took(started_at, Duration::from_secs(40)); +} + +#[tokio::test(start_paused = true)] +async fn gh_api_request_times_out_when_the_server_does_not_read() { + // Setup a server that takes connections and never reads from them, using a small + // buffer so that the size of the request needed does not depend on the system + let socket = TcpSocket::new_v4().unwrap(); + socket.set_recv_buffer_size(4096).unwrap(); + socket.bind(LISTENER_ADDR.parse().unwrap()).unwrap(); + let listener = socket.listen(1).unwrap(); + let base_uri = format!("http://{}", listener.local_addr().unwrap()); + + // Setup GitHub client, without the read timeout that would also end the request + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let client = build_test_client(single_attempt_client_builder().set_read_timeout(None), &base_uri); + + // Run a request and check it fails by itself after thirty seconds + let started_at = Instant::now(); + let body = "a".repeat(LARGE_BODY_SIZE); + let request = client.post::<_, Value>("/stalled", Some(&body)); + let result = time::timeout(STALLED_REQUEST_LIMIT, request).await.expect("request should time out"); + assert_timeout_error(&result.unwrap_err().into()); + assert_timeouts_took(started_at, Duration::from_secs(30)); +} + +#[tokio::test(start_paused = true)] +async fn gh_api_request_times_out_when_the_server_stalls() { + // Setup a server that accepts connections and never responds + let listener = TcpListener::bind(LISTENER_ADDR).await.unwrap(); + let base_uri = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + let mut connections = vec![]; + while let Ok((connection, _)) = listener.accept().await { + connections.push(connection); + } + }); + + // Setup GitHub client + let gh = setup_test_gh_api_for_uri(&base_uri); + + // Run a request and check it fails by itself after four attempts of thirty seconds + let started_at = Instant::now(); + let request = gh.get_repository_installation_id(ORG, REPO); + let err = time::timeout(STALLED_REQUEST_LIMIT, request).await.expect("request should time out"); + assert_timeout_error(&err.unwrap_err()); + assert_timeouts_took(started_at, Duration::from_secs(120)); +} + +// Helpers. + +/// Checks the time elapsed since the instant provided is the one expected. +fn assert_timeouts_took(started_at: Instant, expected: Duration) { + let elapsed = started_at.elapsed(); + let range = expected..expected + REQUEST_TIMEOUT_MARGIN; + assert!(range.contains(&elapsed), "{elapsed:?} not in {range:?}"); +} + +/// Sets up a listener whose accept queue is full, so that new connections to it stall. +/// It is returned with the connections filling the queue, which must be kept open. +fn setup_test_full_listener() -> (TcpListener, Vec) { + // Setup a listener with the shortest accept queue + let socket = TcpSocket::new_v4().unwrap(); + socket.bind(LISTENER_ADDR.parse().unwrap()).unwrap(); + let listener = socket.listen(1).unwrap(); + let addr = listener.local_addr().unwrap(); + + // Connect to it until a connection does not progress + let mut connections = vec![]; + loop { + match TcpStream::connect_timeout(&addr, CONNECT_PROBE_TIMEOUT) { + Ok(connection) => connections.push(connection), + Err(err) => { + assert_eq!(err.kind(), io::ErrorKind::TimedOut); + return (listener, connections); + } + } + } +} diff --git a/src/graphql/announcement_discussions_query.graphql b/src/graphql/announcement_discussions_query.graphql new file mode 100644 index 0000000..43439a8 --- /dev/null +++ b/src/graphql/announcement_discussions_query.graphql @@ -0,0 +1,20 @@ +query AnnouncementDiscussionsQuery($owner: String!, $repo: String!, $categoryId: ID!, $after: String) { + repository(owner: $owner, name: $repo) { + discussions( + first: 50 + after: $after + categoryId: $categoryId + orderBy: {field: CREATED_AT, direction: DESC} + ) { + nodes { + body + createdAt + viewerDidAuthor + } + pageInfo { + endCursor + hasNextPage + } + } + } +} diff --git a/src/handlers.rs b/src/handlers.rs index 104696b..ee31246 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -23,15 +23,15 @@ use uuid::Uuid; use crate::{ cfg_repo::{Cfg as RepoCfg, CfgError}, cfg_svc::Cfg, - cmd::Command, + cmd::{self, Command}, db::DynDB, - github::{ - self, CheckDetails, DynGH, Event, EventError, PullRequestEvent, PullRequestEventAction, - split_full_name, - }, + github::{self, DynGH, Event, EventError, PullRequestEvent, PullRequestEventAction, split_full_name}, results, tmpl, }; +#[cfg(test)] +mod tests; + /// Header representing the kind of the event received. const GITHUB_EVENT_HEADER: &str = "X-GitHub-Event"; @@ -41,14 +41,20 @@ const GITHUB_SIGNATURE_HEADER: &str = "X-Hub-Signature-256"; /// Router's state. #[derive(Clone, FromRef)] struct RouterState { + /// Sender used to queue the commands to process. + cmds_tx: async_channel::Sender, + /// Database handle. db: DynDB, + /// GitHub client. gh: DynGH, - cmds_tx: async_channel::Sender, + /// Secret used to verify the signature of the webhook payloads. webhook_secret: String, + + /// Secret tried when the primary one does not verify the payload signature. webhook_secret_fallback: Option, } -/// Setup HTTP server router. +/// Sets up the HTTP server router. pub(crate) fn setup_router( cfg: &Cfg, db: DynDB, @@ -62,47 +68,41 @@ pub(crate) fn setup_router( .route("/audit/{owner}/{repo}/vote/{vote_id}", get(audit_vote_details)) .layer(ServiceBuilder::new().layer(TraceLayer::new_for_http())) .with_state(RouterState { + cmds_tx, db, gh, - cmds_tx, webhook_secret: cfg.github.webhook_secret.clone(), + webhook_secret_fallback: cfg.github.webhook_secret_fallback.clone(), }) } // Handlers. -/// Handler that returns the index document. -#[allow(clippy::unused_async)] -async fn index() -> impl IntoResponse { - let template = tmpl::Index {}; - match template.render() { - Ok(html) => Ok(Html(html)), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), - } -} - /// Handler that renders the audit page for a repository. async fn audit( State(db): State, State(gh): State, Path((owner, repo)): Path<(String, String)>, ) -> impl IntoResponse { - let repository_full_name = format!("{owner}/{repo}"); + // Build the repository full name from the path segments + let Some(repository_full_name) = repository_full_name(&owner, &repo) else { + return Err(StatusCode::NOT_FOUND); + }; // Check if audit page is enabled for the repository - let audit_enabled = match audit_is_enabled(gh, repository_full_name.clone()).await { - Ok(enabled) => enabled, + let is_audit_enabled = match audit_is_enabled(gh, repository_full_name.clone()).await { + Ok(is_enabled) => is_enabled, Err(err) => { error!(?err, repository_full_name, "error checking audit configuration"); return Err(StatusCode::INTERNAL_SERVER_ERROR); } }; - if !audit_enabled { + if !is_audit_enabled { return Err(StatusCode::NOT_FOUND); } - // Prepare and render template + // Get the repository votes from the database let votes = match db.list_votes(&repository_full_name).await { Ok(votes) => votes, Err(err) => { @@ -110,6 +110,8 @@ async fn audit( return Err(StatusCode::INTERNAL_SERVER_ERROR); } }; + + // Render the template, allowing caches to keep it for 15 minutes let template = tmpl::Audit::new(repository_full_name, votes); match template.render() { Ok(html) => Ok(([(header::CACHE_CONTROL, "max-age=900")], Html(html))), @@ -126,17 +128,20 @@ async fn audit_vote_details( State(gh): State, Path((owner, repo, vote_id)): Path<(String, String, Uuid)>, ) -> impl IntoResponse { - let repository_full_name = format!("{owner}/{repo}"); + // Build the repository full name from the path segments + let Some(repository_full_name) = repository_full_name(&owner, &repo) else { + return Err(StatusCode::NOT_FOUND); + }; // Check if audit page is enabled for the repository - let audit_enabled = match audit_is_enabled(gh.clone(), repository_full_name.clone()).await { - Ok(enabled) => enabled, + let is_audit_enabled = match audit_is_enabled(gh.clone(), repository_full_name.clone()).await { + Ok(is_enabled) => is_enabled, Err(err) => { error!(?err, %vote_id, "error checking audit configuration"); return Err(StatusCode::INTERNAL_SERVER_ERROR); } }; - if !audit_enabled { + if !is_audit_enabled { return Err(StatusCode::NOT_FOUND); } @@ -155,12 +160,13 @@ async fn audit_vote_details( return Err(StatusCode::NOT_FOUND); } - // Get results (from DB if closed, or calculate from GitHub API if open) - let results = if let Some(results) = &vote.results { - results.clone() + // Get results (from DB if closed, or calculate from GitHub API if open). Closed + // votes have no results when their comment was deleted before they were closed + let results = if vote.closed { + vote.results.clone() } else { match results::calculate(gh, &owner, &repo, &vote).await { - Ok(results) => results, + Ok(results) => Some(results), Err(err) => { error!(?err, %vote_id, "error calculating vote results"); return Err(StatusCode::INTERNAL_SERVER_ERROR); @@ -168,10 +174,11 @@ async fn audit_vote_details( } }; - // Render template + // Render the template, allowing caches to keep it for 15 minutes let template = tmpl::AuditVoteDetails { - results: &results, vote: &vote, + + results: results.as_ref(), }; match template.render() { Ok(html) => Ok(([(header::CACHE_CONTROL, "max-age=900")], Html(html))), @@ -219,19 +226,78 @@ async fn event( } Err(EventError::UnsupportedEvent) => return Ok("unsupported event"), }; - trace!(?event, "event received"); - // Try to extract command from event (if available) and queue it - match Command::from_event(gh.clone(), &event).await { + // Trace the event received, without the payload, as it includes user content + let (event_kind, repo, issue_number) = match &event { + Event::Issue(event) => ("issues", &event.repository.full_name, event.issue.number), + Event::IssueComment(event) => ("issue_comment", &event.repository.full_name, event.issue.number), + Event::PullRequest(event) => ( + "pull_request", + &event.repository.full_name, + event.pull_request.number, + ), + }; + trace!(event_kind, repo, issue_number, "event received"); + + // Try to extract command from event (if available) + let cmd = match Command::from_event(gh.clone(), &event).await { + Ok(cmd) => cmd, + Err(err) => { + // Only opened pull requests are evaluated for automatic commands. Don't + // report success on them, as a vote that should exist may be missing. The + // event is not logged, as it includes the content of the pull request + if let Event::PullRequest(event) = &event { + let (repo, pr_number) = (&event.repository.full_name, event.pull_request.number); + error!(?err, repo, pr_number, "error processing automatic command"); + report_automation_failure(db, gh, event).await.map_err(|err| { + error!(?err, repo, pr_number, "error reporting automation failure"); + (StatusCode::INTERNAL_SERVER_ERROR, String::new()) + })?; + } else { + error!(?err, "error processing automatic command"); + } + return Ok("automation failure reported"); + } + }; + + // Queue the command, or update the check of the pull request if there isn't any + match cmd { Some(cmd) => { trace!(?cmd, "command detected"); - cmds_tx.send(cmd).await.unwrap(); + + // Report the check of pull requests opened with commands that cannot create + // a vote, as they prevent automatic ones from being processed + if let Event::PullRequest(event) = &event + && !matches!(cmd, Command::CreateVote(_)) + { + set_no_vote_command_check_status(db, gh, event).await.map_err(|err| { + error!( + ?err, + repo = event.repository.full_name, + pr_number = event.pull_request.number, + "error setting pull request check status" + ); + (StatusCode::INTERNAL_SERVER_ERROR, String::new()) + })?; + } + + // Queue the command for the votes processor + cmds_tx + .send(cmd) + .await + .expect("commands channel should stay open while the http server runs"); return Ok("command queued"); } None => { + // Set the check status of pull requests without commands if let Event::PullRequest(event) = event { set_check_status(db, gh, &event).await.map_err(|err| { - error!(?err, ?event, "error setting pull request check status"); + error!( + ?err, + repo = event.repository.full_name, + pr_number = event.pull_request.number, + "error setting pull request check status" + ); (StatusCode::INTERNAL_SERVER_ERROR, String::new()) })?; } @@ -241,12 +307,29 @@ async fn event( Ok("no command detected") } +/// Handler that returns the index document. +#[allow(clippy::unused_async)] +async fn index() -> impl IntoResponse { + let template = tmpl::Index {}; + match template.render() { + Ok(html) => Ok(Html(html)), + Err(err) => { + error!(?err, "error rendering index template"); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + // Helpers. -/// Check whether the audit page is enabled for the provided repository. +/// Checks whether the audit page is enabled for the provided repository. +// The cache is left out of test builds so that tests do not share results, which +// leaves its size and TTL bounds deliberately untested, as they are options of the +// cached crate #[cfg_attr( not(test), cached( + max_size = 1000, // Keys come from unauthenticated requests ttl_secs = 900, // 15 minutes key = "String", convert = r#"{ repository_full_name.clone() }"#, @@ -254,6 +337,7 @@ async fn event( ) )] async fn audit_is_enabled(gh: DynGH, repository_full_name: String) -> Result { + // Get the repository installation, as the audit page is disabled without one let (owner, repo) = split_full_name(&repository_full_name); let inst_id = match gh.get_repository_installation_id(owner, repo).await { Ok(inst_id) => inst_id, @@ -264,44 +348,73 @@ async fn audit_is_enabled(gh: DynGH, repository_full_name: String) -> Result cfg, Err(CfgError::ConfigNotFound) => return Ok(false), Err(err) => return Err(err.into()), }; + Ok(cfg.audit.is_some_and(|audit| audit.enabled)) } -/// Set a success check status to the pull request referenced in the event -/// provided when it's created or synchronized if no vote has been created on -/// it yet. This makes it possible to use the `GitVote` check in combination -/// with branch protection. +/// Reports that the automation rules of a pull request just opened could not be +/// evaluated, unless it has a vote. +async fn report_automation_failure(db: DynDB, gh: DynGH, event: &PullRequestEvent) -> Result<()> { + let (owner, repo) = split_full_name(&event.repository.full_name); + let inst_id = event.installation.id as u64; + let pr_number = event.pull_request.number; + cmd::report_automation_failure(db, gh, inst_id, owner, repo, pr_number, None).await +} + +/// Builds the full name of a repository from the path segments provided. As the router +/// decodes them, a segment may contain a slash, which isn't valid in these names. +fn repository_full_name(owner: &str, repo: &str) -> Option { + if owner.contains('/') || repo.contains('/') { + return None; + } + Some(format!("{owner}/{repo}")) +} + +/// Sets the check status of the pull request referenced in the event provided when +/// it's created or synchronized if it has no vote. This makes it possible to use the +/// `GitVote` check in combination with branch protection. async fn set_check_status(db: DynDB, gh: DynGH, event: &PullRequestEvent) -> Result<()> { + // Extract the pull request details from the event let (owner, repo) = split_full_name(&event.repository.full_name); let inst_id = event.installation.id as u64; - let pr = event.pull_request.number; + let pr_number = event.pull_request.number; let branch = &event.pull_request.base.reference; - let check_details = CheckDetails { - status: "completed".to_string(), - conclusion: Some("success".to_string()), - summary: "No vote found".to_string(), - }; + // Update the check according to the action, arms in variant order (catch-all last) match event.action { PullRequestEventAction::Opened => { + // No automatic command was found, so the automation rules were already + // evaluated and the pull request does not need a vote if !gh.is_check_required(inst_id, owner, repo, branch).await? { return Ok(()); } - gh.create_check_run(inst_id, owner, repo, pr, &check_details).await?; + + // Publish the check reporting that no vote was found + let check_details = cmd::no_vote_found_check(); + cmd::publish_no_vote_check(db, gh, inst_id, owner, repo, pr_number, &check_details).await?; } PullRequestEventAction::Synchronize => { + // Skip the check if it isn't required in the branch if !gh.is_check_required(inst_id, owner, repo, branch).await? { return Ok(()); } - if db.has_vote(&event.repository.full_name, event.pull_request.number).await? { + + // Skip evaluating the automation rules if a vote owns the check, to avoid + // their GitHub requests on every push to a pull request with a vote + if db.has_vote(&event.repository.full_name, pr_number).await? { return Ok(()); } - gh.create_check_run(inst_id, owner, repo, pr, &check_details).await?; + + // The files changed, so the automation rules are evaluated again, and the + // vote is looked up once more right before reporting the check + cmd::create_no_vote_check_run(db, gh, inst_id, owner, repo, pr_number).await?; } PullRequestEventAction::Other => {} } @@ -309,37 +422,45 @@ async fn set_check_status(db: DynDB, gh: DynGH, event: &PullRequestEvent) -> Res Ok(()) } -/// Verify that the signature provided is valid. +/// Sets the check status of a pull request just opened with a command that cannot +/// create a vote. These commands prevent automatic ones from being processed. +async fn set_no_vote_command_check_status(db: DynDB, gh: DynGH, event: &PullRequestEvent) -> Result<()> { + let (owner, repo) = split_full_name(&event.repository.full_name); + let inst_id = event.installation.id as u64; + let pr_number = event.pull_request.number; + let branch = &event.pull_request.base.reference; + cmd::update_no_vote_check(db, gh, inst_id, owner, repo, pr_number, branch).await +} + +/// Verifies that the signature provided is valid for any of the secrets given. fn verify_signature( signature: Option<&HeaderValue>, secret: &[u8], secret_fallback: Option<&[u8]>, body: &[u8], ) -> Result<()> { - if let Some(signature) = signature - .and_then(|s| s.to_str().ok()) - .and_then(|s| s.strip_prefix("sha256=")) - .and_then(|s| hex::decode(s).ok()) - { - // Try primary secret - let mut mac = Hmac::::new_from_slice(secret)?; - mac.update(body); - let result = mac.verify_slice(&signature[..]); - if result.is_ok() { - return Ok(()); - } - if secret_fallback.is_none() { - return result.map_err(Error::new); - } + // Decode the signature provided in the header + let Some(signature) = signature + .and_then(|value| value.to_str().ok()) + .and_then(|header| header.strip_prefix("sha256=")) + .and_then(|hex_signature| hex::decode(hex_signature).ok()) + else { + return Err(format_err!("no valid signature found")); + }; - // Try fallback secret (if available) - let mut mac = Hmac::::new_from_slice(secret_fallback.expect("secret should be set"))?; - mac.update(body); - mac.verify_slice(&signature[..]).map_err(Error::new) - } else { - Err(format_err!("no valid signature found")) + // Try primary secret + let mut mac = Hmac::::new_from_slice(secret)?; + mac.update(body); + let result = mac.verify_slice(&signature[..]); + if result.is_ok() { + return Ok(()); } -} -#[cfg(test)] -mod tests; + // Try fallback secret (if available) + let Some(secret_fallback) = secret_fallback else { + return result.map_err(Error::new); + }; + let mut mac = Hmac::::new_from_slice(secret_fallback)?; + mac.update(body); + mac.verify_slice(&signature[..]).map_err(Error::new) +} diff --git a/src/handlers/tests.rs b/src/handlers/tests.rs index b2aafd4..68c5be8 100644 --- a/src/handlers/tests.rs +++ b/src/handlers/tests.rs @@ -1,5 +1,4 @@ -use std::sync::Arc; -use std::{fs, path::Path}; +use std::{fs, path::Path, sync::Arc}; use async_channel::Receiver; use axum::{ @@ -8,36 +7,22 @@ use axum::{ }; use figment::{Figment, providers::Serialized}; use futures::future; +use hmac::digest::MacError; use hyper::Response; -use mockall::predicate::eq; +use mockall::{Sequence, predicate::eq}; +use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339}; use tower::ServiceExt; -use crate::github::{IssueEventAction, MockGH}; -use crate::testutil::*; -use crate::{cmd::CreateVoteInput, db::MockDB}; +use crate::{ + cmd::{CancelVoteInput, CheckVoteInput, CreateVoteInput}, + db::{DB, MockDB, VoteCloseStep}, + github::{CheckDetails, File, IssueEventAction, MockGH}, + results::{Vote, VoteResults}, + testutil::*, +}; use super::*; -#[tokio::test] -async fn index() { - // Setup router - let (router, _) = setup_test_router(); - - // Run the request - let response = router - .oneshot(Request::builder().method("GET").uri("/").body(Body::empty()).unwrap()) - .await - .unwrap(); - - // Check the response - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); - assert_eq!( - get_body(response).await, - fs::read_to_string("templates/index.html").unwrap().trim_end_matches('\n') - ); -} - #[tokio::test] async fn audit_config_not_found_returns_not_found() { // Setup mocks @@ -48,105 +33,72 @@ async fn audit_config_not_found_returns_not_found() { gh.expect_get_config_file() .with(eq(INST_ID), eq(ORG), eq(REPO)) .times(1) - .returning(|_, _, _| Box::pin(future::ready(None))); + .returning(|_, _, _| Box::pin(future::ready(Ok(None)))); // Run the request and check the response let response = send_get_request(db, gh, "/audit/org/repo").await; - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_empty_uncached_error(response, StatusCode::NOT_FOUND).await; } #[tokio::test] async fn audit_disabled_returns_not_found() { - // Setup configuration - let cfg = setup_test_config(); - - // Setup database expectations + // Setup mocks let mut db = MockDB::new(); db.expect_list_votes().never(); - let db = Arc::new(db); - - // Setup GitHub expectations let mut gh = MockGH::new(); - gh.expect_get_repository_installation_id() - .with(eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| { - let config = r" -audit: - enabled: false -profiles: - default: - duration: 1m - pass_threshold: 50 -"; - Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) - }); - let gh = Arc::new(gh); - - // Run the request - let (cmds_tx, _) = async_channel::unbounded(); - let router = setup_router(&cfg, db, gh, cmds_tx); - let response = router - .oneshot(Request::builder().method("GET").uri("/audit/org/repo").body(Body::empty()).unwrap()) - .await - .unwrap(); + expect_audit_cfg(&mut gh, false); - // Check the response - assert_eq!(response.status(), StatusCode::NOT_FOUND); + // Run the request and check the response + let response = send_get_request(db, gh, "/audit/org/repo").await; + assert_empty_uncached_error(response, StatusCode::NOT_FOUND).await; } #[tokio::test] async fn audit_enabled_renders_template() { - // Setup configuration - let cfg = setup_test_config(); - - // Setup database expectations + // Setup mocks let mut db = MockDB::new(); - db.expect_list_votes().with(eq(REPOFN)).times(1).returning({ - let votes = vec![setup_test_vote()]; - move |_| Box::pin(future::ready(Ok(votes.clone()))) + db.expect_list_votes().with(eq(REPOFN)).times(1).returning(|_| { + let votes = vec![setup_test_vote(), setup_test_closed_vote()]; + Box::pin(future::ready(Ok(votes))) }); - let db = Arc::new(db); - - // Setup GitHub expectations let mut gh = MockGH::new(); - gh.expect_get_repository_installation_id() - .with(eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| { - let config = r" -audit: - enabled: true -profiles: - default: - duration: 1m - pass_threshold: 50 -"; - Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) - }); - let gh = Arc::new(gh); + expect_audit_cfg(&mut gh, true); // Run the request - let (cmds_tx, _) = async_channel::unbounded(); - let router = setup_router(&cfg, db, gh, cmds_tx); - let response = router - .oneshot(Request::builder().method("GET").uri("/audit/org/repo").body(Body::empty()).unwrap()) - .await - .unwrap(); + let response = send_get_request(db, gh, "/audit/org/repo").await; // Check the response assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); assert_eq!(response.headers()["cache-control"], "max-age=900"); - assert!(!get_body(response).await.is_empty()); + + // Check the page displays the votes of the repository + let body = get_normalized_body(response).await; + assert!(body.contains("

org/repo

")); + assert!(body.contains("Test title (#1)")); + assert!(body.contains("Closed vote title (#2)")); + assert!(body.contains(r#"Passed"#)); + + // Check the open vote links to the route serving its details + assert!(body.contains(&format!(r#"hx-get="/audit/{ORG}/{REPO}/vote/{VOTE_ID}""#))); + assert!(!body.contains(&format!(r#"hx-get="/audit/{ORG}/{REPO}/vote/{VOTE_ID2}""#))); +} + +#[tokio::test] +async fn audit_error_getting_config_file_returns_internal_server_error() { + // Setup mocks + let mut db = MockDB::new(); + db.expect_list_votes().never(); + let mut gh = MockGH::new(); + expect_installation_id(&mut gh); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run the request and check the failure is not reported as audit being disabled + let response = send_get_request(db, gh, "/audit/org/repo").await; + assert_empty_uncached_error(response, StatusCode::INTERNAL_SERVER_ERROR).await; } #[tokio::test] @@ -163,7 +115,7 @@ async fn audit_error_getting_installation_returns_internal_server_error() { // Run the request and check the response let response = send_get_request(db, gh, "/audit/org/repo").await; - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_empty_uncached_error(response, StatusCode::INTERNAL_SERVER_ERROR).await; } #[tokio::test] @@ -179,7 +131,7 @@ async fn audit_error_listing_votes_returns_internal_server_error() { // Run the request and check the response let response = send_get_request(db, gh, "/audit/org/repo").await; - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_empty_uncached_error(response, StatusCode::INTERNAL_SERVER_ERROR).await; } #[tokio::test] @@ -197,7 +149,7 @@ async fn audit_installation_not_found_returns_not_found() { // Run the request and check the response let response = send_get_request(db, gh, "/audit/org/repo").await; - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_empty_uncached_error(response, StatusCode::NOT_FOUND).await; } #[tokio::test] @@ -210,11 +162,11 @@ async fn audit_invalid_config_returns_internal_server_error() { gh.expect_get_config_file() .with(eq(INST_ID), eq(ORG), eq(REPO)) .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_invalid_config())))); + .returning(|_, _, _| Box::pin(future::ready(Ok(Some(get_test_invalid_config()))))); // Run the request and check the response let response = send_get_request(db, gh, "/audit/org/repo").await; - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_empty_uncached_error(response, StatusCode::INTERNAL_SERVER_ERROR).await; } #[tokio::test] @@ -227,119 +179,115 @@ async fn audit_not_configured_returns_not_found() { gh.expect_get_config_file() .with(eq(INST_ID), eq(ORG), eq(REPO)) .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); + .returning(|_, _, _| Box::pin(future::ready(Ok(Some(get_test_valid_config()))))); // Run the request and check the response let response = send_get_request(db, gh, "/audit/org/repo").await; - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_empty_uncached_error(response, StatusCode::NOT_FOUND).await; +} + +#[tokio::test] +async fn audit_segment_with_encoded_slash_returns_not_found() { + for uri in [ + "/audit/org%2Fother/repo".to_string(), + "/audit/org/repo%2Fother".to_string(), + format!("/audit/org%2Fother/repo/vote/{VOTE_ID}"), + format!("/audit/org/repo%2Fother/vote/{VOTE_ID}"), + ] { + // Setup mocks (no calls expected) + let mut db = MockDB::new(); + db.expect_get_vote().never(); + db.expect_list_votes().never(); + let mut gh = MockGH::new(); + gh.expect_get_repository_installation_id().never(); + + // Run the request and check the response + let response = send_get_request(db, gh, &uri).await; + assert_empty_uncached_error(response, StatusCode::NOT_FOUND).await; + } } #[tokio::test] async fn audit_vote_details_audit_disabled_returns_not_found() { - // Setup configuration - let cfg = setup_test_config(); + // Setup mocks + let mut db = MockDB::new(); + db.expect_get_vote().never(); + let mut gh = MockGH::new(); + expect_audit_cfg(&mut gh, false); + + // Run the request and check the response + let response = send_get_request(db, gh, &format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")).await; + assert_empty_uncached_error(response, StatusCode::NOT_FOUND).await; +} +#[tokio::test] +async fn audit_vote_details_closed_vote_renders_template() { // Setup database expectations let mut db = MockDB::new(); - db.expect_get_vote().never(); - let db = Arc::new(db); + db.expect_get_vote() + .with(eq(Uuid::parse_str(VOTE_ID2).unwrap())) + .times(1) + .returning(|_| Box::pin(future::ready(Ok(Some(setup_test_closed_vote()))))); - // Setup GitHub expectations + // Setup GitHub expectations: the stored results must be used let mut gh = MockGH::new(); - gh.expect_get_repository_installation_id() - .with(eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| { - let config = r" -audit: - enabled: false -profiles: - default: - duration: 1m - pass_threshold: 50 -"; - Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) - }); - let gh = Arc::new(gh); + expect_audit_cfg(&mut gh, true); + gh.expect_get_allowed_voters().never(); + gh.expect_get_comment_reactions().never(); // Run the request - let (cmds_tx, _) = async_channel::unbounded(); - let router = setup_router(&cfg, db, gh, cmds_tx); - let response = router - .oneshot( - Request::builder() - .method("GET") - .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); + let response = send_get_request(db, gh, &format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID2}")).await; // Check the response - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); + assert_eq!(response.headers()["cache-control"], "max-age=900"); + + // Check the fragment displays the stored results + let body = get_normalized_body(response).await; + let closed_at = OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(); + assert!(body.contains("Closed vote title (#2)")); + assert!(body.contains(&closed_at.to_string())); + assert!(body.contains(r#"Passed"#)); + assert_summary_stat(&body, "In favor", 3); + assert_summary_stat(&body, "Against", 2); + assert_summary_stat(&body, "Abstain", 1); + assert_summary_stat(&body, "Not voted", 4); + assert!(body.contains(&format!("@{USER1}"))); } #[tokio::test] -async fn audit_vote_details_closed_vote_renders_template() { - // Setup configuration - let cfg = setup_test_config(); - +async fn audit_vote_details_closed_vote_without_results_renders_notice() { // Setup database expectations let mut db = MockDB::new(); - let vote_id = Uuid::parse_str(VOTE_ID).unwrap(); - db.expect_get_vote().with(eq(vote_id)).times(1).returning(|_| { - let mut vote = setup_test_vote(); - vote.results = Some(setup_test_vote_results()); + db.expect_get_vote().with(eq(Uuid::parse_str(VOTE_ID2).unwrap())).times(1).returning(|_| { + let vote = Vote { + results: None, + ..setup_test_closed_vote() + }; Box::pin(future::ready(Ok(Some(vote)))) }); - let db = Arc::new(db); - // Setup GitHub expectations + // Setup GitHub expectations: results must never be calculated for closed votes let mut gh = MockGH::new(); - gh.expect_get_repository_installation_id() - .with(eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| { - let config = r" -audit: - enabled: true -profiles: - default: - duration: 1m - pass_threshold: 50 -"; - Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) - }); - let gh = Arc::new(gh); + expect_audit_cfg(&mut gh, true); + gh.expect_get_allowed_voters().never(); + gh.expect_get_comment_reactions().never(); // Run the request - let (cmds_tx, _) = async_channel::unbounded(); - let router = setup_router(&cfg, db, gh, cmds_tx); - let response = router - .oneshot( - Request::builder() - .method("GET") - .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); + let response = send_get_request(db, gh, &format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID2}")).await; // Check the response assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); assert_eq!(response.headers()["cache-control"], "max-age=900"); - assert!(!get_body(response).await.is_empty()); + + // Check the fragment explains why there are no results + let body = get_normalized_body(response).await; + assert!(body.contains("Closed vote title (#2)")); + assert!(body.contains("the vote comment was deleted before the vote closed")); + assert!(!body.contains("Passed")); + assert!(!body.contains("Failed")); } #[tokio::test] @@ -359,7 +307,7 @@ async fn audit_vote_details_error_calculating_results_returns_internal_server_er // Run the request and check the response let response = send_get_request(db, gh, &format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")).await; - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_empty_uncached_error(response, StatusCode::INTERNAL_SERVER_ERROR).await; } #[tokio::test] @@ -375,7 +323,7 @@ async fn audit_vote_details_error_checking_audit_returns_internal_server_error() // Run the request and check the response let response = send_get_request(db, gh, &format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")).await; - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_empty_uncached_error(response, StatusCode::INTERNAL_SERVER_ERROR).await; } #[tokio::test] @@ -391,7 +339,7 @@ async fn audit_vote_details_error_getting_vote_returns_internal_server_error() { // Run the request and check the response let response = send_get_request(db, gh, &format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")).await; - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_empty_uncached_error(response, StatusCode::INTERNAL_SERVER_ERROR).await; } #[tokio::test] @@ -403,179 +351,129 @@ async fn audit_vote_details_invalid_vote_id_returns_bad_request() { // Run the request and check the response let response = send_get_request(db, gh, &format!("/audit/{ORG}/{REPO}/vote/invalid")).await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!(!response.headers().contains_key(header::CACHE_CONTROL)); + let body = get_body(response).await; + let body = str::from_utf8(&body).unwrap(); + assert!(body.starts_with("Invalid URL: "), "unexpected body: {body}"); } #[tokio::test] async fn audit_vote_details_open_vote_renders_template() { - // Setup configuration - let cfg = setup_test_config(); - // Setup database expectations let mut db = MockDB::new(); - let vote_id = Uuid::parse_str(VOTE_ID).unwrap(); - db.expect_get_vote().with(eq(vote_id)).times(1).returning(|_| { - let vote = setup_test_vote(); - Box::pin(future::ready(Ok(Some(vote)))) - }); - let db = Arc::new(db); + db.expect_get_vote() + .with(eq(Uuid::parse_str(VOTE_ID).unwrap())) + .times(1) + .returning(|_| Box::pin(future::ready(Ok(Some(setup_test_vote()))))); - // Setup GitHub expectations + // Setup GitHub expectations: the results must be calculated let mut gh = MockGH::new(); - gh.expect_get_repository_installation_id() - .with(eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| { - let config = r" -audit: - enabled: true -profiles: - default: - duration: 1m - pass_threshold: 50 -"; - Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) - }); + expect_audit_cfg(&mut gh, true); gh.expect_get_comment_reactions() .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) .times(1) .returning(|_, _, _, _| Box::pin(future::ready(Ok(vec![])))); + let expected_cfg = setup_test_vote().cfg; gh.expect_get_allowed_voters() + .withf(move |inst_id, cfg, owner, repo, org| { + *inst_id == INST_ID + && *cfg == expected_cfg + && owner == ORG + && repo == REPO + && *org == Some(ORG.to_string()).as_ref() + }) .times(1) .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(vec![USER1.to_string()])))); - let gh = Arc::new(gh); // Run the request - let (cmds_tx, _) = async_channel::unbounded(); - let router = setup_router(&cfg, db, gh, cmds_tx); - let response = router - .oneshot( - Request::builder() - .method("GET") - .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); + let response = send_get_request(db, gh, &format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")).await; // Check the response assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); assert_eq!(response.headers()["cache-control"], "max-age=900"); - assert!(!get_body(response).await.is_empty()); + + // Check the fragment displays the calculated results + let body = get_normalized_body(response).await; + assert!(body.contains(r#"Failed"#)); + assert_summary_stat(&body, "In favor", 0); + assert_summary_stat(&body, "Not voted", 1); + assert!(body.contains(&format!( + r#"@{USER1} Not voted"# + ))); } #[tokio::test] async fn audit_vote_details_vote_not_found() { - // Setup configuration - let cfg = setup_test_config(); - - // Setup database expectations + // Setup mocks let mut db = MockDB::new(); - let vote_id = Uuid::parse_str(VOTE_ID).unwrap(); db.expect_get_vote() - .with(eq(vote_id)) + .with(eq(Uuid::parse_str(VOTE_ID).unwrap())) .times(1) .returning(|_| Box::pin(future::ready(Ok(None)))); - let db = Arc::new(db); - - // Setup GitHub expectations let mut gh = MockGH::new(); - gh.expect_get_repository_installation_id() - .with(eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| { - let config = r" -audit: - enabled: true -profiles: - default: - duration: 1m - pass_threshold: 50 -"; - Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) - }); - let gh = Arc::new(gh); - - // Run the request - let (cmds_tx, _) = async_channel::unbounded(); - let router = setup_router(&cfg, db, gh, cmds_tx); - let response = router - .oneshot( - Request::builder() - .method("GET") - .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); + expect_audit_cfg(&mut gh, true); - // Check the response - assert_eq!(response.status(), StatusCode::NOT_FOUND); + // Run the request and check the response + let response = send_get_request(db, gh, &format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")).await; + assert_empty_uncached_error(response, StatusCode::NOT_FOUND).await; } #[tokio::test] async fn audit_vote_details_vote_wrong_repo_returns_not_found() { - // Setup configuration - let cfg = setup_test_config(); - - // Setup database expectations + // Setup mocks let mut db = MockDB::new(); - let vote_id = Uuid::parse_str(VOTE_ID).unwrap(); - db.expect_get_vote().with(eq(vote_id)).times(1).returning(|_| { + db.expect_get_vote().with(eq(Uuid::parse_str(VOTE_ID).unwrap())).times(1).returning(|_| { let mut vote = setup_test_vote(); vote.repository_full_name = "other/repo".to_string(); Box::pin(future::ready(Ok(Some(vote)))) }); - let db = Arc::new(db); - - // Setup GitHub expectations let mut gh = MockGH::new(); - gh.expect_get_repository_installation_id() - .with(eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| { - let config = r" -audit: - enabled: true -profiles: - default: - duration: 1m - pass_threshold: 50 -"; - Box::pin(future::ready(Some(config.trim_start_matches('\n').to_string()))) - }); - let gh = Arc::new(gh); + expect_audit_cfg(&mut gh, true); - // Run the request - let (cmds_tx, _) = async_channel::unbounded(); - let router = setup_router(&cfg, db, gh, cmds_tx); - let response = router - .oneshot( - Request::builder() - .method("GET") - .uri(format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); + // Run the request and check the response + let response = send_get_request(db, gh, &format!("/audit/{ORG}/{REPO}/vote/{VOTE_ID}")).await; + assert_empty_uncached_error(response, StatusCode::NOT_FOUND).await; +} - // Check the response - assert_eq!(response.status(), StatusCode::NOT_FOUND); +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_set_check_status_pr_synchronized_during_pending_cancellation() { + // Setup cases: whether the check run step of the cancellation is done, which hands + // the check of the heads pushed afterwards back to the pull request + for check_run_done in [false, true] { + // Setup database with a vote on the pull request whose cancellation is pending + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + is_pull_request: true, + ..Default::default() + }) + .await; + db.cancel_vote(REPOFN, ISSUE_NUM, USER1).await.unwrap().unwrap(); + if check_run_done { + db.record_vote_close_step(vote_id, VoteCloseStep::CheckRun).await.unwrap(); + } + + // Setup GitHub expectations: the check is reported once the cancellation's is + let mut gh = MockGH::new(); + expect_is_check_required(&mut gh, true); + if check_run_done { + expect_no_config(&mut gh); + expect_check_run(&mut gh, cmd::no_vote_found_check()); + } else { + gh.expect_get_config_file().never(); + gh.expect_create_check_run().never(); + } + + // Setup the synchronized pull request event + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Synchronize; + + // Run and check the result + set_check_status(Arc::new(db), Arc::new(gh), &event).await.unwrap(); + } } #[tokio::test] @@ -600,10 +498,7 @@ async fn event_invalid_body() { // Check the response assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!( - get_body(response).await, - "invalid body: key must be a string at line 1 column 2", - ); + assert!(get_body(response).await.starts_with(b"invalid body: ")); } #[tokio::test] @@ -628,12 +523,12 @@ async fn event_invalid_signature() { // Check the response assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!(get_body(response).await, "no valid signature found",); + assert_eq!(get_body(response).await, "no valid signature found"); } #[tokio::test] async fn event_issue_with_cmd() { - // Setup event payload + // Setup router and event payload let (router, cmds_rx) = setup_test_router(); let mut event = setup_test_issue_event(); event.action = IssueEventAction::Opened; @@ -700,27 +595,437 @@ async fn event_no_signature() { // Check the response assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!(get_body(response).await, "no valid signature found",); + assert_eq!(get_body(response).await, "no valid signature found"); } #[tokio::test] -async fn event_pr_without_cmd_check_not_required() { - // Setup mocks - let cfg = setup_test_config(); +async fn event_pr_automation_failure_error_checking_vote_still_reports_check_and_comment() { + // Setup mocks: the failure is reported as the vote lookup fails let mut db = MockDB::new(); - db.expect_has_vote().never(); + expect_has_vote(&mut db, Err(format_err!(ERROR))); let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(None))); - gh.expect_is_check_required() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(false)))); - gh.expect_create_check_run().never(); - let (cmds_tx, cmds_rx) = async_channel::unbounded(); - let router = setup_router(&cfg, Arc::new(db), Arc::new(gh), cmds_tx); + expect_invalid_config(&mut gh); + expect_check_run(&mut gh, cmd::automation_failed_check()); + expect_automation_failed_comment(&mut gh, || Ok(COMMENT_ID)); + + // Setup router + let (router, cmds_rx) = setup_test_router_with_mocks(db, gh); + + // Run the request + let response = send_pr_event(router, &setup_test_pr_opened_event("Hi!")).await; + + // Check the response and no vote was started + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(get_body(response).await, "automation failure reported"); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn event_pr_automation_failure_error_creating_check_run_still_posts_comment() { + // Setup mocks + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + let mut gh = MockGH::new(); + expect_invalid_config(&mut gh); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(cmd::automation_failed_check()), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_automation_failed_comment(&mut gh, || Ok(COMMENT_ID)); + + // Setup router + let (router, cmds_rx) = setup_test_router_with_mocks(db, gh); + + // Run the request + let response = send_pr_event(router, &setup_test_pr_opened_event("Hi!")).await; + + // Check the response + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(get_body(response).await, ""); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn event_pr_automation_failure_error_posting_comment() { + // Setup mocks + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + let mut gh = MockGH::new(); + expect_invalid_config(&mut gh); + expect_check_run(&mut gh, cmd::automation_failed_check()); + expect_automation_failed_comment(&mut gh, || Err(format_err!(ERROR))); + + // Setup router + let (router, cmds_rx) = setup_test_router_with_mocks(db, gh); + + // Run the request + let response = send_pr_event(router, &setup_test_pr_opened_event("Hi!")).await; + + // Check the response + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(get_body(response).await, ""); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn event_pr_automation_failure_getting_files_reports_check_and_comment() { + // Setup mocks + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + gh.expect_get_pr_files() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_is_check_required().never(); + expect_check_run(&mut gh, cmd::automation_failed_check()); + expect_automation_failed_comment(&mut gh, || Ok(COMMENT_ID)); + + // Setup router + let (router, cmds_rx) = setup_test_router_with_mocks(db, gh); + + // Run the request + let response = send_pr_event(router, &setup_test_pr_opened_event("Hi!")).await; + + // Check the response and no vote was started + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(get_body(response).await, "automation failure reported"); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn event_pr_automation_failure_invalid_config_reports_check_and_comment() { + // Setup mocks + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + let mut gh = MockGH::new(); + expect_invalid_config(&mut gh); + gh.expect_get_pr_files().never(); + gh.expect_is_check_required().never(); + expect_check_run(&mut gh, cmd::automation_failed_check()); + expect_automation_failed_comment(&mut gh, || Ok(COMMENT_ID)); + + // Setup router + let (router, cmds_rx) = setup_test_router_with_mocks(db, gh); + + // Run the request + let response = send_pr_event(router, &setup_test_pr_opened_event("Hi!")).await; + + // Check the response and no vote was started + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(get_body(response).await, "automation failure reported"); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn event_pr_automation_failure_with_vote_reports_nothing() { + // Setup mocks: the pull request has a vote, like when its opened event is replayed + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(true)); + let mut gh = MockGH::new(); + expect_invalid_config(&mut gh); + gh.expect_create_check_run().never(); + gh.expect_post_comment().never(); + + // Setup router + let (router, cmds_rx) = setup_test_router_with_mocks(db, gh); + + // Run the request + let response = send_pr_event(router, &setup_test_pr_opened_event("Hi!")).await; + + // Check the response and no vote was started + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(get_body(response).await, "automation failure reported"); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn event_pr_synchronized_rule_matches_reports_failure_without_starting_vote() { + // Setup mocks: the vote is looked up before evaluating the rules and again right + // before reporting the check + let mut db = MockDB::new(); + db.expect_has_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(2) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + let mut gh = MockGH::new(); + expect_is_check_required(&mut gh, true); + expect_valid_config(&mut gh); + expect_pr_files(&mut gh, "README.md"); + expect_check_run(&mut gh, setup_test_rule_matched_check()); + + // Setup router + let (router, cmds_rx) = setup_test_router_with_mocks(db, gh); + + // Setup the synchronized pull request event + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Synchronize; + + // Run the request + let response = send_pr_event(router, &event).await; + + // Check the response and no vote was started + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(get_body(response).await, "no command detected"); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn event_pr_with_automatic_cmd_does_not_set_check_status() { + // Setup mocks + let mut db = MockDB::new(); + db.expect_has_vote().never(); + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_pr_files(&mut gh, "README.md"); + gh.expect_is_check_required().never(); + gh.expect_create_check_run().never(); + + // Setup router + let (router, cmds_rx) = setup_test_router_with_mocks(db, gh); + + // Run the request + let event = setup_test_pr_opened_event("Hi!"); + let response = send_pr_event(router, &event).await; + + // Check the response and the command queued + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(get_body(response).await, "command queued"); + assert_eq!( + cmds_rx.recv().await.unwrap(), + Command::CreateVote(CreateVoteInput::new_automatic( + "default", + &Event::PullRequest(event) + )) + ); +} + +#[tokio::test] +async fn event_pr_with_cancel_vote_cmd_sets_check_status() { + // Setup mocks + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + let mut gh = MockGH::new(); + expect_is_check_required(&mut gh, true); + expect_no_config(&mut gh); + expect_check_run(&mut gh, cmd::no_vote_found_check()); + + // Setup router + let (router, cmds_rx) = setup_test_router_with_mocks(db, gh); + + // Run the request + let event = setup_test_pr_opened_event("/cancel-vote"); + let response = send_pr_event(router, &event).await; + + // Check the response and the command queued + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(get_body(response).await, "command queued"); + assert_eq!( + cmds_rx.recv().await.unwrap(), + Command::CancelVote(CancelVoteInput::new(&Event::PullRequest(event))) + ); +} + +#[tokio::test] +async fn event_pr_with_check_vote_cmd_check_not_required() { + // Setup mocks + let mut db = MockDB::new(); + db.expect_has_vote().never(); + let mut gh = MockGH::new(); + expect_is_check_required(&mut gh, false); + gh.expect_get_config_file().never(); + gh.expect_create_check_run().never(); + + // Setup router + let (router, cmds_rx) = setup_test_router_with_mocks(db, gh); + + // Run the request + let event = setup_test_pr_opened_event("/check-vote"); + let response = send_pr_event(router, &event).await; + + // Check the response and the command queued + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(get_body(response).await, "command queued"); + assert_eq!( + cmds_rx.recv().await.unwrap(), + Command::CheckVote(CheckVoteInput::new(&Event::PullRequest(event))) + ); +} + +#[tokio::test] +async fn event_pr_with_check_vote_cmd_error_checking_vote() { + // Setup mocks + let mut db = MockDB::new(); + expect_has_vote(&mut db, Err(format_err!(ERROR))); + let mut gh = MockGH::new(); + expect_is_check_required(&mut gh, true); + expect_no_config(&mut gh); + gh.expect_create_check_run().never(); + + // Setup router + let (router, cmds_rx) = setup_test_router_with_mocks(db, gh); + + // Run the request + let response = send_pr_event(router, &setup_test_pr_opened_event("/check-vote")).await; + + // Check the response and the command was left for the redelivery of the event + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(get_body(response).await, ""); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn event_pr_with_check_vote_cmd_error_setting_check_status() { + // Setup mocks + let mut db = MockDB::new(); + db.expect_has_vote().never(); + let mut gh = MockGH::new(); + gh.expect_is_check_required() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_create_check_run().never(); + + // Setup router + let (router, cmds_rx) = setup_test_router_with_mocks(db, gh); + + // Run the request + let response = send_pr_event(router, &setup_test_pr_opened_event("/check-vote")).await; + + // Check the response and the command was left for the redelivery of the event + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(get_body(response).await, ""); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn event_pr_with_check_vote_cmd_rule_matches_reports_failure() { + // Setup mocks + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + let mut gh = MockGH::new(); + expect_is_check_required(&mut gh, true); + expect_valid_config(&mut gh); + expect_pr_files(&mut gh, "README.md"); + expect_check_run(&mut gh, setup_test_rule_matched_check()); + + // Setup router + let (router, cmds_rx) = setup_test_router_with_mocks(db, gh); + + // Run the request + let event = setup_test_pr_opened_event("/check-vote"); + let response = send_pr_event(router, &event).await; + + // Check the response and the command queued + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(get_body(response).await, "command queued"); + assert_eq!( + cmds_rx.recv().await.unwrap(), + Command::CheckVote(CheckVoteInput::new(&Event::PullRequest(event))) + ); +} + +#[tokio::test] +async fn event_pr_with_create_vote_cmd_does_not_set_check_status() { + // Setup mocks + let mut db = MockDB::new(); + db.expect_has_vote().never(); + let mut gh = MockGH::new(); + gh.expect_is_check_required().never(); + gh.expect_create_check_run().never(); + + // Setup router + let (router, cmds_rx) = setup_test_router_with_mocks(db, gh); + + // Run the request + let response = send_pr_event(router, &setup_test_pr_opened_event("/vote")).await; + + // Check the response and the command queued + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(get_body(response).await, "command queued"); + assert_eq!( + cmds_rx.recv().await.unwrap(), + Command::CreateVote(CreateVoteInput { + created_by: USER.to_string(), + installation_id: INST_ID as i64, + is_automatic: false, + is_pull_request: true, + issue_id: ISSUE_ID, + issue_number: ISSUE_NUM, + issue_title: TITLE.to_string(), + repository_full_name: REPOFN.to_string(), + + opened_pr_base_branch: Some(BRANCH.to_string()), + organization: Some(ORG.to_string()), + profile_name: None, + }) + ); +} + +#[tokio::test] +async fn event_pr_with_no_vote_cmd_with_vote_does_not_set_check_status() { + // Setup cases: a vote created or replayed concurrently owns the check of the pull + // request, whatever command it was sent with + type ExpectedCmd = fn(&Event) -> Command; + let cases: [(&str, ExpectedCmd); 2] = [ + ("/cancel-vote", |event| { + Command::CancelVote(CancelVoteInput::new(event)) + }), + ("/check-vote", |event| { + Command::CheckVote(CheckVoteInput::new(event)) + }), + ]; + for (body, expected_cmd) in cases { + // Setup mocks + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(true)); + let mut gh = MockGH::new(); + expect_is_check_required(&mut gh, true); + expect_no_config(&mut gh); + gh.expect_create_check_run().never(); + + // Setup router + let (router, cmds_rx) = setup_test_router_with_mocks(db, gh); + + // Run the request + let event = setup_test_pr_opened_event(body); + let response = send_pr_event(router, &event).await; + + // Check the response and the command queued + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(get_body(response).await, "command queued"); + assert_eq!( + cmds_rx.recv().await.unwrap(), + expected_cmd(&Event::PullRequest(event)) + ); + } +} + +#[tokio::test] +async fn event_pr_without_cmd_check_not_required() { + // Setup mocks + let mut db = MockDB::new(); + db.expect_has_vote().never(); + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Ok(None)))); + gh.expect_is_check_required() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(false)))); + gh.expect_create_check_run().never(); + + // Setup router + let (router, cmds_rx) = setup_test_router_with_mocks(db, gh); // Run the request let body = fs::read(Path::new(TESTDATA_PATH).join("event-pr-no-cmd.json")).unwrap(); @@ -746,39 +1051,67 @@ async fn event_pr_without_cmd_check_not_required() { #[tokio::test] async fn event_pr_without_cmd_set_check_status_failed() { // Setup mocks - let cfg = setup_test_config(); - let db = Arc::new(MockDB::new()); + let mut db = MockDB::new(); + db.expect_has_vote().never(); let mut gh = MockGH::new(); gh.expect_get_config_file() .with(eq(INST_ID), eq(ORG), eq(REPO)) .times(1) - .returning(|_, _, _| Box::pin(future::ready(None))); + .returning(|_, _, _| Box::pin(future::ready(Ok(None)))); gh.expect_is_check_required() .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) .times(1) .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - let gh = Arc::new(gh); - let (cmds_tx, cmds_rx) = async_channel::unbounded(); - let router = setup_router(&cfg, db, gh, cmds_tx); + gh.expect_create_check_run().never(); + + // Setup router + let (router, cmds_rx) = setup_test_router_with_mocks(db, gh); + + // Run the request + let body = fs::read(Path::new(TESTDATA_PATH).join("event-pr-no-cmd.json")).unwrap(); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/api/events") + .header(GITHUB_EVENT_HEADER, "pull_request") + .header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice())) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + // Check the response + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(get_body(response).await, ""); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn event_signature_from_other_secret_is_rejected() { + // Setup router and a well formed signature made with another secret + let (router, cmds_rx) = setup_test_router(); + let body = fs::read(Path::new(TESTDATA_PATH).join("event-cmd.json")).unwrap(); + let signature = generate_signature_with_secret(b"other-secret", &body); // Run the request - let body = fs::read(Path::new(TESTDATA_PATH).join("event-pr-no-cmd.json")).unwrap(); let response = router .oneshot( Request::builder() .method("POST") .uri("/api/events") - .header(GITHUB_EVENT_HEADER, "pull_request") - .header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice())) + .header(GITHUB_EVENT_HEADER, "issue_comment") + .header(GITHUB_SIGNATURE_HEADER, signature) .body(Body::from(body)) .unwrap(), ) .await .unwrap(); - // Check the response - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); - assert_eq!(get_body(response).await, "",); + // Check the response and that no command was queued + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(get_body(response).await, "no valid signature found"); assert!(cmds_rx.is_empty()); } @@ -864,15 +1197,18 @@ async fn event_with_cmd() { assert_eq!( cmds_rx.recv().await.unwrap(), Command::CreateVote(CreateVoteInput { - profile_name: None, created_by: USER.to_string(), installation_id: INST_ID as i64, + is_automatic: false, + is_pull_request: false, issue_id: ISSUE_ID, issue_number: ISSUE_NUM, issue_title: TITLE.to_string(), - is_pull_request: false, repository_full_name: REPOFN.to_string(), + + opened_pr_base_branch: None, organization: Some(ORG.to_string()), + profile_name: None, }) ); } @@ -903,15 +1239,18 @@ async fn event_with_cmd_with_profile() { assert_eq!( cmds_rx.recv().await.unwrap(), Command::CreateVote(CreateVoteInput { - profile_name: Some(PROFILE_NAME.to_string()), created_by: USER.to_string(), installation_id: INST_ID as i64, + is_automatic: false, + is_pull_request: false, issue_id: ISSUE_ID, issue_number: ISSUE_NUM, issue_title: TITLE.to_string(), - is_pull_request: false, repository_full_name: REPOFN.to_string(), + + opened_pr_base_branch: None, organization: Some(ORG.to_string()), + profile_name: Some(PROFILE_NAME.to_string()), }) ); } @@ -942,10 +1281,32 @@ async fn event_without_cmd() { assert!(cmds_rx.is_empty()); } +#[tokio::test] +async fn index() { + // Setup router + let (router, _) = setup_test_router(); + + // Run the request + let response = router + .oneshot(Request::builder().method("GET").uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + // Check the response + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); + assert_eq!( + get_body(response).await, + fs::read_to_string("templates/index.html").unwrap().trim_end_matches('\n') + ); +} + #[tokio::test] async fn set_check_status_pr_opened_check_required() { // Setup mocks - let db = Arc::new(MockDB::new()); + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + let db = Arc::new(db); let mut gh = MockGH::new(); gh.expect_is_check_required() .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) @@ -959,13 +1320,16 @@ async fn set_check_status_pr_opened_check_required() { eq(ISSUE_NUM), eq(CheckDetails { status: "completed".to_string(), - conclusion: Some("success".to_string()), summary: "No vote found".to_string(), + + conclusion: Some("success".to_string()), }), ) .times(1) .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); let gh = Arc::new(gh); + + // Setup the opened pull request event let mut event = setup_test_pr_event(); event.action = PullRequestEventAction::Opened; @@ -973,22 +1337,66 @@ async fn set_check_status_pr_opened_check_required() { assert!(set_check_status(db, gh, &event).await.is_ok()); } +#[tokio::test] +async fn set_check_status_pr_opened_check_required_with_vote() { + // Setup mocks: the automation rules are not evaluated again + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(true)); + let mut gh = MockGH::new(); + expect_is_check_required(&mut gh, true); + gh.expect_get_config_file().never(); + gh.expect_create_check_run().never(); + + // Setup the opened pull request event + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Opened; + + // Run and check nothing is reported + set_check_status(Arc::new(db), Arc::new(gh), &event).await.unwrap(); +} + +#[tokio::test] +async fn set_check_status_pr_opened_error_checking_vote() { + // Setup mocks + let mut db = MockDB::new(); + expect_has_vote(&mut db, Err(format_err!(ERROR))); + let mut gh = MockGH::new(); + expect_is_check_required(&mut gh, true); + gh.expect_create_check_run().never(); + + // Setup the opened pull request event + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Opened; + + // Run and check the error is propagated + let err = set_check_status(Arc::new(db), Arc::new(gh), &event).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + #[tokio::test] async fn set_check_status_pr_opened_error_creating_check_run() { // Setup mocks - let db = Arc::new(MockDB::new()); + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + let db = Arc::new(db); let mut gh = MockGH::new(); gh.expect_is_check_required() .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) .times(1) .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); gh.expect_create_check_run() - .withf(|inst_id, owner, repo, issue_number, _| { - *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM - }) + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(cmd::no_vote_found_check()), + ) .times(1) .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); let gh = Arc::new(gh); + + // Setup the opened pull request event let mut event = setup_test_pr_event(); event.action = PullRequestEventAction::Opened; @@ -1000,30 +1408,41 @@ async fn set_check_status_pr_opened_error_creating_check_run() { #[tokio::test] async fn set_check_status_pr_opened_is_check_required_failed() { // Setup mocks - let db = Arc::new(MockDB::new()); + let mut db = MockDB::new(); + db.expect_has_vote().never(); + let db = Arc::new(db); let mut gh = MockGH::new(); gh.expect_is_check_required() .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) .times(1) .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_create_check_run().never(); let gh = Arc::new(gh); + + // Setup the opened pull request event let mut event = setup_test_pr_event(); event.action = PullRequestEventAction::Opened; // Run and check the error is returned - assert!(set_check_status(db, gh, &event).await.is_err()); + let err = set_check_status(db, gh, &event).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); } #[tokio::test] async fn set_check_status_pr_opened_no_check_required() { // Setup mocks - let db = Arc::new(MockDB::new()); + let mut db = MockDB::new(); + db.expect_has_vote().never(); + let db = Arc::new(db); let mut gh = MockGH::new(); gh.expect_is_check_required() .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) .times(1) .returning(|_, _, _, _| Box::pin(future::ready(Ok(false)))); + gh.expect_create_check_run().never(); let gh = Arc::new(gh); + + // Setup the opened pull request event let mut event = setup_test_pr_event(); event.action = PullRequestEventAction::Opened; @@ -1033,7 +1452,7 @@ async fn set_check_status_pr_opened_no_check_required() { #[tokio::test] async fn set_check_status_pr_synchronized_check_required_with_vote() { - // Setup mocks + // Setup mocks: the automation rules are not evaluated let mut db = MockDB::new(); db.expect_has_vote() .with(eq(REPOFN), eq(ISSUE_NUM)) @@ -1045,7 +1464,11 @@ async fn set_check_status_pr_synchronized_check_required_with_vote() { .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) .times(1) .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + gh.expect_get_config_file().never(); + gh.expect_create_check_run().never(); let gh = Arc::new(gh); + + // Setup the synchronized pull request event let mut event = setup_test_pr_event(); event.action = PullRequestEventAction::Synchronize; @@ -1055,11 +1478,12 @@ async fn set_check_status_pr_synchronized_check_required_with_vote() { #[tokio::test] async fn set_check_status_pr_synchronized_check_required_without_vote() { - // Setup mocks + // Setup mocks: the vote is looked up before evaluating the rules and again right + // before reporting the check let mut db = MockDB::new(); db.expect_has_vote() .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) + .times(2) .returning(|_, _| Box::pin(future::ready(Ok(false)))); let db = Arc::new(db); let mut gh = MockGH::new(); @@ -1067,6 +1491,7 @@ async fn set_check_status_pr_synchronized_check_required_without_vote() { .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) .times(1) .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + expect_no_config(&mut gh); gh.expect_create_check_run() .with( eq(INST_ID), @@ -1075,13 +1500,16 @@ async fn set_check_status_pr_synchronized_check_required_without_vote() { eq(ISSUE_NUM), eq(CheckDetails { status: "completed".to_string(), - conclusion: Some("success".to_string()), summary: "No vote found".to_string(), + + conclusion: Some("success".to_string()), }), ) .times(1) .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); let gh = Arc::new(gh); + + // Setup the synchronized pull request event let mut event = setup_test_pr_event(); event.action = PullRequestEventAction::Synchronize; @@ -1091,7 +1519,7 @@ async fn set_check_status_pr_synchronized_check_required_without_vote() { #[tokio::test] async fn set_check_status_pr_synchronized_error_checking_vote() { - // Setup mocks + // Setup mocks: the automation rules are not evaluated let mut db = MockDB::new(); db.expect_has_vote() .with(eq(REPOFN), eq(ISSUE_NUM)) @@ -1103,8 +1531,11 @@ async fn set_check_status_pr_synchronized_error_checking_vote() { .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) .times(1) .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + gh.expect_get_config_file().never(); gh.expect_create_check_run().never(); let gh = Arc::new(gh); + + // Setup the synchronized pull request event let mut event = setup_test_pr_event(); event.action = PullRequestEventAction::Synchronize; @@ -1113,16 +1544,48 @@ async fn set_check_status_pr_synchronized_error_checking_vote() { assert_eq!(err.to_string(), ERROR); } +#[tokio::test] +async fn set_check_status_pr_synchronized_evaluation_fails_reports_failure() { + // Setup mocks: the vote is looked up before evaluating the rules and again right + // before reporting the check + let mut db = MockDB::new(); + db.expect_has_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(2) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + let mut gh = MockGH::new(); + expect_is_check_required(&mut gh, true); + expect_valid_config(&mut gh); + gh.expect_get_pr_files() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_check_run(&mut gh, cmd::automation_failed_check()); + gh.expect_post_comment().never(); + + // Setup the synchronized pull request event + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Synchronize; + + // Run and check the result + set_check_status(Arc::new(db), Arc::new(gh), &event).await.unwrap(); +} + #[tokio::test] async fn set_check_status_pr_synchronized_no_check_required() { // Setup mocks - let db = Arc::new(MockDB::new()); + let mut db = MockDB::new(); + db.expect_has_vote().never(); + let db = Arc::new(db); let mut gh = MockGH::new(); gh.expect_is_check_required() .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) .times(1) .returning(|_, _, _, _| Box::pin(future::ready(Ok(false)))); + gh.expect_create_check_run().never(); let gh = Arc::new(gh); + + // Setup the synchronized pull request event let mut event = setup_test_pr_event(); event.action = PullRequestEventAction::Synchronize; @@ -1130,11 +1593,61 @@ async fn set_check_status_pr_synchronized_no_check_required() { assert!(set_check_status(db, gh, &event).await.is_ok()); } +#[tokio::test] +async fn set_check_status_pr_synchronized_no_rule_matches_reports_success() { + // Setup mocks: the vote is looked up before evaluating the rules and again right + // before reporting the check + let mut db = MockDB::new(); + db.expect_has_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(2) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + let mut gh = MockGH::new(); + expect_is_check_required(&mut gh, true); + expect_valid_config(&mut gh); + expect_pr_files(&mut gh, "main.rs"); + expect_check_run(&mut gh, cmd::no_vote_found_check()); + + // Setup the synchronized pull request event + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Synchronize; + + // Run and check the result + set_check_status(Arc::new(db), Arc::new(gh), &event).await.unwrap(); +} + +#[tokio::test] +async fn set_check_status_pr_synchronized_vote_created_while_evaluating() { + // Setup mocks: a vote is created while the automation rules are evaluated + let mut db = MockDB::new(); + let mut seq = Sequence::new(); + for has_vote in [false, true] { + db.expect_has_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .in_sequence(&mut seq) + .returning(move |_, _| Box::pin(future::ready(Ok(has_vote)))); + } + let mut gh = MockGH::new(); + expect_is_check_required(&mut gh, true); + expect_no_config(&mut gh); + gh.expect_create_check_run().never(); + + // Setup the synchronized pull request event + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Synchronize; + + // Run and check nothing is reported + set_check_status(Arc::new(db), Arc::new(gh), &event).await.unwrap(); +} + #[tokio::test] async fn set_check_status_unsupported_pr_action() { - // Setup event + // Setup mocks (no calls expected) let db = Arc::new(MockDB::new()); let gh = Arc::new(MockGH::new()); + + // Setup the pull request event with an unsupported action let mut event = setup_test_pr_event(); event.action = PullRequestEventAction::Other; @@ -1148,7 +1661,8 @@ fn verify_signature_invalid_hex() { let signature = HeaderValue::from_static("sha256=zz"); // Check the signature is rejected - assert!(verify_signature(Some(&signature), b"secret", None, b"body").is_err()); + let err = verify_signature(Some(&signature), b"secret", None, b"body").unwrap_err(); + assert_eq!(err.to_string(), "no valid signature found"); } #[test] @@ -1156,8 +1670,9 @@ fn verify_signature_invalid_with_fallback() { // Setup signature let signature = HeaderValue::from_str(&generate_signature(b"body")).unwrap(); - // Check the signature is rejected - assert!(verify_signature(Some(&signature), b"new-secret", Some(b"old-secret"), b"body").is_err()); + // Check the signature does not match any of the secrets + let err = verify_signature(Some(&signature), b"new-secret", Some(b"old-secret"), b"body").unwrap_err(); + assert!(err.is::(), "unexpected error: {err}"); } #[test] @@ -1165,13 +1680,15 @@ fn verify_signature_invalid_without_fallback() { // Setup signature let signature = HeaderValue::from_str(&generate_signature(b"body")).unwrap(); - // Check the signature is rejected - assert!(verify_signature(Some(&signature), b"new-secret", None, b"body").is_err()); + // Check the signature does not match the secret + let err = verify_signature(Some(&signature), b"new-secret", None, b"body").unwrap_err(); + assert!(err.is::(), "unexpected error: {err}"); } #[test] fn verify_signature_missing() { - assert!(verify_signature(None, b"secret", None, b"body").is_err()); + let err = verify_signature(None, b"secret", None, b"body").unwrap_err(); + assert_eq!(err.to_string(), "no valid signature found"); } #[test] @@ -1181,7 +1698,8 @@ fn verify_signature_missing_prefix() { let signature = HeaderValue::from_str(signature.trim_start_matches("sha256=")).unwrap(); // Check the signature is rejected - assert!(verify_signature(Some(&signature), b"secret", None, b"body").is_err()); + let err = verify_signature(Some(&signature), b"secret", None, b"body").unwrap_err(); + assert_eq!(err.to_string(), "no valid signature found"); } #[test] @@ -1190,7 +1708,8 @@ fn verify_signature_tampered_body() { let signature = HeaderValue::from_str(&generate_signature(b"body")).unwrap(); // Check the tampered body is rejected - assert!(verify_signature(Some(&signature), b"secret", None, b"tampered").is_err()); + let err = verify_signature(Some(&signature), b"secret", None, b"tampered").unwrap_err(); + assert!(err.is::(), "unexpected error: {err}"); } #[test] @@ -1213,41 +1732,21 @@ fn verify_signature_valid_primary() { // Helpers. -/// Setup a router with default mocks, returning it with the commands receiver. -fn setup_test_router() -> (Router, Receiver) { - let cfg = setup_test_config(); - let db = Arc::new(MockDB::new()); - let gh = Arc::new(MockGH::new()); - let (cmds_tx, cmds_rx) = async_channel::unbounded(); - (setup_router(&cfg, db, gh, cmds_tx), cmds_rx) -} - -/// Setup a service configuration for tests. -fn setup_test_config() -> Cfg { - Figment::new() - .merge(Serialized::default("addr", "127.0.0.1:9000")) - .merge(Serialized::default("db.host", "127.0.0.1")) - .merge(Serialized::default("log.format", "pretty")) - .merge(Serialized::default("github.appId", 1234)) - .merge(Serialized::default("github.appPrivateKey", "key")) - .merge(Serialized::default("github.webhookSecret", "secret")) - .extract() - .unwrap() -} - -/// Read the body of the response provided. -async fn get_body(response: Response) -> Bytes { - to_bytes(response.into_body(), usize::MAX).await.unwrap() +/// Checks the response is an error with the status provided, an empty body and +/// no cache headers. +async fn assert_empty_uncached_error(response: Response, status: StatusCode) { + assert_eq!(response.status(), status); + assert!(!response.headers().contains_key(header::CACHE_CONTROL)); + assert!(get_body(response).await.is_empty()); } -/// Generate the signature of the body provided using the test webhook secret. -fn generate_signature(body: &[u8]) -> String { - let mut mac = Hmac::::new_from_slice(b"secret").unwrap(); - mac.update(body); - format!("sha256={}", hex::encode(mac.finalize().into_bytes())) +/// Checks the normalized vote details provided display the summary value. +fn assert_summary_stat(body: &str, label: &str, value: i64) { + let stat = format!(r#"
{label}
{value}
"#); + assert!(body.contains(&stat), "summary should include {stat}"); } -/// Expect the repository installation and configuration file to be requested +/// Expects the repository installation and configuration file to be requested /// once, returning a configuration with the audit page enabled or disabled. fn expect_audit_cfg(gh: &mut MockGH, audit_enabled: bool) { expect_installation_id(gh); @@ -1258,11 +1757,26 @@ fn expect_audit_cfg(gh: &mut MockGH, audit_enabled: bool) { let config = format!( "audit:\n enabled: {audit_enabled}\nprofiles:\n default:\n duration: 1m\n pass_threshold: 50\n" ); - Box::pin(future::ready(Some(config))) + Box::pin(future::ready(Ok(Some(config)))) }); } -/// Expect the repository installation id to be requested once. +/// Expects the automation failed comment to be posted once, with the result provided. +fn expect_automation_failed_comment(gh: &mut MockGH, result: fn() -> Result) { + let expected_body = tmpl::AutomationFailed::new(None).render().unwrap(); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body + }) + .times(1) + .returning(move |_, _, _, _, _| Box::pin(future::ready(result()))); +} + +/// Expects the repository installation id to be requested once. fn expect_installation_id(gh: &mut MockGH) { gh.expect_get_repository_installation_id() .with(eq(ORG), eq(REPO)) @@ -1270,7 +1784,53 @@ fn expect_installation_id(gh: &mut MockGH) { .returning(|_, _| Box::pin(future::ready(Ok(INST_ID)))); } -/// Send a GET request to the router built with the mocks provided. +/// Expects the invalid test configuration file to be requested once. +fn expect_invalid_config(gh: &mut MockGH) { + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Ok(Some(get_test_invalid_config()))))); +} + +/// Expects the pull request files to be requested once, returning the file provided. +fn expect_pr_files(gh: &mut MockGH, filename: &'static str) { + gh.expect_get_pr_files() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) + .times(1) + .returning(move |_, _, _, _| { + let file = File { + filename: filename.to_string(), + }; + Box::pin(future::ready(Ok(vec![file]))) + }); +} + +/// Generates the signature of the body provided using the test webhook secret. +fn generate_signature(body: &[u8]) -> String { + generate_signature_with_secret(b"secret", body) +} + +/// Generates the signature of the body provided using the secret provided. +fn generate_signature_with_secret(secret: &[u8], body: &[u8]) -> String { + let mut mac = Hmac::::new_from_slice(secret).unwrap(); + mac.update(body); + format!("sha256={}", hex::encode(mac.finalize().into_bytes())) +} + +/// Reads the body of the response provided. +async fn get_body(response: Response) -> Bytes { + to_bytes(response.into_body(), usize::MAX).await.unwrap() +} + +/// Reads the body of the response provided, collapsing whitespace runs so that +/// assertions do not depend on the template indentation. +async fn get_normalized_body(response: Response) -> String { + let body = get_body(response).await; + let body = str::from_utf8(&body).unwrap(); + body.split_whitespace().collect::>().join(" ") +} + +/// Sends a GET request to the router built with the mocks provided. async fn send_get_request(db: MockDB, gh: MockGH, uri: &str) -> Response { let (cmds_tx, _) = async_channel::unbounded(); let router = setup_router(&setup_test_config(), Arc::new(db), Arc::new(gh), cmds_tx); @@ -1279,3 +1839,96 @@ async fn send_get_request(db: MockDB, gh: MockGH, uri: &str) -> Response { .await .unwrap() } + +/// Sends a signed pull request event to the router provided. +async fn send_pr_event(router: Router, event: &PullRequestEvent) -> Response { + let body = serde_json::to_vec(event).unwrap(); + router + .oneshot( + Request::builder() + .method("POST") + .uri("/api/events") + .header(GITHUB_EVENT_HEADER, "pull_request") + .header(GITHUB_SIGNATURE_HEADER, generate_signature(body.as_slice())) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap() +} + +/// Sets up a vote as left by the votes closer, with distinct stored results. +fn setup_test_closed_vote() -> Vote { + let closed_at = OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(); + Vote { + closed: true, + created_at: closed_at - Duration::days(1), + ends_at: closed_at, + issue_number: 2, + vote_id: Uuid::parse_str(VOTE_ID2).unwrap(), + + closed_at: Some(closed_at), + issue_title: Some("Closed vote title".to_string()), + results: Some(VoteResults { + abstain: 1, + against: 2, + against_percentage: 20.0, + allowed_voters: 10, + binding: 6, + in_favor: 3, + in_favor_percentage: 30.0, + not_voted: 4, + pass_threshold: 25.0, + passed: true, + ..setup_test_vote_results() + }), + ..setup_test_vote() + } +} + +/// Sets up a service configuration for tests. +fn setup_test_config() -> Cfg { + Figment::new() + .merge(Serialized::default("db.host", "127.0.0.1")) + .merge(Serialized::default("github.appId", 1234)) + .merge(Serialized::default("github.appPrivateKey", "key")) + .merge(Serialized::default("github.webhookSecret", "secret")) + .merge(Serialized::default("addr", "127.0.0.1:9000")) + .merge(Serialized::default("log.format", "pretty")) + .extract() + .unwrap() +} + +/// Sets up a pull request opened event with the body provided. +fn setup_test_pr_opened_event(body: &str) -> PullRequestEvent { + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Opened; + event.pull_request.body = Some(body.to_string()); + event +} + +/// Sets up a router with default mocks, returning it with the commands receiver. +fn setup_test_router() -> (Router, Receiver) { + let cfg = setup_test_config(); + let db = Arc::new(MockDB::new()); + let gh = Arc::new(MockGH::new()); + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + (setup_router(&cfg, db, gh, cmds_tx), cmds_rx) +} + +/// Sets up a router with the mocks provided, returning it with the commands receiver. +fn setup_test_router_with_mocks(db: MockDB, gh: MockGH) -> (Router, Receiver) { + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let router = setup_router(&setup_test_config(), Arc::new(db), Arc::new(gh), cmds_tx); + (router, cmds_rx) +} + +/// Sets up the check reported when a rule matches a pull request with no vote. +fn setup_test_rule_matched_check() -> CheckDetails { + CheckDetails { + status: "completed".to_string(), + summary: "An automation rule matches but there is no vote, maintainers should run /vote".to_string(), + + conclusion: Some("failure".to_string()), + } +} diff --git a/src/main.rs b/src/main.rs index fbaf305..a53138f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,6 @@ +//! This module is the entry point of the `GitVote` service. It sets up the +//! configuration, logging, database, GitHub client, votes processor and HTTP server. + #![warn(clippy::all, clippy::pedantic)] #![allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)] @@ -6,7 +9,6 @@ use std::{net::SocketAddr, path::PathBuf, sync::Arc}; use anyhow::{Context, Result}; use clap::Parser; use deadpool_postgres::Runtime; -use octocrab::Octocrab; use rustls::{ ClientConfig, DigitallySignedStruct, Error as RustlsError, SignatureScheme, client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, @@ -22,7 +24,7 @@ use tracing_subscriber::EnvFilter; use crate::{ cfg_svc::{Cfg, LogFormat}, db::PgDB, - github::GHApi, + github::{GHApi, retrying_client_builder, single_attempt_client_builder}, }; mod cfg_repo; @@ -37,17 +39,24 @@ mod results; mod testutil; mod tmpl; +// The doc comments of `Args` and its fields are used by clap as the help text. The +// trailing period of single paragraph doc comments is removed, and the explicit `about` +// attribute takes precedence over the struct doc comment. + +/// Command line arguments. #[derive(Debug, Parser)] #[clap(author, version, about)] struct Args { - /// Config file path + /// Config file path. #[clap(short, long)] config: PathBuf, } +/// Runs the `GitVote` service until it is asked to stop. #[tokio::main] async fn main() -> Result<()> { - // Install a process-wide rustls provider before building any TLS config + // Install a process-wide rustls provider before building any TLS config, which + // only fails when a provider has already been installed let _ = aws_lc_rs::default_provider().install_default(); // Parse command line arguments @@ -58,10 +67,10 @@ async fn main() -> Result<()> { // Setup logging let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("gitvote=debug")); - let ts = tracing_subscriber::fmt().with_env_filter(env_filter); + let subscriber = tracing_subscriber::fmt().with_env_filter(env_filter); match cfg.log.format { - LogFormat::Json => ts.json().init(), - LogFormat::Pretty => ts.init(), + LogFormat::Json => subscriber.json().init(), + LogFormat::Pretty => subscriber.init(), } // Setup database @@ -70,15 +79,26 @@ async fn main() -> Result<()> { .with_custom_certificate_verifier(Arc::new(InsecureCertVerifier)) .with_no_client_auth(); let connector = MakeRustlsConnect::new(tls_config); - let pool = cfg.db.create_pool(Some(Runtime::Tokio1), connector)?; + let pool = cfg + .db + .create_pool(Some(Runtime::Tokio1), connector) + .context("error creating database pool")?; let db = Arc::new(PgDB::new(pool)); // Setup GitHub client let app_id = cfg.github.app_id as u64; let app_private_key = cfg.github.app_private_key.clone(); - let app_private_key = jsonwebtoken::EncodingKey::from_rsa_pem(app_private_key.as_bytes())?; - let app_client = Octocrab::builder().app(app_id.into(), app_private_key).build()?; - let gh = Arc::new(GHApi::new(app_client)); + let app_private_key = jsonwebtoken::EncodingKey::from_rsa_pem(app_private_key.as_bytes()) + .context("error parsing github app private key")?; + let retrying_client = retrying_client_builder() + .app(app_id.into(), app_private_key.clone()) + .build() + .context("error building retrying github client")?; + let single_attempt_client = single_attempt_client_builder() + .app(app_id.into(), app_private_key) + .build() + .context("error building single attempt github client")?; + let gh = Arc::new(GHApi::new(app_id, retrying_client, single_attempt_client)); // Setup and launch votes processor let (cmds_tx, cmds_rx) = async_channel::unbounded(); @@ -89,10 +109,13 @@ async fn main() -> Result<()> { // Setup and launch HTTP server let router = handlers::setup_router(&cfg, db, gh, cmds_tx); - let addr: SocketAddr = cfg.addr.parse()?; - let listener = TcpListener::bind(addr).await?; + let addr: SocketAddr = cfg.addr.parse().context("error parsing server address")?; + let listener = TcpListener::bind(addr).await.context("error binding server address")?; info!(%addr, "gitvote service started"); - axum::serve(listener, router).with_graceful_shutdown(shutdown_signal()).await.unwrap(); + axum::serve(listener, router) + .with_graceful_shutdown(shutdown_signal()) + .await + .context("error running http server")?; // Ask votes processor to stop and wait for it to finish cancel_token.cancel(); @@ -103,18 +126,18 @@ async fn main() -> Result<()> { Ok(()) } -/// Return a future that will complete when the program is asked to stop via a +/// Returns a future that will complete when the program is asked to stop via a /// ctrl+c or terminate signal. async fn shutdown_signal() { // Setup signal handlers let ctrl_c = async { - signal::ctrl_c().await.expect("failed to install ctrl+c signal handler"); + signal::ctrl_c().await.expect("ctrl+c signal handler should be installable at startup"); }; #[cfg(unix)] let terminate = async { signal::unix::signal(signal::unix::SignalKind::terminate()) - .expect("failed to install terminate signal handler") + .expect("terminate signal handler should be installable at startup") .recv() .await; }; @@ -134,13 +157,15 @@ async fn shutdown_signal() { struct InsecureCertVerifier; impl ServerCertVerifier for InsecureCertVerifier { + /// [`ServerCertVerifier::supported_verify_schemes`]. fn supported_verify_schemes(&self) -> Vec { rustls::crypto::CryptoProvider::get_default() - .expect("crypto provider not installed") + .expect("crypto provider must be installed at startup") .signature_verification_algorithms .supported_schemes() } + /// [`ServerCertVerifier::verify_server_cert`]. fn verify_server_cert( &self, _end_entity: &CertificateDer<'_>, @@ -152,6 +177,7 @@ impl ServerCertVerifier for InsecureCertVerifier { Ok(ServerCertVerified::assertion()) } + /// [`ServerCertVerifier::verify_tls12_signature`]. fn verify_tls12_signature( &self, _message: &[u8], @@ -161,6 +187,7 @@ impl ServerCertVerifier for InsecureCertVerifier { Ok(HandshakeSignatureValid::assertion()) } + /// [`ServerCertVerifier::verify_tls13_signature`]. fn verify_tls13_signature( &self, _message: &[u8], diff --git a/src/processor.rs b/src/processor.rs index d8ddb3a..d6c3a58 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -2,35 +2,61 @@ use std::time::Duration; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, format_err}; use askama::Template; use futures::future::{self, JoinAll}; +use thiserror::Error; use time::OffsetDateTime; -use tokio::{task::JoinHandle, time::sleep}; +use tokio::{ + task::JoinHandle, + time::{sleep, timeout}, +}; use tokio_util::sync::CancellationToken; use tracing::{debug, error, instrument, warn}; -use uuid::Uuid; use crate::{ cfg_repo::{CfgError, CfgProfile}, - cmd::{CancelVoteInput, CheckVoteInput, Command, CreateVoteInput}, - db::DynDB, - github::{CheckDetails, DynGH, split_full_name}, - results, tmpl, + cmd::{self, CancelVoteInput, CheckVoteInput, Command, CreateVoteInput}, + db::{CLOSE_CLAIM_DURATION_SECS, DynDB, VoteAlreadyOpenError, VoteCloseFailureOutcome, VoteCloseStep}, + github::{CheckDetails, CommentNotFoundError, DiscussionRejectedError, DynGH, split_full_name}, + results::{self, Vote, VoteResults}, + tmpl, }; +#[cfg(test)] +mod tests; + +/// How often we try to auto close votes that have already passed. +const AUTO_CLOSE_FREQUENCY: Duration = Duration::from_hours(24); + +/// Summary of the check of a vote closed without results. +const CHECK_SUMMARY_NO_RESULTS: &str = + "The vote closed without results as its comment was not found, please run /vote again"; + +/// Maximum time an attempt to close a vote, or to publish its cancellation, can take. An +/// attempt taking longer is stopped and recorded as failed like any other failure, so +/// that it ends before the claim of the vote expires and another worker can claim it. +const CLOSE_ATTEMPT_TIMEOUT: Duration = Duration::from_mins(20); + +/// Minimum time left on the claim of a vote when its attempt times out, so that there is +/// time to record the failure before another worker can claim the vote. +const CLOSE_CLAIM_MARGIN: Duration = Duration::from_mins(10); + +// Attempts must end before their claim expires, leaving the margin to record the outcome +const _: () = assert!( + CLOSE_ATTEMPT_TIMEOUT.as_secs() + CLOSE_CLAIM_MARGIN.as_secs() <= CLOSE_CLAIM_DURATION_SECS as u64, + "attempts to close a vote must end well before their claim expires" +); + /// Number of commands handlers workers. const COMMANDS_HANDLERS_WORKERS: usize = 5; -/// Number of votes closers workers. -const VOTES_CLOSERS_WORKERS: usize = 1; - -/// Amount of time the votes closer will sleep when there are no pending votes -/// to close. -const VOTES_CLOSER_PAUSE_ON_NONE: Duration = Duration::from_secs(15); +/// Label used to tag issues/prs where a vote has been created. +const GITVOTE_LABEL: &str = "gitvote"; -/// Amount of time the votes closer will sleep when something goes wrong. -const VOTES_CLOSER_PAUSE_ON_ERROR: Duration = Duration::from_secs(30); +/// How long before a vote was closed to start looking for the comments and discussions +/// that a previous attempt published, to allow for clock differences with GitHub. +const MARKER_LOOKUP_MARGIN: Duration = Duration::from_mins(10); /// How often a vote can be checked. const MAX_VOTE_CHECK_FREQUENCY: Duration = Duration::from_hours(24); @@ -38,36 +64,46 @@ const MAX_VOTE_CHECK_FREQUENCY: Duration = Duration::from_hours(24); /// How often the status checker should run. const STATUS_CHECK_FREQUENCY: Duration = Duration::from_mins(30); -/// How often we try to auto close votes that have already passed. -const AUTO_CLOSE_FREQUENCY: Duration = Duration::from_hours(24); +/// Label used to tag issues/prs where a vote is closed. +const VOTE_CLOSED_LABEL: &str = "gitvote/closed"; -/// Label used to tag issues/prs where a vote has been created. -const GITVOTE_LABEL: &str = "gitvote"; +/// Label used to tag issues/prs where a vote failed. +const VOTE_FAILED_LABEL: &str = "gitvote/failed"; /// Label used to tag issues/prs where a vote is open. const VOTE_OPEN_LABEL: &str = "gitvote/open"; -/// Label used to tag issues/prs where a vote is closed. -const VOTE_CLOSED_LABEL: &str = "gitvote/closed"; - /// Label used to tag issues/prs where a vote passed. const VOTE_PASSED_LABEL: &str = "gitvote/passed"; -/// Label used to tag issues/prs where a vote failed. -const VOTE_FAILED_LABEL: &str = "gitvote/failed"; +/// Amount of time the votes closer will sleep when it cannot pick the next vote or +/// record the outcome of an attempt. Failures closing a vote do not cause a pause, so +/// that they do not hold up other votes: they are retried later, with a backoff. +const VOTES_CLOSER_PAUSE_ON_ERROR: Duration = Duration::from_secs(30); + +/// Amount of time the votes closer will sleep when there are no pending votes +/// to close. +const VOTES_CLOSER_PAUSE_ON_NONE: Duration = Duration::from_secs(15); + +/// Number of votes closers workers. +const VOTES_CLOSERS_WORKERS: usize = 1; /// A votes processor is in charge of creating the requested votes, stopping /// them at the scheduled time and publishing results, etc. It relies on some /// specialized workers to handle the different tasks. pub(crate) struct Processor { + /// Receiving end of the commands queue, shared by the commands handlers. + cmds_rx: async_channel::Receiver, + /// Sending end of the commands queue, used by the status checker. + cmds_tx: async_channel::Sender, + /// Database handle. db: DynDB, + /// GitHub client. gh: DynGH, - cmds_tx: async_channel::Sender, - cmds_rx: async_channel::Receiver, } impl Processor { - /// Create a new votes processor instance. + /// Creates a new votes processor instance. pub(crate) fn new( db: DynDB, gh: DynGH, @@ -75,16 +111,18 @@ impl Processor { cmds_rx: async_channel::Receiver, ) -> Self { Self { + cmds_rx, + cmds_tx, db, gh, - cmds_tx, - cmds_rx, } } - /// Start votes processor. + /// Starts the votes processor, launching all its workers. pub(crate) fn run(self, cancel_token: &CancellationToken) -> JoinAll> { - let num_workers = COMMANDS_HANDLERS_WORKERS + VOTES_CLOSERS_WORKERS + 2; // Status checker + auto closer + // Reserve room for the handles of all the workers, including the status checker + // and the votes auto closer + let num_workers = COMMANDS_HANDLERS_WORKERS + VOTES_CLOSERS_WORKERS + 2; let mut workers_handles = Vec::with_capacity(num_workers); // Launch commands handler workers @@ -111,6 +149,7 @@ impl Processor { let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); workers_handles.push(votes_auto_closer_handle); + // Return a future that completes once all the workers have finished future::join_all(workers_handles) } } @@ -119,18 +158,21 @@ impl Processor { /// are added to the queue when certain events from GitHub are received on the /// webhook endpoint. struct CommandsHandler { + /// Receiving end of the commands queue. + cmds_rx: async_channel::Receiver, + /// Database handle. db: DynDB, + /// GitHub client. gh: DynGH, - cmds_rx: async_channel::Receiver, } impl CommandsHandler { - /// Create a new commands handler instance. - pub(crate) fn new(db: DynDB, gh: DynGH, cmds_rx: async_channel::Receiver) -> Self { - Self { db, gh, cmds_rx } + /// Creates a new commands handler instance. + fn new(db: DynDB, gh: DynGH, cmds_rx: async_channel::Receiver) -> Self { + Self { cmds_rx, db, gh } } - /// Start commands handler. + /// Starts the commands handler. fn run(self, cancel_token: CancellationToken) -> JoinHandle<()> { tokio::spawn(async move { loop { @@ -138,20 +180,7 @@ impl CommandsHandler { biased; // Pick next command from the queue and process it - Ok(cmd) = self.cmds_rx.recv() => { - match cmd { - Command::CreateVote(input) => { - debug!(repo=input.repository_full_name, issue=input.issue_number, "processing create vote command"); - _ = self.create_vote(&input).await; - } - Command::CancelVote(input) => { - _ = self.cancel_vote(&input).await; - } - Command::CheckVote(input) => { - _ = self.check_vote(&input).await; - } - } - } + Ok(cmd) = self.cmds_rx.recv() => self.process_command(cmd).await, // Exit if the worker has been asked to stop () = cancel_token.cancelled() => break, @@ -160,87 +189,9 @@ impl CommandsHandler { }) } - /// Create a new vote. - #[instrument(fields(repo = i.repository_full_name, issue_number = i.issue_number), skip_all, err(Debug))] - async fn create_vote(&self, i: &CreateVoteInput) -> Result<()> { - // Get vote configuration profile - let inst_id = i.installation_id as u64; - let (owner, repo) = split_full_name(&i.repository_full_name); - let cfg = match CfgProfile::get( - self.gh.clone(), - inst_id, - owner, - i.organization.is_some(), - repo, - i.profile_name.clone(), - ) - .await - { - Ok(cfg) => cfg, - Err(err) => { - let body = match err { - CfgError::ConfigNotFound => tmpl::ConfigNotFound {}.render()?, - CfgError::ProfileNotFound => tmpl::ConfigProfileNotFound {}.render()?, - CfgError::InvalidConfig(reason) => tmpl::InvalidConfig::new(&reason).render()?, - }; - self.gh.post_comment(inst_id, owner, repo, i.issue_number, &body).await?; - return Ok(()); - } - }; - - // Only repository collaborators can create votes - if !self.gh.user_is_collaborator(inst_id, owner, repo, &i.created_by).await? { - let body = tmpl::VoteRestricted::new(&i.created_by).render()?; - self.gh.post_comment(inst_id, owner, repo, i.issue_number, &body).await?; - return Ok(()); - } - - // Only allow one vote open at the same time per issue/pr - if self.db.has_vote_open(&i.repository_full_name, i.issue_number).await? { - let body = tmpl::VoteInProgress::new(&i.created_by, i.is_pull_request).render()?; - self.gh.post_comment(inst_id, owner, repo, i.issue_number, &body).await?; - return Ok(()); - } - - // Post vote created comment on the issue/pr - let body = tmpl::VoteCreated::new(i, &cfg).render()?; - let vote_comment_id = self.gh.post_comment(inst_id, owner, repo, i.issue_number, &body).await?; - - // Store vote information in database - let vote_id = self.db.store_vote(vote_comment_id, i, &cfg).await?; - - // Create check run if the vote is on a pull request - if i.is_pull_request { - let check_details = CheckDetails { - status: "in_progress".to_string(), - conclusion: None, - summary: "Vote open".to_string(), - }; - self.gh.create_check_run(inst_id, owner, repo, i.issue_number, &check_details).await?; - } - - // Update issue/pr labels - // - // We try to remove the vote closed and result labels just in case they - // were left from a previous vote that was closed - _ = self.gh.remove_label(inst_id, owner, repo, i.issue_number, VOTE_CLOSED_LABEL).await; - _ = self.gh.remove_label(inst_id, owner, repo, i.issue_number, VOTE_PASSED_LABEL).await; - _ = self.gh.remove_label(inst_id, owner, repo, i.issue_number, VOTE_FAILED_LABEL).await; - self.gh - .add_labels( - inst_id, - owner, - repo, - i.issue_number, - &[GITVOTE_LABEL, VOTE_OPEN_LABEL], - ) - .await?; - - debug!(?vote_id, "created"); - Ok(()) - } - - /// Cancel an open vote. + /// Cancels an open vote. The cancellation is committed before anything is published, + /// so failing to publish it does not fail the command: the votes closer retries it + /// until it has no attempts left, when the cancellation is abandoned. #[instrument(fields(repo = i.repository_full_name, issue_number = i.issue_number), skip_all, err(Debug))] async fn cancel_vote(&self, i: &CancelVoteInput) -> Result<()> { // Only repository collaborators can cancel votes @@ -255,46 +206,72 @@ impl CommandsHandler { return Ok(()); } - // Try cancelling the vote open in the issue/pr provided - let cancelled_vote_id: Option = self + // Cancel the vote open in the issue/pr provided, claiming the publication of the + // cancellation, or report there was none + let Some(vote) = self .db - .cancel_vote(&i.repository_full_name, i.issue_number) + .cancel_vote(&i.repository_full_name, i.issue_number, &i.cancelled_by) .await - .context("error cancelling vote")?; - - // Post the corresponding comment on the issue/pr - let body: String; - match cancelled_vote_id { - Some(vote_id) => { - body = tmpl::VoteCancelled::new(&i.cancelled_by, i.is_pull_request).render()?; - debug!(?vote_id, "cancelled"); - } - None => { - body = tmpl::NoVoteInProgress::new(&i.cancelled_by, i.is_pull_request).render()?; + .context("error cancelling vote")? + else { + let body = tmpl::NoVoteInProgress::new(&i.cancelled_by, i.is_pull_request).render()?; + self.gh.post_comment(inst_id, owner, repo, i.issue_number, &body).await?; + return Ok(()); + }; + + // Publish the cancellation right away, which is its first attempt. A failure is + // recorded so the votes closer retries it later, and if that fails too it + // retries once the claim expires + let vote_id = vote.vote_id; + let publish = publish_vote_cancellation(&self.db, &self.gh, &vote, false); + if let Err(err) = run_close_attempt(publish).await { + // Record the failure, which is only done while the claim is held + let record_result = async { + let claimed_until = vote.close_next_attempt_at.context("cancelled vote without claim")?; + self.db + .record_vote_close_failure(vote_id, claimed_until) + .await + .context("error recording vote cancellation failure") } - } - self.gh.post_comment(inst_id, owner, repo, i.issue_number, &body).await?; - - // Create check run and remove "vote open" label if the vote was cancelled - if cancelled_vote_id.is_some() { - // Create check run if the vote is on a pull request - if i.is_pull_request { - let check_details = CheckDetails { - status: "completed".to_string(), - conclusion: Some("success".to_string()), - summary: "Vote cancelled".to_string(), - }; - self.gh.create_check_run(inst_id, owner, repo, i.issue_number, &check_details).await?; + .await; + + // Log the publication error according to the outcome recorded + match record_result { + Ok(VoteCloseFailureOutcome::CancellationAbandoned) => error!( + ?err, + ?vote_id, + "error publishing vote cancellation, no attempts left, it was abandoned" + ), + Ok(VoteCloseFailureOutcome::ClaimLost) => warn!( + ?err, + ?vote_id, + "error publishing vote cancellation, its claim was taken over" + ), + Ok(VoteCloseFailureOutcome::NoAttemptsLeft) => error!( + ?err, + ?vote_id, + "error publishing vote cancellation, no attempts left" + ), + Ok(VoteCloseFailureOutcome::RetryScheduled) => warn!( + ?err, + ?vote_id, + "error publishing vote cancellation, it will be retried" + ), + Err(record_err) => warn!( + ?err, + ?record_err, + ?vote_id, + "error publishing vote cancellation, it will be retried once its claim expires" + ), } - - // Update issue/pr labels - _ = self.gh.remove_label(inst_id, owner, repo, i.issue_number, VOTE_OPEN_LABEL).await; + return Ok(()); } + debug!(?vote_id, "cancelled"); Ok(()) } - /// Check the status of a vote. The vote must be open and not have been + /// Checks the status of a vote. The vote must be open and not have been /// checked in `MAX_VOTE_CHECK_FREQUENCY`. #[instrument(fields(vote_id), skip_all, err(Debug))] async fn check_vote(&self, i: &CheckVoteInput) -> Result<()> { @@ -331,176 +308,386 @@ impl CommandsHandler { let body = tmpl::VoteStatus::new(&results).render()?; self.gh.post_comment(inst_id, owner, repo, vote.issue_number, &body).await?; - // Update vote's last check ts - self.db.update_vote_last_check(vote.vote_id).await?; + // Update vote's last check ts. The status has been posted already, so this is + // done on a best effort basis, as asking to check the vote again would post it + // twice + if let Err(err) = self.db.update_vote_last_check(vote.vote_id).await { + warn!( + ?err, + repository_full_name = vote.repository_full_name, + issue_number = vote.issue_number, + vote_id = %vote.vote_id, + "error updating vote last check" + ); + } Ok(()) } -} -/// Worker that periodically checks the database for votes that should be -/// closed and closes them. -struct VotesCloser { - db: DynDB, - gh: DynGH, -} - -impl VotesCloser { - /// Create a new votes closer instance. - pub(crate) fn new(db: DynDB, gh: DynGH) -> Self { - Self { db, gh } - } + /// Creates a new vote. + #[instrument(fields(repo = i.repository_full_name, issue_number = i.issue_number), skip_all, err(Debug))] + async fn create_vote(&self, i: &CreateVoteInput) -> Result<()> { + // Get vote configuration profile + let inst_id = i.installation_id as u64; + let (owner, repo) = split_full_name(&i.repository_full_name); + let cfg = match CfgProfile::get( + self.gh.clone(), + inst_id, + owner, + i.organization.is_some(), + repo, + i.profile_name.clone(), + ) + .await + { + Ok(cfg) => cfg, + Err(err) => { + // Explain why the configuration cannot be used, unless it could not be + // looked up, which is an error + let body = match err { + CfgError::ConfigLookup(err) => return Err(err), + CfgError::ConfigNotFound => tmpl::ConfigNotFound {}.render()?, + CfgError::InvalidConfig(reason) => tmpl::InvalidConfig::new(&reason).render()?, + CfgError::ProfileNotFound => tmpl::ConfigProfileNotFound {}.render()?, + }; - /// Start votes closer. - fn run(self, cancel_token: CancellationToken) -> JoinHandle<()> { - tokio::spawn(async move { - loop { - // Close any pending finished votes - match self.close_finished_vote().await { - Ok(Some(())) => { - // One pending finished vote was closed, try to close - // another one immediately - } - Ok(None) => tokio::select! { - // No pending finished votes were found, pause unless - // we've been asked to stop - () = sleep(VOTES_CLOSER_PAUSE_ON_NONE) => {}, - () = cancel_token.cancelled() => break, - }, - Err(_) => { - // Something went wrong closing finished vote, pause - // unless we've been asked to stop - tokio::select! { - () = sleep(VOTES_CLOSER_PAUSE_ON_ERROR) => {}, - () = cancel_token.cancelled() => break, - } + // This is an automation failure if a rule requested the vote, which + // is reported even if the check is not required + if i.is_automatic { + if let Err(err) = cmd::report_automation_failure( + self.db.clone(), + self.gh.clone(), + inst_id, + owner, + repo, + i.issue_number, + Some(&body), + ) + .await + { + warn!(?err, "error reporting automation failure"); } + return Ok(()); } - // Exit if the worker has been asked to stop - if cancel_token.is_cancelled() { - break; - } + // Otherwise post the explanation on the issue/pr and update its check + self.gh.post_comment(inst_id, owner, repo, i.issue_number, &body).await?; + self.update_rejected_vote_check(i).await; + return Ok(()); } - }) - } + }; - /// Close any pending finished vote. - #[instrument(fields(vote_id), skip_all, err(Debug))] - async fn close_finished_vote(&self) -> Result> { - // Try to close any finished vote - let Some((vote, results)) = self - .db - .close_finished_vote(self.gh.clone()) - .await - .context("error closing finished vote")? - else { - return Ok(None); + // Only repository collaborators can create votes + if !self.gh.user_is_collaborator(inst_id, owner, repo, &i.created_by).await? { + let body = tmpl::VoteRestricted::new(&i.created_by).render()?; + self.gh.post_comment(inst_id, owner, repo, i.issue_number, &body).await?; + self.update_rejected_vote_check(i).await; + return Ok(()); + } + + // Only allow one vote open at the same time per issue/pr + if self.db.has_vote_open(&i.repository_full_name, i.issue_number).await? { + let body = tmpl::VoteInProgress::new(&i.created_by, i.is_pull_request).render()?; + self.gh.post_comment(inst_id, owner, repo, i.issue_number, &body).await?; + return Ok(()); + } + + // Post vote created comment on the issue/pr + let body = tmpl::VoteCreated::new(i, &cfg).render()?; + let vote_comment_id = self.gh.post_comment(inst_id, owner, repo, i.issue_number, &body).await?; + + // Store vote information in database. The vote may have been stored despite an + // error, in which case it is open and must be set up all the same + let vote_id = match self.db.store_vote(vote_comment_id, i, &cfg).await { + Ok(vote_id) => Some(vote_id), + Err(err) => match self.handle_store_vote_error(i, vote_comment_id, err).await? { + StoreVoteRecovery::Handled => return Ok(()), + StoreVoteRecovery::VoteStored => None, + }, }; - // Record vote_id as part of the current span - tracing::Span::current().record("vote_id", vote.vote_id.to_string()); + // Set up the vote, which is open at this point + self.set_up_vote(i).await.map_err(VoteSetupError)?; - // Extract some information from the vote - let inst_id = vote.installation_id as u64; - let (owner, repo) = split_full_name(&vote.repository_full_name); + debug!(?vote_id, "created"); + Ok(()) + } - // Post vote results (if some were returned; if the vote comment was - // removed, the vote will be closed but no results will be received) - if let Some(results) = &results { - // Post comment on the issue/pr - let body = tmpl::VoteClosed::new(results).render()?; - self.gh.post_comment(inst_id, owner, repo, vote.issue_number, &body).await?; + /// Deletes a vote comment no vote is tracking, on a best effort basis. + async fn delete_orphan_vote_comment(&self, i: &CreateVoteInput, vote_comment_id: i64) { + let inst_id = i.installation_id as u64; + let (owner, repo) = split_full_name(&i.repository_full_name); + if let Err(err) = self.gh.delete_comment(inst_id, owner, repo, vote_comment_id).await { + warn!(?err, "error deleting orphan vote comment"); + } + } - // Post announcement to discussions if enabled in vote profile - if let Some(discussions) = &vote.cfg.announcements.and_then(|a| a.discussions) { - let issue_title = vote.issue_title.unwrap_or_default(); - let title = build_announcement_title(vote.issue_number, &issue_title); - let body = - tmpl::VoteClosedAnnouncement::new(vote.issue_number, &issue_title, results).render()?; - if let Err(err) = self - .gh - .create_discussion(inst_id, owner, repo, &discussions.category, &title, &body) - .await - { - // Permissions required to create discussions may not have - // been granted yet for this installation, so we raise a - // warning and continue - warn!(?err, "error creating announcement discussion"); - } + /// Handles an error storing a vote whose comment has been posted already. The error + /// is returned unless the vote is known to be open, either because another worker + /// won the race to create it, which is reported on the issue/pr, or because it was + /// stored despite the error, in which case the caller must set it up. + async fn handle_store_vote_error( + &self, + i: &CreateVoteInput, + vote_comment_id: i64, + err: anyhow::Error, + ) -> Result { + // Another worker won the race to create the vote, so nothing was stored and the + // vote comment must not stay around + if err.is::() { + self.delete_orphan_vote_comment(i, vote_comment_id).await; + let inst_id = i.installation_id as u64; + let (owner, repo) = split_full_name(&i.repository_full_name); + let body = tmpl::VoteInProgress::new(&i.created_by, i.is_pull_request).render()?; + self.gh.post_comment(inst_id, owner, repo, i.issue_number, &body).await?; + return Ok(StoreVoteRecovery::Handled); + } + + // The vote may have been stored even if the database could not confirm it, so + // the vote comment is only deleted once it is known that no vote is tracking it + match self.db.has_vote_with_comment(vote_comment_id).await { + Ok(false) => self.delete_orphan_vote_comment(i, vote_comment_id).await, + Ok(true) => { + warn!( + ?err, + repository_full_name = i.repository_full_name, + issue_number = i.issue_number, + vote_comment_id, + "vote stored despite the error, setting it up" + ); + return Ok(StoreVoteRecovery::VoteStored); + } + Err(lookup_err) => { + warn!( + ?lookup_err, + vote_comment_id, "error looking up vote, keeping its comment" + ); } } - // Create check run if the vote is on a pull request - if vote.is_pull_request { - let (conclusion, summary) = if let Some(results) = &results { - if results.passed { - ( - "success", - format!( - "The vote passed! {} out of {} voted in favor.", - results.in_favor, results.allowed_voters - ), + // The vote is not known to be open, so the error is returned + Err(err) + } + + /// Posts a comment on the issue/pr provided saying that the command failed and that + /// it can be tried again. This is done on a best effort basis. + async fn post_command_failed_comment( + &self, + installation_id: i64, + repository_full_name: &str, + issue_number: i64, + ) { + // Render and post the comment, logging any failure + let inst_id = installation_id as u64; + let (owner, repo) = split_full_name(repository_full_name); + let template = tmpl::CommandFailed {}; + let result = match template.render() { + Ok(body) => self.gh.post_comment(inst_id, owner, repo, issue_number, &body).await, + Err(err) => Err(err.into()), + }; + if let Err(err) = result { + warn!( + ?err, + repository_full_name, issue_number, "error posting command failed comment" + ); + } + } + + /// Executes the command provided, reporting on the issue/pr when it fails. The error + /// itself is logged by each of the commands. + async fn process_command(&self, cmd: Command) { + match cmd { + Command::CancelVote(input) => { + if self.cancel_vote(&input).await.is_err() { + self.post_command_failed_comment( + input.installation_id, + &input.repository_full_name, + input.issue_number, ) - } else { - ( - "failure", - format!( - "The vote did not pass. {} out of {} voted in favor.", - results.in_favor, results.allowed_voters - ), + .await; + } + } + Command::CheckVote(input) => { + if self.check_vote(&input).await.is_err() + && let Some(installation_id) = input.installation_id + { + self.post_command_failed_comment( + installation_id, + &input.repository_full_name, + input.issue_number, ) + .await; } - } else { - ("success", "The vote was cancelled".to_string()) - }; - let check_details = CheckDetails { - status: "completed".to_string(), - conclusion: Some(conclusion.to_string()), - summary, - }; - self.gh.create_check_run(inst_id, owner, repo, vote.issue_number, &check_details).await?; + } + Command::CreateVote(input) => { + debug!( + repo = input.repository_full_name, + issue = input.issue_number, + "processing create vote command" + ); + if let Err(err) = self.create_vote(&input).await + && !err.is::() + { + self.report_create_vote_failure(&input).await; + } + } } + } - // Update issue/pr labels - let mut labels_to_add = vec![VOTE_CLOSED_LABEL]; - if let Some(results) = &results { - if results.passed { - labels_to_add.push(VOTE_PASSED_LABEL); - } else { - labels_to_add.push(VOTE_FAILED_LABEL); + /// Reports that creating a vote failed, on a best effort basis. On pull requests + /// just opened a failure check is reported too, as nothing else would report the + /// check, and reporting success could allow merging them without the vote requested. + /// Nobody ran a command on automatic votes, so they are reported as an automation + /// failure. No check is reported if the pull request has a vote, which owns it, like + /// one stored despite the failure. + async fn report_create_vote_failure(&self, i: &CreateVoteInput) { + let inst_id = i.installation_id as u64; + let (owner, repo) = split_full_name(&i.repository_full_name); + + // Report the automation failure + if i.is_automatic { + if let Err(err) = cmd::report_automation_failure( + self.db.clone(), + self.gh.clone(), + inst_id, + owner, + repo, + i.issue_number, + None, + ) + .await + { + warn!( + ?err, + repository_full_name = i.repository_full_name, + issue_number = i.issue_number, + "error reporting automation failure" + ); } + return; } - _ = self.gh.remove_label(inst_id, owner, repo, vote.issue_number, VOTE_OPEN_LABEL).await; - self.gh.add_labels(inst_id, owner, repo, vote.issue_number, &labels_to_add).await?; - debug!("closed"); - Ok(Some(())) + // Post the comment + self.post_command_failed_comment(i.installation_id, &i.repository_full_name, i.issue_number) + .await; + + // Report the failure check, unless a vote owns it + if i.opened_pr_base_branch.is_some() { + let check_details = cmd::vote_not_created_check(); + if let Err(err) = cmd::publish_failure_check( + self.db.clone(), + self.gh.clone(), + inst_id, + owner, + repo, + i.issue_number, + &check_details, + ) + .await + { + warn!( + ?err, + repository_full_name = i.repository_full_name, + issue_number = i.issue_number, + "error reporting vote not created check" + ); + } + } + } + + /// Sets up a vote just opened, creating its in progress check run on pull requests + /// and updating the labels of its issue/pr. The vote is open already, so both steps + /// are tried even if the other one fails. + async fn set_up_vote(&self, i: &CreateVoteInput) -> Result<()> { + let inst_id = i.installation_id as u64; + let (owner, repo) = split_full_name(&i.repository_full_name); + + // Create check run if the vote is on a pull request + let check_run_result = if i.is_pull_request { + let check_details = CheckDetails { + status: "in_progress".to_string(), + summary: "Vote open".to_string(), + conclusion: None, + }; + self.gh.create_check_run(inst_id, owner, repo, i.issue_number, &check_details).await + } else { + Ok(()) + }; + + // Update issue/pr labels, removing first the vote closed and result labels just + // in case they were left from a previous vote that was closed + for label in [VOTE_CLOSED_LABEL, VOTE_FAILED_LABEL, VOTE_PASSED_LABEL] { + remove_label_best_effort(&self.gh, inst_id, owner, repo, i.issue_number, label).await; + } + let labels_result = self + .gh + .add_labels( + inst_id, + owner, + repo, + i.issue_number, + &[GITVOTE_LABEL, VOTE_OPEN_LABEL], + ) + .await; + + // Report any failure once all the steps have been tried + combine_step_outcomes(vec![("check run", check_run_result), ("labels", labels_result)]) + } + + /// Updates the check of a pull request just opened whose vote was rejected, as + /// nothing else would report it. This is done on a best effort basis: the user has + /// been told about the rejection already, and a required check not reported blocks + /// the merge. The check is left alone if the pull request has a vote, which owns it, + /// like one created concurrently by a maintainer. + async fn update_rejected_vote_check(&self, i: &CreateVoteInput) { + let Some(branch) = &i.opened_pr_base_branch else { + return; + }; + let inst_id = i.installation_id as u64; + let (owner, repo) = split_full_name(&i.repository_full_name); + if let Err(err) = cmd::update_no_vote_check( + self.db.clone(), + self.gh.clone(), + inst_id, + owner, + repo, + i.issue_number, + branch, + ) + .await + { + warn!(?err, "error updating rejected vote check"); + } } } /// Worker that periodically runs status check commands. struct StatusChecker { - db: DynDB, + /// Sending end of the commands queue, where the check vote commands are enqueued. cmds_tx: async_channel::Sender, + /// Database handle. + db: DynDB, } impl StatusChecker { - /// Create a new status checker instance. - pub(crate) fn new(db: DynDB, cmds_tx: async_channel::Sender) -> Self { - Self { db, cmds_tx } + /// Creates a new status checker instance. + fn new(db: DynDB, cmds_tx: async_channel::Sender) -> Self { + Self { cmds_tx, db } } - /// Start status checker. + /// Starts the status checker. fn run(self, cancel_token: CancellationToken) -> JoinHandle<()> { tokio::spawn(async move { loop { // Process pending periodic status checks match self.db.get_pending_status_checks().await { Ok(inputs) => { - // Enqueue check vote commands + // Enqueue check vote commands, which only fails if the queue + // has been closed for input in inputs { - _ = self.cmds_tx.send(Command::CheckVote(input)).await; + if let Err(err) = self.cmds_tx.send(Command::CheckVote(input)).await { + warn!(?err, "error enqueuing check vote command"); + } } } Err(err) => { @@ -519,21 +706,42 @@ impl StatusChecker { } } +/// Outcome of handling an error storing a vote that leaves it known to be open. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StoreVoteRecovery { + /// Another worker created the vote, which has been reported on the issue/pr, so + /// nothing else must be done. + Handled, + /// The vote was stored despite the error, so it must be set up. + VoteStored, +} + +/// Error returned when a vote was created but some of the steps that follow failed. +/// The vote is open, so the command must not be tried again. +#[derive(Debug, Error)] +#[error(transparent)] +struct VoteSetupError( + /// Error of the first setup step that failed. + anyhow::Error, +); + /// Worker that periodically auto closes votes that have already passed over all /// the allowed voters. This only applies to votes with the `close_on_passing` /// feature enabled. struct VotesAutoCloser { + /// Database handle. db: DynDB, + /// GitHub client. gh: DynGH, } impl VotesAutoCloser { - /// Create a new votes auto closer instance. - pub(crate) fn new(db: DynDB, gh: DynGH) -> Self { + /// Creates a new votes auto closer instance. + fn new(db: DynDB, gh: DynGH) -> Self { Self { db, gh } } - /// Start votes auto closer. + /// Starts the votes auto closer. fn run(self, cancel_token: CancellationToken) -> JoinHandle<()> { tokio::spawn(async move { loop { @@ -551,7 +759,7 @@ impl VotesAutoCloser { // timestamp) only once it passes over all the // allowed voters, so that further votes from // pending voters cannot make it fail. A vote - // passing over the votes cast may stay open. + // passing over the votes cast may stay open if results.passes_with_all_allowed_voters() && let Err(err) = self.db.update_vote_ends_at(vote_id).await { @@ -578,10 +786,571 @@ impl VotesAutoCloser { } } -/// Helper function to build an announcement title. +/// Worker that periodically checks the database for votes that should be +/// closed and closes them, retrying the closing side effects that failed, as well +/// as the publication of the cancellations that failed. +struct VotesCloser { + /// Database handle. + db: DynDB, + /// GitHub client. + gh: DynGH, +} + +impl VotesCloser { + /// Creates a new votes closer instance. + fn new(db: DynDB, gh: DynGH) -> Self { + Self { db, gh } + } + + /// Starts the votes closer. + fn run(self, cancel_token: CancellationToken) -> JoinHandle<()> { + tokio::spawn(async move { + loop { + // Process the next vote with closing work due + match self.close_finished_vote().await { + Ok(Some(())) => { + // One vote with closing work due was processed, try to + // process another one immediately + } + Ok(None) => tokio::select! { + // No votes with closing work due were found, pause unless + // we've been asked to stop + () = sleep(VOTES_CLOSER_PAUSE_ON_NONE) => {}, + () = cancel_token.cancelled() => break, + }, + Err(_) => { + // Something went wrong getting the next vote or recording + // the outcome, pause unless we've been asked to stop + tokio::select! { + () = sleep(VOTES_CLOSER_PAUSE_ON_ERROR) => {}, + () = cancel_token.cancelled() => break, + } + } + } + + // Exit if the worker has been asked to stop + if cancel_token.is_cancelled() { + break; + } + } + }) + } + + /// Tries to make progress on the next vote with closing work due: either a finished + /// vote to close, or a closed vote with some of its side effects missing. A failure + /// is recorded so that the attempt is retried later, as described in + /// [`DB::record_vote_close_failure`](crate::db::DB::record_vote_close_failure), and + /// it is not returned, as the votes closer can continue with the next vote. + #[instrument(fields(vote_id), skip_all, err(Debug))] + async fn close_finished_vote(&self) -> Result> { + // Claim the next vote with closing work due + let Some(vote) = self.db.claim_vote_to_close().await.context("error claiming vote to close")? else { + return Ok(None); + }; + + // Record vote_id as part of the current span + tracing::Span::current().record("vote_id", vote.vote_id.to_string()); + + // Close the vote, recording the failure if something goes wrong or it takes too + // long. It is recorded only while the claim is held, as the vote may have been + // cancelled meanwhile + if let Err(err) = run_close_attempt(self.close_vote(&vote)).await { + let record_result = async { + let claimed_until = vote.close_next_attempt_at.context("claimed vote without claim")?; + self.db + .record_vote_close_failure(vote.vote_id, claimed_until) + .await + .context("error recording vote close failure") + } + .await; + + // Log the close error if it could not be recorded, as only the recording one + // is returned, which makes the votes closer pause and the span log it + let outcome = match record_result { + Ok(outcome) => outcome, + Err(record_err) => { + warn!( + ?err, + "error closing vote, it will be retried once its claim expires" + ); + return Err(record_err); + } + }; + + // Log the close error according to the outcome recorded + match outcome { + VoteCloseFailureOutcome::CancellationAbandoned => { + error!( + ?err, + "error publishing vote cancellation, no attempts left, it was abandoned" + ); + } + VoteCloseFailureOutcome::ClaimLost => { + warn!(?err, "error closing vote, its claim was taken over"); + } + VoteCloseFailureOutcome::NoAttemptsLeft => { + error!(?err, "error closing vote, no attempts left"); + } + VoteCloseFailureOutcome::RetryScheduled => { + warn!(?err, "error closing vote, it will be retried"); + } + } + return Ok(Some(())); + } + + debug!("closed"); + Ok(Some(())) + } + + /// Closes the vote provided if needed and runs the side effects still missing. The + /// cancellation of a cancelled vote is published instead, as it has no results. + /// + /// A vote claimed closed already had a previous attempt, which may have published + /// some steps without recording them: only an attempt by the votes closer closes a + /// vote, before publishing anything, and cancelling a vote closes it and publishes + /// its cancellation right away. This holds when a worker died holding the claim too, + /// and unlike the failed attempts count it is not reset when the vote is closed. + async fn close_vote(&self, vote: &Vote) -> Result<()> { + // Publish the cancellation of a cancelled vote, retrying the steps still missing + let is_retry = vote.closed; + if vote.cancelled_at.is_some() { + return publish_vote_cancellation(&self.db, &self.gh, vote, is_retry).await; + } + + // Calculate and store the results, unless a previous attempt already did. The + // head commit is read last, right before closing the vote, and stored with it + let (results, head_sha) = if vote.closed { + (vote.results.clone(), vote.close_head_sha.clone()) + } else { + let (owner, repo) = split_full_name(&vote.repository_full_name); + let results = match results::calculate(self.gh.clone(), owner, repo, vote).await { + Ok(results) => Some(results), + // Vote comment was deleted. We still want to proceed and close the + // vote so that we don't try again to close it + Err(err) if err.is::() => None, + Err(err) => return Err(err), + }; + let head_sha = self.get_close_head_sha(vote).await; + if !self + .db + .close_vote(vote.vote_id, results.as_ref(), head_sha.as_deref()) + .await + .context("error closing vote in database")? + { + // The vote was cancelled after being claimed, nothing else to do + return Ok(()); + } + (results, head_sha) + }; + + // Try all the side effects missing, recording each one as soon as it is done. + // They are independent, so a failure must not hold up the others. A step is + // recorded after its GitHub call, so if that call fails after GitHub processed + // it, or the process or the database fail in between, the next attempt repeats + // the step. Retries look for the comment and the announcement a previous attempt + // posted before posting them again, while the check run and labels are repeated + let mut outcomes = vec![]; + if let Some(results) = &results { + if vote.close_comment_posted_at.is_none() { + outcomes.push(("comment", self.post_close_comment(vote, results, is_retry).await)); + } + if vote.close_announcement_posted_at.is_none() { + let outcome = self.post_close_announcement(vote, results, is_retry).await; + outcomes.push(("announcement", outcome)); + } + } + outcomes.extend(self.publish_close_state(vote, results.as_ref(), head_sha.as_deref()).await); + + // The attempt failed if any of them did + combine_step_outcomes(outcomes)?; + + // Nothing else is left to do for this vote + self.db.complete_vote_close(vote.vote_id).await.context("error completing vote close") + } + + /// Creates the check run of a vote being closed on the commit provided, which is the + /// head of the pull request when the vote closed. When that commit is unknown, the + /// check run is created on the current head. + async fn create_close_check_run( + &self, + vote: &Vote, + results: Option<&VoteResults>, + head_sha: Option<&str>, + ) -> Result<()> { + // Prepare the check run details + let (conclusion, summary) = if let Some(results) = results { + if results.passed { + ( + "success", + format!( + "The vote passed! {} out of {} voted in favor.", + results.in_favor, results.allowed_voters + ), + ) + } else { + ( + "failure", + format!( + "The vote did not pass. {} out of {} voted in favor.", + results.in_favor, results.allowed_voters + ), + ) + } + } else { + ("failure", CHECK_SUMMARY_NO_RESULTS.to_string()) + }; + let check_details = CheckDetails { + status: "completed".to_string(), + summary, + conclusion: Some(conclusion.to_string()), + }; + + // Create the check run + let inst_id = vote.installation_id as u64; + let (owner, repo) = split_full_name(&vote.repository_full_name); + if let Some(head_sha) = head_sha { + self.gh.create_commit_check_run(inst_id, owner, repo, head_sha, &check_details).await?; + } else { + self.gh.create_check_run(inst_id, owner, repo, vote.issue_number, &check_details).await?; + } + + // Record that it has been created + self.db + .record_vote_close_step(vote.vote_id, VoteCloseStep::CheckRun) + .await + .context("error recording vote close step") + } + + /// Gets the head commit of the pull request of a vote about to be closed. Votes on + /// issues have none. A failure reading it must not hold up closing the vote, as that + /// could leave it without results, so it is logged and the head is left unknown. + async fn get_close_head_sha(&self, vote: &Vote) -> Option { + if !vote.is_pull_request { + return None; + } + let inst_id = vote.installation_id as u64; + let (owner, repo) = split_full_name(&vote.repository_full_name); + match self.gh.get_pr_head_sha(inst_id, owner, repo, vote.issue_number).await { + Ok(head_sha) => Some(head_sha), + Err(err) => { + warn!(?err, "error getting head commit of vote being closed"); + None + } + } + } + + /// Posts the announcement of a vote being closed if enabled in the vote profile. On + /// a retry, an announcement created by a previous attempt is recorded instead of + /// being created again. + async fn post_close_announcement( + &self, + vote: &Vote, + results: &VoteResults, + is_retry: bool, + ) -> Result<()> { + // Announcements are only posted when enabled in the vote profile + let Some(discussions) = vote.cfg.announcements.as_ref().and_then(|a| a.discussions.as_ref()) else { + return Ok(()); + }; + + // Prepare the announcement + let inst_id = vote.installation_id as u64; + let (owner, repo) = split_full_name(&vote.repository_full_name); + let issue_title = vote.issue_title.as_deref().unwrap_or_default(); + let title = build_announcement_title(vote.issue_number, issue_title); + let body = tmpl::VoteClosedAnnouncement::new(vote.vote_id, vote.issue_number, issue_title, results) + .render()?; + + // Post it to discussions, unless a previous attempt created it already + let category = &discussions.category; + let publish = async { + if is_retry { + let marker = tmpl::VoteMarker::ResultsAnnouncement.render(vote.vote_id); + let since = marker_lookup_since(vote)?; + if self + .gh + .has_discussion_with_marker(inst_id, owner, repo, category, &marker, since) + .await + .context("error looking up announcement discussion")? + { + debug!("announcement discussion created by a previous attempt found"); + return Ok(()); + } + } + self.gh.create_discussion(inst_id, owner, repo, category, &title, &body).await + }; + match publish.await { + Ok(()) => {} + // Permissions required to create or look up discussions may not have been + // granted yet for this installation, or the category may not exist. Retrying + // won't help, so we raise a warning and give up on the announcement + Err(err) if err.is::() => { + warn!(?err, "announcement discussion rejected"); + } + Err(err) => return Err(err), + } + + // Record that it has been posted, or given up on when rejected + self.db + .record_vote_close_step(vote.vote_id, VoteCloseStep::Announcement) + .await + .context("error recording vote close step") + } + + /// Posts the results comment of a vote being closed. On a retry, a comment posted by + /// a previous attempt is recorded instead of being posted again. + async fn post_close_comment(&self, vote: &Vote, results: &VoteResults, is_retry: bool) -> Result<()> { + // Prepare the comment + let inst_id = vote.installation_id as u64; + let (owner, repo) = split_full_name(&vote.repository_full_name); + let body = tmpl::VoteClosed::new(vote.vote_id, results).render()?; + + // Post it on the issue/pr, unless a previous attempt posted it already + let marker = tmpl::VoteMarker::ResultsComment.render(vote.vote_id); + if is_retry + && has_previous_comment(&self.gh, vote, &marker) + .await + .context("error looking up results comment")? + { + debug!("results comment posted by a previous attempt found"); + } else { + self.gh.post_comment(inst_id, owner, repo, vote.issue_number, &body).await?; + } + + // Record that it has been posted + self.db + .record_vote_close_step(vote.vote_id, VoteCloseStep::Comment) + .await + .context("error recording vote close step") + } + + /// Publishes the state of a vote being closed on its issue/pr, running the steps + /// still missing of the check run and the labels. The outcome of each step tried is + /// returned along with its name. + async fn publish_close_state( + &self, + vote: &Vote, + results: Option<&VoteResults>, + head_sha: Option<&str>, + ) -> Vec<(&'static str, Result<()>)> { + // Create the check run of pull requests, unless a previous attempt did + let mut outcomes = vec![]; + if vote.is_pull_request && vote.close_check_run_created_at.is_none() { + let publish = self.create_close_check_run(vote, results, head_sha); + let outcome = run_close_state_step(&self.db, vote, VoteCloseStep::CheckRun, publish).await; + outcomes.push(("check run", outcome)); + } + + // Update the labels, unless a previous attempt did + if vote.close_labels_updated_at.is_none() { + let publish = self.update_close_labels(vote, results); + let outcome = run_close_state_step(&self.db, vote, VoteCloseStep::Labels, publish).await; + outcomes.push(("labels", outcome)); + } + outcomes + } + + /// Updates the labels of the issue/pr of a vote being closed. + async fn update_close_labels(&self, vote: &Vote, results: Option<&VoteResults>) -> Result<()> { + // Prepare the labels to add + let mut labels_to_add = vec![VOTE_CLOSED_LABEL]; + if let Some(results) = results { + if results.passed { + labels_to_add.push(VOTE_PASSED_LABEL); + } else { + labels_to_add.push(VOTE_FAILED_LABEL); + } + } + + // Update the labels, removing the vote open one on a best effort basis + let inst_id = vote.installation_id as u64; + let (owner, repo) = split_full_name(&vote.repository_full_name); + remove_label_best_effort(&self.gh, inst_id, owner, repo, vote.issue_number, VOTE_OPEN_LABEL).await; + self.gh.add_labels(inst_id, owner, repo, vote.issue_number, &labels_to_add).await?; + + // Record that they have been updated + self.db + .record_vote_close_step(vote.vote_id, VoteCloseStep::Labels) + .await + .context("error recording vote close step") + } +} + +// Helpers. + +/// Builds the title of the announcement of a closed vote. fn build_announcement_title(issue_number: i64, issue_title: &str) -> String { format!("{issue_title} #{issue_number} (vote closed)") } -#[cfg(test)] -mod tests; +/// Combines the outcomes of the steps tried, along with their names, in an attempt to +/// set up, close or cancel a vote. The attempt failed if any of them did: the first +/// error is the one returned, with the name of its step as context, and the others are +/// logged. +fn combine_step_outcomes(outcomes: Vec<(&'static str, Result<()>)>) -> Result<()> { + let mut errors = outcomes.into_iter().filter_map(|(step, result)| result.err().map(|err| (step, err))); + if let Some((first_step, first_err)) = errors.next() { + for (step, err) in errors { + warn!(?err, step, "error running vote step"); + } + return Err(first_err.context(format!("error running {first_step} step"))); + } + Ok(()) +} + +/// Checks if a previous attempt to close or cancel the vote provided posted a comment +/// with the marker given on its issue/pr. Only the comments posted since shortly before +/// the vote was closed are looked at, which cancelling it also does. +async fn has_previous_comment(gh: &DynGH, vote: &Vote, marker: &str) -> Result { + let inst_id = vote.installation_id as u64; + let (owner, repo) = split_full_name(&vote.repository_full_name); + let since = marker_lookup_since(vote)?; + gh.has_comment_with_marker(inst_id, owner, repo, vote.issue_number, marker, since).await +} + +/// Returns the time from which to look for what a previous attempt to close or cancel +/// the vote provided published: shortly before the vote was closed, which cancelling it +/// also does, allowing for clock differences between the database and GitHub. +fn marker_lookup_since(vote: &Vote) -> Result { + let closed_at = vote.closed_at.context("closed vote without close time")?; + Ok(closed_at - MARKER_LOOKUP_MARGIN) +} + +/// Publishes the cancellation of the vote provided on its issue/pr, running the steps +/// still missing, and deletes the vote once they are all done. This is done right after +/// the vote is cancelled by the commands handler, which is the first attempt, and +/// retried by the votes closer when it fails, so the caller must record the failure. On +/// a retry, a cancellation comment posted by a previous attempt is recorded instead of +/// being posted again. There are no results, so no announcement. +async fn publish_vote_cancellation(db: &DynDB, gh: &DynGH, vote: &Vote, is_retry: bool) -> Result<()> { + let inst_id = vote.installation_id as u64; + let (owner, repo) = split_full_name(&vote.repository_full_name); + + // Try all the steps missing, recording each one as soon as it is done. They are + // independent, so a failure must not hold up the others. The comment is posted + // first, unless a previous attempt posted it already + let mut outcomes = vec![]; + if vote.close_comment_posted_at.is_none() { + let publish = async { + let cancelled_by = vote.cancelled_by.as_deref().context("cancelled vote without user")?; + let marker = tmpl::VoteMarker::CancellationComment.render(vote.vote_id); + if is_retry + && has_previous_comment(gh, vote, &marker) + .await + .context("error looking up cancellation comment")? + { + debug!("cancellation comment posted by a previous attempt found"); + } else { + let body = + tmpl::VoteCancelled::new(vote.vote_id, cancelled_by, vote.is_pull_request).render()?; + gh.post_comment(inst_id, owner, repo, vote.issue_number, &body).await?; + } + + // Record that it has been posted + db.record_vote_close_step(vote.vote_id, VoteCloseStep::Comment) + .await + .context("error recording vote close step") + }; + outcomes.push(("comment", publish.await)); + } + + // The check run of pull requests and the labels are skipped if a newer vote has + // superseded them + if vote.is_pull_request && vote.close_check_run_created_at.is_none() { + let publish = async { + // Create the check run + let check_details = CheckDetails { + status: "completed".to_string(), + summary: "Vote cancelled".to_string(), + conclusion: Some("success".to_string()), + }; + gh.create_check_run(inst_id, owner, repo, vote.issue_number, &check_details).await?; + + // Record that it has been created + db.record_vote_close_step(vote.vote_id, VoteCloseStep::CheckRun) + .await + .context("error recording vote close step") + }; + let outcome = run_close_state_step(db, vote, VoteCloseStep::CheckRun, publish).await; + outcomes.push(("check run", outcome)); + } + if vote.close_labels_updated_at.is_none() { + // Update the labels and record it. Removing the vote open label is best effort, + // as when closing the vote + let publish = async { + remove_label_best_effort(gh, inst_id, owner, repo, vote.issue_number, VOTE_OPEN_LABEL).await; + db.record_vote_close_step(vote.vote_id, VoteCloseStep::Labels) + .await + .context("error recording vote close step") + }; + let outcome = run_close_state_step(db, vote, VoteCloseStep::Labels, publish).await; + outcomes.push(("labels", outcome)); + } + + // The attempt failed if any of them did + combine_step_outcomes(outcomes)?; + + // Nothing else is needed from the vote once its cancellation has been published + db.complete_vote_cancellation(vote.vote_id) + .await + .context("error completing vote cancellation") +} + +/// Removes the label provided from the issue/pr given on a best effort basis, logging +/// the failure. A label the issue/pr does not have is not a failure. +async fn remove_label_best_effort( + gh: &DynGH, + inst_id: u64, + owner: &str, + repo: &str, + issue_number: i64, + label: &str, +) { + if let Err(err) = gh.remove_label(inst_id, owner, repo, issue_number, label).await { + warn!(?err, owner, repo, issue_number, label, "error removing label"); + } +} + +/// Runs an attempt to close a vote or to publish its cancellation, failing it if it +/// takes longer than [`CLOSE_ATTEMPT_TIMEOUT`]. The attempt is stopped then, wherever it +/// is, so it may have published some steps without recording them. +async fn run_close_attempt(attempt: impl Future>) -> Result<()> { + match timeout(CLOSE_ATTEMPT_TIMEOUT, attempt).await { + Ok(result) => result, + Err(_) => Err(format_err!( + "attempt timed out after {} seconds", + CLOSE_ATTEMPT_TIMEOUT.as_secs() + )), + } +} + +/// Runs a step publishing the state of a vote being closed or cancelled. The state +/// reflects the latest vote, so the step is only published while it is pending, as +/// described in +/// [`DB::is_close_state_step_pending`](crate::db::DB::is_close_state_step_pending). +/// Otherwise it is recorded as done without publishing anything. The vote stored is +/// checked right before each step, rather than the one read when claiming it, as a newer +/// vote can be created, and even cancelled and deleted, while a previous step is waiting +/// for GitHub. A newer vote created while the step itself is waiting for GitHub is not +/// seen, as that would require holding a lock across the GitHub call. +async fn run_close_state_step( + db: &DynDB, + vote: &Vote, + step: VoteCloseStep, + publish: impl Future>, +) -> Result<()> { + // Record the step as done without publishing it if it is not pending anymore + if !db + .is_close_state_step_pending(vote.vote_id, step) + .await + .context("error checking if vote close step is pending")? + { + return db + .record_vote_close_step(vote.vote_id, step) + .await + .context("error recording vote close step"); + } + + // Publish the state + publish.await +} diff --git a/src/processor/tests.rs b/src/processor/tests.rs index ba8f553..d391742 100644 --- a/src/processor/tests.rs +++ b/src/processor/tests.rs @@ -1,2301 +1,20 @@ -use std::{sync::Arc, vec}; - -use anyhow::format_err; -use mockall::{Sequence, predicate::eq}; -use time::ext::NumericalDuration; - -use crate::results::{REACTION_IN_FAVOR, Vote}; -use crate::testutil::*; -use crate::{ - cfg_repo::{AllowedVoters, PassThresholdBase}, - db::MockDB, - github::*, -}; - +use self::helpers::*; use super::*; -#[tokio::test] -async fn votes_processor_stops_when_requested() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_close_finished_vote().times(1).returning(|_| Box::pin(future::ready(Ok(None)))); - db.expect_get_pending_status_checks() - .times(1) - .returning(|| Box::pin(future::ready(Ok(vec![])))); - db.expect_get_open_votes_with_close_on_passing() - .times(1) - .returning(|| Box::pin(future::ready(Ok(vec![])))); - let gh = MockGH::new(); - - // Run the processor until it's asked to stop - let (cmds_tx, cmds_rx) = async_channel::unbounded(); - let cancel_token = CancellationToken::new(); - let votes_processor = Processor::new(Arc::new(db), Arc::new(gh), cmds_tx, cmds_rx); - let votes_processor_handle = votes_processor.run(&cancel_token); - cancel_token.cancel(); - - // Check all worker tasks completed - assert!(votes_processor_handle.await.iter().all(Result::is_ok)); -} - -#[tokio::test] -async fn commands_handler_processes_create_and_check_vote_cmds() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_get_open_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(None)))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(None))); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::ConfigNotFound {}.render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - - // Queue commands - let (cmds_tx, cmds_rx) = async_channel::unbounded(); - let event = Event::Issue(setup_test_issue_event()); - cmds_tx.send(Command::CreateVote(CreateVoteInput::new(None, &event))).await.unwrap(); - cmds_tx.send(Command::CheckVote(CheckVoteInput::new(&event))).await.unwrap(); - - // Run the commands handler until it's asked to stop - let cancel_token = CancellationToken::new(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx.clone()); - let cmds_handler_handle = cmds_handler.run(cancel_token.clone()); - cancel_token.cancel(); - - // Check all commands were processed - assert!(cmds_handler_handle.await.is_ok()); - assert!(cmds_rx.is_empty()); -} - -#[tokio::test] -async fn commands_handler_stops_after_processing_queued_cmd() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_cancel_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - - // Queue command - let (cmds_tx, cmds_rx) = async_channel::unbounded(); - let cancel_token = CancellationToken::new(); - let event = setup_test_issue_event(); - let cmd = Command::CancelVote(CancelVoteInput::new(&Event::Issue(event))); - cmds_tx.send(cmd).await.unwrap(); - - // Run the commands handler until it's asked to stop - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx.clone()); - let cmds_handler_handle = cmds_handler.run(cancel_token.clone()); - cancel_token.cancel(); - - // Check the command was processed - assert!(cmds_handler_handle.await.is_ok()); - assert!(cmds_rx.is_empty()); -} - -#[tokio::test] -async fn commands_handler_stops_when_requested() { - // Setup mocks - let db = MockDB::new(); - let gh = MockGH::new(); - - // Run the commands handler until it's asked to stop - let (_, cmds_rx) = async_channel::unbounded(); - let cancel_token = CancellationToken::new(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - let cmds_handler_handle = cmds_handler.run(cancel_token.clone()); - cancel_token.cancel(); - - // Check the commands handler completed - assert!(cmds_handler_handle.await.is_ok()); -} - -#[tokio::test] -async fn create_vote_error_adding_labels() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_has_vote_open() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(false)))); - db.expect_store_vote() - .withf(|vote_comment_id, _, _| *vote_comment_id == COMMENT_ID) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Ok(Uuid::parse_str(VOTE_ID).unwrap())))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - expect_valid_config(&mut gh); - expect_user_is_collaborator(&mut gh, true); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, _| { - *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_remove_label() - .withf(|inst_id, owner, repo, issue_number, _| { - *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM - }) - .times(3) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![GITVOTE_LABEL, VOTE_OPEN_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - - // Run and check the error is propagated - let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); - let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); - assert_eq!(err.to_string(), ERROR); -} - -#[tokio::test] -async fn create_vote_error_checking_if_user_is_collaborator() { - // Setup GitHub expectations - let db = MockDB::new(); - let mut gh = MockGH::new(); - expect_valid_config(&mut gh); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - gh.expect_post_comment().never(); - - // Run and check the error is propagated - let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); - let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); - assert_eq!(err.to_string(), ERROR); -} - -#[tokio::test] -async fn create_vote_error_checking_if_vote_is_open() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_has_vote_open() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - db.expect_store_vote().never(); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - expect_valid_config(&mut gh); - expect_user_is_collaborator(&mut gh, true); - gh.expect_post_comment().never(); - - // Run and check the error is propagated - let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); - let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); - assert_eq!(err.to_string(), ERROR); -} - -#[tokio::test] -async fn create_vote_error_getting_configuration_profile() { - // Setup GitHub expectations - let db = MockDB::new(); - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(None))); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::ConfigNotFound {}.render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - - // Run and check the configuration error is posted - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_issue_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.create_vote(&CreateVoteInput::new(None, &Event::Issue(event))).await.unwrap(); -} - -#[tokio::test] -async fn create_vote_error_posting_vote_created_comment() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_has_vote_open() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(false)))); - db.expect_store_vote().never(); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - expect_valid_config(&mut gh); - expect_user_is_collaborator(&mut gh, true); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, _| { - *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - gh.expect_add_labels().never(); - - // Run and check the error is propagated - let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); - let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); - assert_eq!(err.to_string(), ERROR); -} - -#[tokio::test] -async fn create_vote_error_storing_vote() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_has_vote_open() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(false)))); - db.expect_store_vote() - .withf(|vote_comment_id, _, _| *vote_comment_id == COMMENT_ID) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - expect_valid_config(&mut gh); - expect_user_is_collaborator(&mut gh, true); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, _| { - *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_create_check_run().never(); - gh.expect_remove_label().never(); - gh.expect_add_labels().never(); - - // Run and check the error is propagated - let input = CreateVoteInput::new(None, &Event::PullRequest(setup_test_pr_event())); - let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); - assert_eq!(err.to_string(), ERROR); -} - -#[tokio::test] -async fn create_vote_invalid_config_teams_owner_not_org() { - // Setup GitHub expectations - let db = MockDB::new(); - let mut gh = MockGH::new(); - expect_valid_config(&mut gh); - gh.expect_user_is_collaborator().never(); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = - tmpl::InvalidConfig::new("teams in allowed voters can only be used in organizations") - .render() - .unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - - // Run the command on a repository that does not belong to an organization - let mut event = setup_test_issue_event(); - event.organization = None; - let input = CreateVoteInput::new(Some(PROFILE_NAME), &Event::Issue(event)); - setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); -} - -#[tokio::test] -async fn create_vote_issue_already_has_a_vote() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_has_vote_open() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(true)))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteInProgress::new(USER, false).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - - // Run and check the vote is not created - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_issue_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.create_vote(&CreateVoteInput::new(None, &Event::Issue(event))).await.unwrap(); -} - -#[tokio::test] -async fn create_vote_non_collaborator() { - // Setup GitHub expectations - let db = MockDB::new(); - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(false)))); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteRestricted::new(USER).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - - // Run and check the vote is not created - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_issue_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.create_vote(&CreateVoteInput::new(None, &Event::Issue(event))).await.unwrap(); -} - -#[tokio::test] -async fn create_vote_pr_error_creating_check_run() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_has_vote_open() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(false)))); - db.expect_store_vote() - .withf(|vote_comment_id, _, _| *vote_comment_id == COMMENT_ID) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Ok(Uuid::parse_str(VOTE_ID).unwrap())))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - expect_valid_config(&mut gh); - expect_user_is_collaborator(&mut gh, true); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, _| { - *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_create_check_run() - .withf(|inst_id, owner, repo, issue_number, _| { - *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - gh.expect_remove_label().never(); - gh.expect_add_labels().never(); - - // Run and check the error is propagated - let input = CreateVoteInput::new(None, &Event::PullRequest(setup_test_pr_event())); - let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); - assert_eq!(err.to_string(), ERROR); -} - -#[tokio::test] -async fn create_vote_pr_success() { - // Setup input - let event = setup_test_pr_event(); - let create_vote_input = CreateVoteInput::new(None, &Event::PullRequest(event)); - let cfg = CfgProfile { - duration: Duration::from_mins(5), - pass_threshold: 50.0, - allowed_voters: Some(AllowedVoters::default()), - ..Default::default() - }; - - // Setup database expectations - let mut db = MockDB::new(); - db.expect_has_vote_open() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(false)))); - let cfg_copy = cfg.clone(); - let create_vote_input_copy = create_vote_input.clone(); - db.expect_store_vote() - .withf(move |vote_comment_id, input, cfg| { - *vote_comment_id == COMMENT_ID && *input == create_vote_input_copy && *cfg == cfg_copy - }) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Ok(Uuid::new_v4())))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - let create_vote_input_copy = create_vote_input.clone(); - gh.expect_post_comment() - .withf(move |inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteCreated::new(&create_vote_input_copy, &cfg).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_create_check_run() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(CheckDetails { - status: "in_progress".to_string(), - conclusion: None, - summary: "Vote open".to_string(), - }), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(VOTE_CLOSED_LABEL), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(VOTE_PASSED_LABEL), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(VOTE_FAILED_LABEL), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![GITVOTE_LABEL, VOTE_OPEN_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - // Run and check the vote is created successfully - let (_, cmds_rx) = async_channel::unbounded(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.create_vote(&create_vote_input).await.unwrap(); -} - -#[tokio::test] -async fn create_vote_profile_not_found() { - // Setup GitHub expectations - let db = MockDB::new(); - let mut gh = MockGH::new(); - expect_valid_config(&mut gh); - gh.expect_user_is_collaborator().never(); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::ConfigProfileNotFound {}.render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - - // Run the command using a profile that does not exist - let input = CreateVoteInput::new(Some("profile9"), &Event::Issue(setup_test_issue_event())); - setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); -} - -#[tokio::test] -async fn create_vote_removing_labels_errors_are_ignored() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_has_vote_open() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(false)))); - db.expect_store_vote() - .withf(|vote_comment_id, _, _| *vote_comment_id == COMMENT_ID) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Ok(Uuid::parse_str(VOTE_ID).unwrap())))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - expect_valid_config(&mut gh); - expect_user_is_collaborator(&mut gh, true); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, _| { - *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_remove_label() - .withf(|inst_id, owner, repo, issue_number, _| { - *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM - }) - .times(3) - .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![GITVOTE_LABEL, VOTE_OPEN_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - // Run and check the vote is created successfully - let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); - setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); -} - -#[tokio::test] -async fn create_vote_success() { - // Setup input - let event = setup_test_issue_event(); - let create_vote_input = CreateVoteInput::new(None, &Event::Issue(event)); - let cfg = CfgProfile { - duration: Duration::from_mins(5), - pass_threshold: 50.0, - allowed_voters: Some(AllowedVoters::default()), - ..Default::default() - }; - - // Setup database expectations - let mut db = MockDB::new(); - db.expect_has_vote_open() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(false)))); - let cfg_copy = cfg.clone(); - let create_vote_input_copy = create_vote_input.clone(); - db.expect_store_vote() - .withf(move |vote_comment_id, input, cfg| { - *vote_comment_id == COMMENT_ID && *input == create_vote_input_copy && *cfg == cfg_copy - }) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Ok(Uuid::new_v4())))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - let create_vote_input_copy = create_vote_input.clone(); - gh.expect_post_comment() - .withf(move |inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteCreated::new(&create_vote_input_copy, &cfg).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_remove_label() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(VOTE_CLOSED_LABEL), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(VOTE_PASSED_LABEL), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(VOTE_FAILED_LABEL), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![GITVOTE_LABEL, VOTE_OPEN_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - // Run and check the vote is created successfully - let (_, cmds_rx) = async_channel::unbounded(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.create_vote(&create_vote_input).await.unwrap(); -} - -#[tokio::test] -#[should_panic(expected = "error cancelling vote")] -async fn cancel_vote_error_cancelling() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_cancel_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - - // Run and check the error is propagated - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_issue_comment_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler - .cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event))) - .await - .unwrap(); -} - -#[tokio::test] -#[should_panic(expected = "error checking if user is collaborator")] -async fn cancel_vote_error_checking_if_user_is_collaborator() { - // Setup GitHub expectations - let db = MockDB::new(); - let mut gh = MockGH::new(); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - - // Run and check the error is propagated - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_issue_comment_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler - .cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event))) - .await - .unwrap(); -} - -#[tokio::test] -async fn cancel_vote_error_posting_comment() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_cancel_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(Some(Uuid::parse_str(VOTE_ID).unwrap()))))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - expect_user_is_collaborator(&mut gh, true); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, _| { - *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - gh.expect_remove_label().never(); - - // Run and check the error is propagated - let input = CancelVoteInput::new(&Event::IssueComment(setup_test_issue_comment_event())); - let err = setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap_err(); - assert_eq!(err.to_string(), ERROR); -} - -#[tokio::test] -async fn cancel_vote_in_pr_error_creating_check_run() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_cancel_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(Some(Uuid::parse_str(VOTE_ID).unwrap()))))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - expect_user_is_collaborator(&mut gh, true); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteCancelled::new(USER, true).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_create_check_run() - .withf(|inst_id, owner, repo, issue_number, _| { - *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - gh.expect_remove_label().never(); - - // Run and check the error is propagated - let input = CancelVoteInput::new(&Event::PullRequest(setup_test_pr_event())); - let err = setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap_err(); - assert_eq!(err.to_string(), ERROR); -} - -#[tokio::test] -async fn cancel_vote_in_pr_no_vote_in_progress() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_cancel_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(None)))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - expect_user_is_collaborator(&mut gh, true); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::NoVoteInProgress::new(USER, true).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_create_check_run().never(); - gh.expect_remove_label().never(); - - // Run and check the vote is not cancelled - let input = CancelVoteInput::new(&Event::PullRequest(setup_test_pr_event())); - setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap(); -} - -#[tokio::test] -async fn cancel_vote_in_pr_success() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_cancel_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(Some(Uuid::parse_str(VOTE_ID).unwrap()))))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteCancelled::new(USER, true).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_create_check_run() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(CheckDetails { - status: "completed".to_string(), - conclusion: Some("success".to_string()), - summary: "Vote cancelled".to_string(), - }), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - // Run and check the vote is cancelled successfully - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_pr_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.cancel_vote(&CancelVoteInput::new(&Event::PullRequest(event))).await.unwrap(); -} - -#[tokio::test] -async fn cancel_vote_no_vote_in_progress() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_cancel_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(None)))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::NoVoteInProgress::new(USER, false).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - - // Run and check the vote is not cancelled - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_issue_comment_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler - .cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event))) - .await - .unwrap(); -} - -#[tokio::test] -async fn cancel_vote_only_collaborators_can_close_votes() { - // Setup GitHub expectations - let db = MockDB::new(); - let mut gh = MockGH::new(); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(false)))); - - // Run and check the vote is not cancelled - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_issue_comment_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler - .cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event))) - .await - .unwrap(); -} - -#[tokio::test] -async fn cancel_vote_removing_label_error_is_ignored() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_cancel_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(Some(Uuid::parse_str(VOTE_ID).unwrap()))))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - expect_user_is_collaborator(&mut gh, true); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, _| { - *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - - // Run and check the vote is cancelled successfully - let input = CancelVoteInput::new(&Event::IssueComment(setup_test_issue_comment_event())); - setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap(); -} - -#[tokio::test] -async fn cancel_vote_success() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_cancel_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(Some(Uuid::parse_str(VOTE_ID).unwrap()))))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteCancelled::new(USER, false).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - // Run and check the vote is cancelled successfully - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_issue_comment_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler - .cancel_vote(&CancelVoteInput::new(&Event::IssueComment(event))) - .await - .unwrap(); -} - -#[tokio::test] -async fn check_vote_checked_long_ago() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_get_open_vote().with(eq(REPOFN), eq(ISSUE_NUM)).times(1).returning(|_, _| { - Box::pin(future::ready(Ok(Some(Vote { - checked_at: OffsetDateTime::now_utc().checked_sub(25.hours()), - ..setup_test_vote() - })))) - }); - db.expect_update_vote_last_check() - .with(eq(Uuid::parse_str(VOTE_ID).unwrap())) - .times(1) - .returning(|_| Box::pin(future::ready(Ok(())))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let results = setup_test_vote_results(); - let expected_body = tmpl::VoteStatus::new(&results).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - - // Run the check - let input = CheckVoteInput::new(&Event::PullRequest(setup_test_pr_event())); - setup_cmds_handler(db, gh).check_vote(&input).await.unwrap(); -} - -#[tokio::test] -async fn check_vote_checked_recently() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_get_open_vote().with(eq(REPOFN), eq(ISSUE_NUM)).times(1).returning(|_, _| { - Box::pin(future::ready(Ok(Some(Vote { - checked_at: OffsetDateTime::now_utc().checked_sub(1.hours()), - ..setup_test_vote() - })))) - }); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteCheckedRecently {}.render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - - // Run and check the recently checked comment is posted - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_pr_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.check_vote(&CheckVoteInput::new(&Event::PullRequest(event))).await.unwrap(); -} - -#[tokio::test] -async fn check_vote_error_calculating_results() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_get_open_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(Some(setup_test_vote()))))); - db.expect_update_vote_last_check().never(); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_get_comment_reactions() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - gh.expect_post_comment().never(); - - // Run and check the error is propagated - let input = CheckVoteInput::new(&Event::PullRequest(setup_test_pr_event())); - let err = setup_cmds_handler(db, gh).check_vote(&input).await.unwrap_err(); - assert_eq!(err.to_string(), ERROR); -} - -#[tokio::test] -#[should_panic(expected = "error getting open vote")] -async fn check_vote_error_getting_vote() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_get_open_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - let gh = MockGH::new(); - - // Run and check the error is propagated - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_pr_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.check_vote(&CheckVoteInput::new(&Event::PullRequest(event))).await.unwrap(); -} - -#[tokio::test] -async fn check_vote_error_updating_last_check() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_get_open_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(Some(setup_test_vote()))))); - db.expect_update_vote_last_check() - .with(eq(Uuid::parse_str(VOTE_ID).unwrap())) - .times(1) - .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, _| { - *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - - // Run and check the error is propagated - let input = CheckVoteInput::new(&Event::PullRequest(setup_test_pr_event())); - let err = setup_cmds_handler(db, gh).check_vote(&input).await.unwrap_err(); - assert_eq!(err.to_string(), ERROR); -} - -#[tokio::test] -async fn check_vote_not_found() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_get_open_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(None)))); - let gh = MockGH::new(); - - // Run and check the missing vote is ignored - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_pr_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.check_vote(&CheckVoteInput::new(&Event::PullRequest(event))).await.unwrap(); -} - -#[tokio::test] -async fn check_vote_success() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_get_open_vote() - .with(eq(REPOFN), eq(ISSUE_NUM)) - .times(1) - .returning(|_, _| Box::pin(future::ready(Ok(Some(setup_test_vote()))))); - db.expect_update_vote_last_check() - .with(eq(Uuid::parse_str(VOTE_ID).unwrap())) - .times(1) - .returning(|_| Box::pin(future::ready(Ok(())))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_get_comment_reactions() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) - .times(1) - .returning(|_, _, _, _| { - Box::pin(future::ready(Ok(vec![Reaction { - user: User { - login: USER1.to_string(), - }, - content: REACTION_IN_FAVOR.to_string(), - created_at: TIMESTAMP.to_string(), - }]))) - }); - gh.expect_get_allowed_voters() - .withf(|inst_id, cfg, owner, repo, org| { - *inst_id == INST_ID - && *cfg == setup_test_vote().cfg - && owner == ORG - && repo == REPO - && *org == Some(ORG.to_string()).as_ref() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(vec![USER1.to_string()])))); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let results = setup_test_vote_results(); - let expected_body = tmpl::VoteStatus::new(&results).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - - // Run and check the vote status is posted - let (_, cmds_rx) = async_channel::unbounded(); - let event = setup_test_pr_event(); - let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); - cmds_handler.check_vote(&CheckVoteInput::new(&Event::PullRequest(event))).await.unwrap(); -} - -#[tokio::test] -async fn votes_closer_closes_pending_votes_until_none_left() { - // Setup database expectations - let (none_left_tx, none_left_rx) = tokio::sync::oneshot::channel(); - let mut seq = Sequence::new(); - let mut db = MockDB::new(); - db.expect_close_finished_vote() - .times(1) - .in_sequence(&mut seq) - .returning(|_| Box::pin(future::ready(Ok(Some((setup_test_vote(), None)))))); - db.expect_close_finished_vote().times(1).in_sequence(&mut seq).return_once(move |_| { - none_left_tx.send(()).unwrap(); - Box::pin(future::ready(Ok(None))) - }); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![VOTE_CLOSED_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - // Run the votes closer until no pending votes are left - let cancel_token = CancellationToken::new(); - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - let votes_closer_handle = votes_closer.run(cancel_token.clone()); - none_left_rx.await.unwrap(); - cancel_token.cancel(); - - // Check the worker completed (expectations are verified on drop) - assert!(votes_closer_handle.await.is_ok()); -} - -#[tokio::test] -async fn votes_closer_stops_when_requested_error_closing() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_close_finished_vote() - .times(1) - .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); - let gh = MockGH::new(); - - // Run the votes closer until it's asked to stop - let cancel_token = CancellationToken::new(); - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - let votes_closer_handle = votes_closer.run(cancel_token.clone()); - cancel_token.cancel(); - - // Check the worker completed - assert!(votes_closer_handle.await.is_ok()); -} - -#[tokio::test] -async fn votes_closer_stops_when_requested_none_closed() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_close_finished_vote().times(1).returning(|_| Box::pin(future::ready(Ok(None)))); - let gh = MockGH::new(); - - // Run the votes closer until it's asked to stop - let cancel_token = CancellationToken::new(); - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - let votes_closer_handle = votes_closer.run(cancel_token.clone()); - cancel_token.cancel(); - - // Check the worker completed - assert!(votes_closer_handle.await.is_ok()); -} - -#[tokio::test] -async fn close_finished_vote_error_adding_labels() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_close_finished_vote().times(1).returning(|_| { - let mut vote = setup_test_vote(); - vote.cfg.announcements = None; - Box::pin(future::ready(Ok(Some((vote, Some(setup_test_vote_results())))))) - }); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, _| { - *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - - // Run and check the error is propagated - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - let err = votes_closer.close_finished_vote().await.unwrap_err(); - assert_eq!(err.to_string(), ERROR); -} - -#[tokio::test] -#[should_panic(expected = "error closing finished vote")] -async fn close_finished_vote_error_closing() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_close_finished_vote() - .times(1) - .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); - let gh = MockGH::new(); - - // Run and check the error is propagated - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - votes_closer.close_finished_vote().await.unwrap(); -} - -#[tokio::test] -async fn close_finished_vote_error_creating_announcement_is_ignored() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_close_finished_vote().times(1).returning(|_| { - Box::pin(future::ready(Ok(Some(( - setup_test_vote(), - Some(setup_test_vote_results()), - ))))) - }); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, _| { - *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_create_discussion() - .withf(|inst_id, owner, repo, category, _, _| { - *inst_id == INST_ID && owner == ORG && repo == REPO && category == DISCUSSIONS_CATEGORY - }) - .times(1) - .returning(|_, _, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - // Run and check the vote is closed successfully - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); -} - -#[tokio::test] -async fn close_finished_vote_error_posting_comment() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_close_finished_vote().times(1).returning(|_| { - Box::pin(future::ready(Ok(Some(( - setup_test_vote(), - Some(setup_test_vote_results()), - ))))) - }); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, _| { - *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - gh.expect_create_discussion().never(); - gh.expect_remove_label().never(); - gh.expect_add_labels().never(); - - // Run and check the error is propagated - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - let err = votes_closer.close_finished_vote().await.unwrap_err(); - assert_eq!(err.to_string(), ERROR); -} - -#[tokio::test] -async fn close_finished_vote_none_closed() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_close_finished_vote().times(1).returning(|_| Box::pin(future::ready(Ok(None)))); - let gh = MockGH::new(); - - // Run and check no vote is closed - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - votes_closer.close_finished_vote().await.unwrap(); -} - -#[tokio::test] -async fn close_finished_vote_on_issue() { - // Setup vote results - let results = setup_test_vote_results(); - let results_copy = results.clone(); - let results_copy2 = results.clone(); - - // Setup database expectations - let mut db = MockDB::new(); - db.expect_close_finished_vote().times(1).returning(move |_| { - Box::pin(future::ready(Ok(Some(( - setup_test_vote(), - Some(results_copy.clone()), - ))))) - }); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_post_comment() - .withf(move |inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteClosed::new(&results_copy2).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_create_discussion() - .withf(move |inst_id, owner, repo, category, title, body| { - let expected_body = - tmpl::VoteClosedAnnouncement::new(ISSUE_NUM, TITLE, &results).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && category == DISCUSSIONS_CATEGORY - && title == build_announcement_title(ISSUE_NUM, TITLE) - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - // Run and check the vote is closed - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - votes_closer.close_finished_vote().await.unwrap(); -} - -#[tokio::test] -async fn close_finished_vote_on_issue_without_results() { - // Setup database expectations (vote comment was deleted) - let mut db = MockDB::new(); - db.expect_close_finished_vote() - .times(1) - .returning(|_| Box::pin(future::ready(Ok(Some((setup_test_vote(), None)))))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_post_comment().never(); - gh.expect_create_discussion().never(); - gh.expect_create_check_run().never(); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![VOTE_CLOSED_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - // Run and check the vote is closed - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); -} - -#[tokio::test] -async fn close_finished_vote_on_pr_closed_vote_not_passed() { - // Setup vote results - let mut results = setup_test_vote_results(); - results.passed = false; - results.in_favor_percentage = 0.0; - results.in_favor = 0; - results.against = 1; - let results_copy = results.clone(); - let results_copy2 = results.clone(); - - // Setup database expectations - let mut db = MockDB::new(); - db.expect_close_finished_vote().times(1).returning(move |_| { - let mut vote = setup_test_vote(); - vote.is_pull_request = true; - Box::pin(future::ready(Ok(Some((vote, Some(results_copy.clone())))))) - }); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_post_comment() - .withf(move |inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteClosed::new(&results_copy2).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_create_discussion() - .withf(move |inst_id, owner, repo, category, title, body| { - let expected_body = - tmpl::VoteClosedAnnouncement::new(ISSUE_NUM, TITLE, &results).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && category == DISCUSSIONS_CATEGORY - && title == build_announcement_title(ISSUE_NUM, TITLE) - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_create_check_run() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(CheckDetails { - status: "completed".to_string(), - conclusion: Some("failure".to_string()), - summary: "The vote did not pass. 0 out of 1 voted in favor.".to_string(), - }), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![VOTE_CLOSED_LABEL, VOTE_FAILED_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - // Run and check the PR vote is closed - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - votes_closer.close_finished_vote().await.unwrap(); -} - -#[tokio::test] -async fn close_finished_vote_on_pr_closed_vote_passed() { - // Setup vote results - let results = setup_test_vote_results(); - let results_copy = results.clone(); - let results_copy2 = results.clone(); - - // Setup database expectations - let mut db = MockDB::new(); - db.expect_close_finished_vote().times(1).returning(move |_| { - let mut vote = setup_test_vote(); - vote.is_pull_request = true; - Box::pin(future::ready(Ok(Some((vote, Some(results_copy.clone())))))) - }); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_post_comment() - .withf(move |inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteClosed::new(&results_copy2).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_create_discussion() - .withf(move |inst_id, owner, repo, category, title, body| { - let expected_body = - tmpl::VoteClosedAnnouncement::new(ISSUE_NUM, TITLE, &results).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && category == DISCUSSIONS_CATEGORY - && title == build_announcement_title(ISSUE_NUM, TITLE) - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_create_check_run() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(CheckDetails { - status: "completed".to_string(), - conclusion: Some("success".to_string()), - summary: "The vote passed! 1 out of 1 voted in favor.".to_string(), - }), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - // Run and check the PR vote is closed - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - votes_closer.close_finished_vote().await.unwrap(); -} - -#[tokio::test] -async fn close_finished_vote_on_pr_without_results() { - // Setup database expectations (vote comment was deleted) - let mut db = MockDB::new(); - db.expect_close_finished_vote().times(1).returning(|_| { - let mut vote = setup_test_vote(); - vote.is_pull_request = true; - Box::pin(future::ready(Ok(Some((vote, None))))) - }); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_post_comment().never(); - gh.expect_create_discussion().never(); - gh.expect_create_check_run() - .with( - eq(INST_ID), - eq(ORG), - eq(REPO), - eq(ISSUE_NUM), - eq(CheckDetails { - status: "completed".to_string(), - conclusion: Some("success".to_string()), - summary: "The vote was cancelled".to_string(), - }), - ) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![VOTE_CLOSED_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - // Run and check the vote is closed - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); -} - -#[tokio::test] -async fn close_finished_vote_removing_label_error_is_ignored() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_close_finished_vote() - .times(1) - .returning(|_| Box::pin(future::ready(Ok(Some((setup_test_vote(), None)))))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![VOTE_CLOSED_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - // Run and check the vote is closed successfully - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); -} - -#[tokio::test] -async fn close_finished_vote_without_announcements() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_close_finished_vote().times(1).returning(|_| { - let mut vote = setup_test_vote(); - vote.cfg.announcements = None; - Box::pin(future::ready(Ok(Some((vote, Some(setup_test_vote_results())))))) - }); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, body| { - let expected_body = tmpl::VoteClosed::new(&setup_test_vote_results()).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_create_discussion().never(); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - // Run and check the vote is closed - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); -} - -#[tokio::test] -async fn close_finished_vote_without_issue_title() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_close_finished_vote().times(1).returning(|_| { - let mut vote = setup_test_vote(); - vote.issue_title = None; - Box::pin(future::ready(Ok(Some((vote, Some(setup_test_vote_results())))))) - }); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_post_comment() - .withf(|inst_id, owner, repo, issue_number, _| { - *inst_id == INST_ID && owner == ORG && repo == REPO && *issue_number == ISSUE_NUM - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); - gh.expect_create_discussion() - .withf(|inst_id, owner, repo, category, title, body| { - let results = setup_test_vote_results(); - let expected_body = tmpl::VoteClosedAnnouncement::new(ISSUE_NUM, "", &results).render().unwrap(); - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && category == DISCUSSIONS_CATEGORY - && title == build_announcement_title(ISSUE_NUM, "") - && body == expected_body.as_str() - }) - .times(1) - .returning(|_, _, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_remove_label() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - gh.expect_add_labels() - .withf(|inst_id, owner, repo, issue_number, labels| { - *inst_id == INST_ID - && owner == ORG - && repo == REPO - && *issue_number == ISSUE_NUM - && labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL] - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); - - // Run and check the vote is closed - let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); - assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); -} - -#[tokio::test] -async fn status_checker_enqueues_all_pending_status_checks() { - // Setup database expectations - let first_input = CheckVoteInput { - issue_number: 1, - repository_full_name: REPOFN.to_string(), - }; - let second_input = CheckVoteInput { - issue_number: 2, - repository_full_name: REPOFN.to_string(), - }; - let mut db = MockDB::new(); - db.expect_get_pending_status_checks().times(1).returning({ - let inputs = vec![first_input.clone(), second_input.clone()]; - move || Box::pin(future::ready(Ok(inputs.clone()))) - }); - - // Run the status checker until it's asked to stop - let (cmds_tx, cmds_rx) = async_channel::unbounded(); - let cancel_token = CancellationToken::new(); - let status_checker = StatusChecker::new(Arc::new(db), cmds_tx); - let status_checker_handle = status_checker.run(cancel_token.clone()); - cancel_token.cancel(); - - // Check commands were enqueued in order - assert!(status_checker_handle.await.is_ok()); - assert_eq!(cmds_rx.recv().await.unwrap(), Command::CheckVote(first_input)); - assert_eq!(cmds_rx.recv().await.unwrap(), Command::CheckVote(second_input)); - assert!(cmds_rx.is_empty()); -} - -#[tokio::test] -async fn status_checker_stops_after_processing_pending_status_check() { - // Setup input - let check_vote_input = CheckVoteInput { - repository_full_name: "repo_full_name".to_string(), - issue_number: 1, - }; - let check_vote_input_copy = check_vote_input.clone(); - - // Setup database expectations - let mut db = MockDB::new(); - db.expect_get_pending_status_checks() - .times(1) - .returning(move || Box::pin(future::ready(Ok(vec![check_vote_input_copy.clone()])))); - - // Run the status checker until it's asked to stop - let (cmds_tx, cmds_rx) = async_channel::unbounded(); - let cancel_token = CancellationToken::new(); - let status_checker = StatusChecker::new(Arc::new(db), cmds_tx); - let status_checker_handle = status_checker.run(cancel_token.clone()); - cancel_token.cancel(); - - // Check the command was enqueued - assert!(status_checker_handle.await.is_ok()); - assert_eq!( - cmds_rx.recv().await.unwrap(), - Command::CheckVote(check_vote_input) - ); -} - -#[tokio::test] -async fn status_checker_stops_when_requested_error_getting_pending() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_get_pending_status_checks() - .times(1) - .returning(|| Box::pin(future::ready(Err(format_err!(ERROR))))); - - // Run the status checker until it's asked to stop - let (cmds_tx, cmds_rx) = async_channel::unbounded(); - let cancel_token = CancellationToken::new(); - let status_checker = StatusChecker::new(Arc::new(db), cmds_tx); - let status_checker_handle = status_checker.run(cancel_token.clone()); - cancel_token.cancel(); - - // Check no commands were enqueued - assert!(status_checker_handle.await.is_ok()); - assert!(cmds_rx.is_empty()); -} - -#[tokio::test] -async fn status_checker_stops_when_requested_none_pending() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_get_pending_status_checks() - .times(1) - .returning(|| Box::pin(future::ready(Ok(vec![])))); - - // Run the status checker until it's asked to stop - let (cmds_tx, cmds_rx) = async_channel::unbounded(); - let cancel_token = CancellationToken::new(); - let status_checker = StatusChecker::new(Arc::new(db), cmds_tx); - let status_checker_handle = status_checker.run(cancel_token.clone()); - cancel_token.cancel(); - - // Check no commands were enqueued - assert!(status_checker_handle.await.is_ok()); - assert!(cmds_rx.is_empty()); -} - -#[tokio::test] -async fn votes_auto_closer_closes_vote_only_when_passing_over_all_allowed_voters() { - // Setup cases (exclude abstentions, allowed voters, expected ends at updates). - // In all cases USER1 votes in favor and the vote passes over the votes cast. - let cases = [ - (None, vec![USER1, USER2, USER3], 0), - (None, vec![USER1, USER2], 1), - (Some(true), vec![USER1, USER2], 1), - (None, vec![], 0), - ]; - - for (exclude_abstentions, allowed_voters, expected_updates) in cases { - // Setup vote using the votes cast as the pass threshold base - let mut vote = setup_test_vote(); - vote.cfg.pass_threshold_base = Some(PassThresholdBase::VotesCast { exclude_abstentions }); - - // Setup database expectations - let mut db = MockDB::new(); - db.expect_get_open_votes_with_close_on_passing() - .times(1) - .return_once(move || Box::pin(future::ready(Ok(vec![vote])))); - db.expect_update_vote_ends_at() - .with(eq(Uuid::parse_str(VOTE_ID).unwrap())) - .times(expected_updates) - .returning(|_| Box::pin(future::ready(Ok(())))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_get_comment_reactions() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(vec![in_favor_reaction(USER1)])))); - let allowed_voters: Vec = allowed_voters.into_iter().map(ToString::to_string).collect(); - gh.expect_get_allowed_voters() - .withf(|inst_id, _, owner, repo, _| *inst_id == INST_ID && owner == ORG && repo == REPO) - .times(1) - .return_once(move |_, _, _, _, _| Box::pin(future::ready(Ok(allowed_voters)))); - - // Run the votes auto closer until it's asked to stop - let cancel_token = CancellationToken::new(); - let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); - let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); - cancel_token.cancel(); - - // Check the worker completed (expectations are verified on drop) - assert!( - votes_auto_closer_handle.await.is_ok(), - "exclude abstentions: {exclude_abstentions:?}, expected updates: {expected_updates}" - ); - } -} - -#[tokio::test] -async fn votes_auto_closer_continues_after_error_calculating_results() { - // Setup votes - let first_vote = setup_test_vote(); - let mut second_vote = setup_test_vote(); - second_vote.vote_id = Uuid::parse_str(VOTE_ID2).unwrap(); - second_vote.vote_comment_id = COMMENT_ID2; - - // Setup database expectations - let mut db = MockDB::new(); - db.expect_get_open_votes_with_close_on_passing().times(1).returning({ - let votes = vec![first_vote, second_vote]; - move || Box::pin(future::ready(Ok(votes.clone()))) - }); - db.expect_update_vote_ends_at() - .with(eq(Uuid::parse_str(VOTE_ID2).unwrap())) - .times(1) - .returning(|_| Box::pin(future::ready(Ok(())))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_get_comment_reactions() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - expect_vote_results(&mut gh, COMMENT_ID2, Ok(vec![in_favor_reaction(USER1)])); - - // Run the votes auto closer until it's asked to stop - let cancel_token = CancellationToken::new(); - let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); - let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); - cancel_token.cancel(); - - // Check the worker completed (expectations are verified on drop) - assert!(votes_auto_closer_handle.await.is_ok()); -} - -#[tokio::test] -async fn votes_auto_closer_continues_after_error_updating_ends_at() { - // Setup votes - let first_vote = setup_test_vote(); - let mut second_vote = setup_test_vote(); - second_vote.vote_id = Uuid::parse_str(VOTE_ID2).unwrap(); - second_vote.vote_comment_id = COMMENT_ID2; - - // Setup database expectations - let mut db = MockDB::new(); - db.expect_get_open_votes_with_close_on_passing().times(1).returning({ - let votes = vec![first_vote, second_vote]; - move || Box::pin(future::ready(Ok(votes.clone()))) - }); - db.expect_update_vote_ends_at() - .with(eq(Uuid::parse_str(VOTE_ID).unwrap())) - .times(1) - .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); - db.expect_update_vote_ends_at() - .with(eq(Uuid::parse_str(VOTE_ID2).unwrap())) - .times(1) - .returning(|_| Box::pin(future::ready(Ok(())))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_get_comment_reactions() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(vec![in_favor_reaction(USER1)])))); - gh.expect_get_comment_reactions() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID2)) - .times(1) - .returning(|_, _, _, _| Box::pin(future::ready(Ok(vec![in_favor_reaction(USER1)])))); - gh.expect_get_allowed_voters() - .withf(|inst_id, _, owner, repo, _| *inst_id == INST_ID && owner == ORG && repo == REPO) - .times(2) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(vec![USER1.to_string()])))); - - // Run the votes auto closer until it's asked to stop - let cancel_token = CancellationToken::new(); - let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); - let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); - cancel_token.cancel(); - - // Check the worker completed (expectations are verified on drop) - assert!(votes_auto_closer_handle.await.is_ok()); -} - -#[tokio::test] -async fn votes_auto_closer_does_not_close_vote_not_passed() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_get_open_votes_with_close_on_passing() - .times(1) - .returning(|| Box::pin(future::ready(Ok(vec![setup_test_vote()])))); - db.expect_update_vote_ends_at().never(); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![])); - - // Run the votes auto closer until it's asked to stop - let cancel_token = CancellationToken::new(); - let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); - let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); - cancel_token.cancel(); - - // Check the worker completed (expectations are verified on drop) - assert!(votes_auto_closer_handle.await.is_ok()); -} - -#[tokio::test] -async fn votes_auto_closer_stops_after_processing_pending_vote() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_get_open_votes_with_close_on_passing() - .times(1) - .returning(move || Box::pin(future::ready(Ok(vec![setup_test_vote()])))); - db.expect_update_vote_ends_at() - .times(1) - .returning(move |_| Box::pin(future::ready(Ok(())))); - - // Setup GitHub expectations - let mut gh = MockGH::new(); - gh.expect_get_comment_reactions() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) - .times(1) - .returning(|_, _, _, _| { - Box::pin(future::ready(Ok(vec![Reaction { - user: User { - login: USER1.to_string(), - }, - content: REACTION_IN_FAVOR.to_string(), - created_at: TIMESTAMP.to_string(), - }]))) - }); - gh.expect_get_allowed_voters() - .withf(|inst_id, cfg, owner, repo, org| { - *inst_id == INST_ID - && *cfg == setup_test_vote().cfg - && owner == ORG - && repo == REPO - && *org == Some(ORG.to_string()).as_ref() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(vec![USER1.to_string()])))); - - // Run the votes auto closer until it's asked to stop - let cancel_token = CancellationToken::new(); - let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); - let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); - cancel_token.cancel(); - - // Check the worker completed - assert!(votes_auto_closer_handle.await.is_ok()); -} - -#[tokio::test] -async fn votes_auto_closer_stops_when_requested_error_getting_pending() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_get_open_votes_with_close_on_passing() - .times(1) - .returning(|| Box::pin(future::ready(Err(format_err!(ERROR))))); - let gh = MockGH::new(); - - // Run the votes auto closer until it's asked to stop - let cancel_token = CancellationToken::new(); - let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); - let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); - cancel_token.cancel(); - - // Check the worker completed - assert!(votes_auto_closer_handle.await.is_ok()); -} - -#[tokio::test] -async fn votes_auto_closer_stops_when_requested_none_pending() { - // Setup database expectations - let mut db = MockDB::new(); - db.expect_get_open_votes_with_close_on_passing() - .times(1) - .returning(|| Box::pin(future::ready(Ok(vec![])))); - let gh = MockGH::new(); - - // Run the votes auto closer until it's asked to stop - let cancel_token = CancellationToken::new(); - let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); - let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); - cancel_token.cancel(); - - // Check the worker completed - assert!(votes_auto_closer_handle.await.is_ok()); -} - -#[test] -fn build_announcement_title_includes_issue_number() { - assert_eq!( - build_announcement_title(ISSUE_NUM, TITLE), - "Test title #1 (vote closed)" - ); -} - -// Helpers. - -/// Expect the user to be checked as a repository collaborator once. -fn expect_user_is_collaborator(gh: &mut MockGH, is_collaborator: bool) { - gh.expect_user_is_collaborator() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) - .times(1) - .returning(move |_, _, _, _| Box::pin(future::ready(Ok(is_collaborator)))); -} - -/// Expect the valid test configuration file to be requested once. -fn expect_valid_config(gh: &mut MockGH) { - gh.expect_get_config_file() - .with(eq(INST_ID), eq(ORG), eq(REPO)) - .times(1) - .returning(|_, _, _| Box::pin(future::ready(Some(get_test_valid_config())))); -} - -/// Expect the GitHub calls needed to calculate the results of a vote whose -/// allowed voters only include `USER1`. -fn expect_vote_results(gh: &mut MockGH, comment_id: i64, reactions: Result>) { - gh.expect_get_comment_reactions() - .with(eq(INST_ID), eq(ORG), eq(REPO), eq(comment_id)) - .times(1) - .return_once(move |_, _, _, _| Box::pin(future::ready(reactions))); - gh.expect_get_allowed_voters() - .withf(|inst_id, cfg, owner, repo, org| { - *inst_id == INST_ID - && *cfg == setup_test_vote().cfg - && owner == ORG - && repo == REPO - && *org == Some(ORG.to_string()).as_ref() - }) - .times(1) - .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(vec![USER1.to_string()])))); -} - -/// Create an in favor reaction from the user provided. -fn in_favor_reaction(user: &str) -> Reaction { - Reaction { - user: User { - login: user.to_string(), - }, - content: REACTION_IN_FAVOR.to_string(), - created_at: TIMESTAMP.to_string(), - } -} - -/// Setup a commands handler using the mocks provided. -fn setup_cmds_handler(db: MockDB, gh: MockGH) -> CommandsHandler { - let (_, cmds_rx) = async_channel::unbounded(); - CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx) -} +mod cancel_vote; +mod check_vote; +mod close_finished_vote; +mod close_vote; +mod commands_handler; +mod create_vote; +mod db_contracts; +mod helpers; +mod module_functions; +mod process_command; +mod status_checker; +mod votes_auto_closer; +mod votes_closer; +mod votes_processor; + +/// Maximum time to wait for a running worker to reach the point being tested. +const WORKER_TIMEOUT: Duration = Duration::from_secs(5); diff --git a/src/processor/tests/cancel_vote.rs b/src/processor/tests/cancel_vote.rs new file mode 100644 index 0000000..0b4125d --- /dev/null +++ b/src/processor/tests/cancel_vote.rs @@ -0,0 +1,421 @@ +use anyhow::format_err; +use mockall::{Sequence, predicate::eq}; +use tokio::time::Instant; + +use crate::{db::MockDB, github::*, testutil::*}; + +use super::*; + +#[tokio::test] +async fn cancel_vote_cancellation_abandoned_is_not_an_error() { + // Setup database expectations: the failure was the last attempt, so the + // cancellation was abandoned when recording it + let mut db = MockDB::new(); + expect_cancel_vote(&mut db, Some(setup_test_cancelled_vote(false))); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Labels]); + db.expect_complete_vote_cancellation().never(); + expect_close_failure(&mut db, VoteCloseFailureOutcome::CancellationAbandoned); + + // Setup GitHub expectations: posting the comment fails + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + expect_vote_cancelled_comment_fails(&mut gh, test_vote_id(), false); + expect_remove_open_label(&mut gh, || Ok(())); + + // Run and check the command succeeds, so no failure comment would be posted + let input = CancelVoteInput::new(&Event::IssueComment(setup_test_issue_comment_event())); + setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn cancel_vote_claim_taken_over_is_not_an_error() { + // Setup database expectations: the claim was taken over when recording the failure + let mut db = MockDB::new(); + expect_cancel_vote(&mut db, Some(setup_test_cancelled_vote(false))); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Labels]); + db.expect_complete_vote_cancellation().never(); + expect_close_failure(&mut db, VoteCloseFailureOutcome::ClaimLost); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + expect_vote_cancelled_comment_fails(&mut gh, test_vote_id(), false); + expect_remove_open_label(&mut gh, || Ok(())); + + // Run and check the command succeeds, as the owner of the claim publishes it + let input = CancelVoteInput::new(&Event::IssueComment(setup_test_issue_comment_event())); + setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn cancel_vote_error_cancelling() { + // Setup database expectations: nothing is committed, so nothing else is done + let mut db = MockDB::new(); + db.expect_cancel_vote() + .withf(|repository_full_name, issue_number, cancelled_by| { + repository_full_name == REPOFN && *issue_number == ISSUE_NUM && cancelled_by == USER + }) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + db.expect_record_vote_close_step().never(); + db.expect_record_vote_close_failure().never(); + db.expect_complete_vote_cancellation().never(); + + // Setup GitHub expectations: the cancellation is not published + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + gh.expect_post_comment().never(); + gh.expect_create_check_run().never(); + gh.expect_remove_label().never(); + + // Run and check the error is propagated + let input = CancelVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + let err = setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), "error cancelling vote"); + assert_eq!(err.root_cause().to_string(), ERROR); +} + +#[tokio::test] +async fn cancel_vote_error_checking_if_user_is_collaborator() { + // Setup database expectations: the vote is not cancelled + let mut db = MockDB::new(); + db.expect_cancel_vote().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run and check the error is propagated + let input = CancelVoteInput::new(&Event::IssueComment(setup_test_issue_comment_event())); + let err = setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), "error checking if user is collaborator"); + assert_eq!(err.root_cause().to_string(), ERROR); +} + +#[tokio::test] +async fn cancel_vote_error_deleting_vote_records_failure() { + // Setup database expectations: the vote cannot be deleted once published + let mut db = MockDB::new(); + expect_cancel_vote(&mut db, Some(setup_test_cancelled_vote(false))); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Comment, VoteCloseStep::Labels]); + db.expect_complete_vote_cancellation() + .with(eq(test_vote_id())) + .times(1) + .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + expect_comment(&mut gh, vote_cancelled_body(test_vote_id(), false)); + expect_remove_open_label(&mut gh, || Ok(())); + + // Run and check the command succeeds, as the cancellation is retried later + let input = CancelVoteInput::new(&Event::IssueComment(setup_test_issue_comment_event())); + setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn cancel_vote_error_posting_comment_records_failure() { + // Setup database expectations + let mut db = MockDB::new(); + expect_cancel_vote(&mut db, Some(setup_test_cancelled_vote(false))); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Labels]); + db.expect_complete_vote_cancellation().never(); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + + // Setup GitHub expectations: the other steps are tried even if the comment fails, + // and the first attempt does not look up a comment posted before + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + expect_no_marker_lookups(&mut gh); + expect_vote_cancelled_comment_fails(&mut gh, test_vote_id(), false); + expect_remove_open_label(&mut gh, || Ok(())); + + // Run and check the command succeeds, as the cancellation is retried later + let input = CancelVoteInput::new(&Event::IssueComment(setup_test_issue_comment_event())); + setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn cancel_vote_error_recording_failure_is_ignored() { + // Setup database expectations: the failure cannot be recorded either + let mut db = MockDB::new(); + expect_cancel_vote(&mut db, Some(setup_test_cancelled_vote(false))); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Labels]); + db.expect_complete_vote_cancellation().never(); + db.expect_record_vote_close_failure() + .with(eq(test_vote_id()), eq(test_claimed_until())) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + expect_vote_cancelled_comment_fails(&mut gh, test_vote_id(), false); + expect_remove_open_label(&mut gh, || Ok(())); + + // Run and check the command succeeds, as the cancellation is retried once the + // claim expires + let input = CancelVoteInput::new(&Event::IssueComment(setup_test_issue_comment_event())); + setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn cancel_vote_in_pr_error_creating_check_run_records_failure() { + // Setup database expectations + let mut db = MockDB::new(); + expect_cancel_vote(&mut db, Some(setup_test_cancelled_vote(true))); + expect_state_steps_pending(&mut db, &[VoteCloseStep::CheckRun, VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Comment, VoteCloseStep::Labels]); + db.expect_complete_vote_cancellation().never(); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + expect_comment(&mut gh, vote_cancelled_body(test_vote_id(), true)); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(cancelled_check_details()), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_remove_open_label(&mut gh, || Ok(())); + + // Run and check the command succeeds, as the cancellation is retried later + let input = CancelVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn cancel_vote_in_pr_newer_vote_skips_check_run_and_labels() { + // Setup database expectations + let mut db = MockDB::new(); + expect_cancel_vote(&mut db, Some(setup_test_cancelled_vote(true))); + expect_state_steps_pending(&mut db, &[VoteCloseStep::CheckRun, VoteCloseStep::Labels], false); + expect_close_steps( + &mut db, + &[ + VoteCloseStep::CheckRun, + VoteCloseStep::Comment, + VoteCloseStep::Labels, + ], + ); + expect_complete_vote_cancellation(&mut db); + + // Setup GitHub expectations: only the comment is posted + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + expect_comment(&mut gh, vote_cancelled_body(test_vote_id(), true)); + gh.expect_create_check_run().never(); + gh.expect_remove_label().never(); + + // Run and check the state of the newer vote was left alone + let input = CancelVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn cancel_vote_in_pr_no_vote_in_progress() { + // Setup database expectations + let mut db = MockDB::new(); + expect_cancel_vote(&mut db, None); + db.expect_complete_vote_cancellation().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + expect_comment(&mut gh, tmpl::NoVoteInProgress::new(USER, true).render().unwrap()); + gh.expect_create_check_run().never(); + gh.expect_remove_label().never(); + + // Run and check the vote is not cancelled + let input = CancelVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn cancel_vote_in_pr_success() { + // Setup database expectations: the vote is cancelled before anything is published + let mut seq = Sequence::new(); + let mut db = MockDB::new(); + db.expect_cancel_vote() + .withf(|repository_full_name, issue_number, cancelled_by| { + repository_full_name == REPOFN && *issue_number == ISSUE_NUM && cancelled_by == USER + }) + .times(1) + .in_sequence(&mut seq) + .return_once(|_, _, _| Box::pin(future::ready(Ok(Some(setup_test_cancelled_vote(true)))))); + expect_state_steps_pending(&mut db, &[VoteCloseStep::CheckRun, VoteCloseStep::Labels], true); + expect_close_steps( + &mut db, + &[ + VoteCloseStep::CheckRun, + VoteCloseStep::Comment, + VoteCloseStep::Labels, + ], + ); + expect_complete_vote_cancellation(&mut db); + + // Setup GitHub expectations: the comment is posted first, after the cancellation + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + let expected_body = vote_cancelled_body(test_vote_id(), true); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body + }) + .times(1) + .in_sequence(&mut seq) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(cancelled_check_details()), + ) + .times(1) + .in_sequence(&mut seq) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + expect_remove_open_label(&mut gh, || Ok(())); + + // Run and check the vote is cancelled successfully + let input = CancelVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn cancel_vote_no_vote_in_progress() { + // Setup database expectations + let mut db = MockDB::new(); + expect_cancel_vote(&mut db, None); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + expect_comment( + &mut gh, + tmpl::NoVoteInProgress::new(USER, false).render().unwrap(), + ); + gh.expect_remove_label().never(); + + // Run and check the vote is not cancelled + let input = CancelVoteInput::new(&Event::IssueComment(setup_test_issue_comment_event())); + setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn cancel_vote_only_collaborators_can_close_votes() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_cancel_vote().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, false); + + // Run and check the vote is not cancelled + let input = CancelVoteInput::new(&Event::IssueComment(setup_test_issue_comment_event())); + setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap(); +} + +#[tokio::test(start_paused = true)] +async fn cancel_vote_publication_timeout_records_failure() { + // Setup database expectations: the vote is cancelled, and nothing is recorded as + // done once its publication times out + let mut db = MockDB::new(); + expect_cancel_vote(&mut db, Some(setup_test_cancelled_vote(false))); + db.expect_record_vote_close_step().never(); + db.expect_complete_vote_cancellation().never(); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + + // Setup GitHub expectations: posting the comment never completes, and the first + // attempt does not look up a comment posted before + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + expect_no_marker_lookups(&mut gh); + let expected_body = vote_cancelled_body(test_vote_id(), false); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::pending())); + gh.expect_remove_label().never(); + + // Run the command + let started_at = Instant::now(); + let input = CancelVoteInput::new(&Event::IssueComment(setup_test_issue_comment_event())); + setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap(); + + // Check the publication was stopped once the attempt timed out + assert_eq!(started_at.elapsed(), CLOSE_ATTEMPT_TIMEOUT); +} + +#[tokio::test] +async fn cancel_vote_removing_label_error_is_ignored() { + // Setup database expectations + let mut db = MockDB::new(); + expect_cancel_vote(&mut db, Some(setup_test_cancelled_vote(false))); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Comment, VoteCloseStep::Labels]); + expect_complete_vote_cancellation(&mut db); + db.expect_record_vote_close_failure().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + expect_comment(&mut gh, vote_cancelled_body(test_vote_id(), false)); + expect_remove_open_label(&mut gh, || Err(format_err!(ERROR))); + + // Run and check the vote is cancelled successfully + let input = CancelVoteInput::new(&Event::IssueComment(setup_test_issue_comment_event())); + setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn cancel_vote_success() { + // Setup database expectations + let mut db = MockDB::new(); + expect_cancel_vote(&mut db, Some(setup_test_cancelled_vote(false))); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Comment, VoteCloseStep::Labels]); + expect_complete_vote_cancellation(&mut db); + db.expect_record_vote_close_failure().never(); + + // Setup GitHub expectations: votes on issues have no check, and the first attempt + // does not look up a comment posted before + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + expect_no_marker_lookups(&mut gh); + expect_comment(&mut gh, vote_cancelled_body(test_vote_id(), false)); + gh.expect_create_check_run().never(); + expect_remove_open_label(&mut gh, || Ok(())); + + // Run and check the vote is cancelled successfully + let input = CancelVoteInput::new(&Event::IssueComment(setup_test_issue_comment_event())); + setup_cmds_handler(db, gh).cancel_vote(&input).await.unwrap(); +} diff --git a/src/processor/tests/check_vote.rs b/src/processor/tests/check_vote.rs new file mode 100644 index 0000000..098788b --- /dev/null +++ b/src/processor/tests/check_vote.rs @@ -0,0 +1,226 @@ +use std::sync::Arc; + +use anyhow::format_err; +use mockall::predicate::eq; +use time::ext::NumericalDuration; + +use crate::{ + db::MockDB, + github::*, + results::{REACTION_IN_FAVOR, Vote}, + testutil::*, +}; + +use super::*; + +#[tokio::test] +async fn check_vote_checked_long_ago() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_vote().with(eq(REPOFN), eq(ISSUE_NUM)).times(1).returning(|_, _| { + Box::pin(future::ready(Ok(Some(Vote { + checked_at: OffsetDateTime::now_utc().checked_sub(25.hours()), + ..setup_test_vote() + })))) + }); + db.expect_update_vote_last_check() + .with(eq(test_vote_id())) + .times(1) + .returning(|_| Box::pin(future::ready(Ok(())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let results = setup_test_vote_results(); + let expected_body = tmpl::VoteStatus::new(&results).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run and check the vote status is posted + let input = CheckVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + setup_cmds_handler(db, gh).check_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn check_vote_checked_recently() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_vote().with(eq(REPOFN), eq(ISSUE_NUM)).times(1).returning(|_, _| { + Box::pin(future::ready(Ok(Some(Vote { + checked_at: OffsetDateTime::now_utc().checked_sub(1.hours()), + ..setup_test_vote() + })))) + }); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteCheckedRecently {}.render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run and check the recently checked comment is posted + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_pr_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler.check_vote(&CheckVoteInput::new(&Event::PullRequest(event))).await.unwrap(); +} + +#[tokio::test] +async fn check_vote_error_calculating_results() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(Some(setup_test_vote()))))); + db.expect_update_vote_last_check().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_post_comment().never(); + + // Run and check the error is propagated + let input = CheckVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + let err = setup_cmds_handler(db, gh).check_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn check_vote_error_getting_vote() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + db.expect_update_vote_last_check().never(); + + // Setup GitHub expectations: no status is posted + let mut gh = MockGH::new(); + gh.expect_post_comment().never(); + + // Run and check the error is propagated + let input = CheckVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + let err = setup_cmds_handler(db, gh).check_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), "error getting open vote"); + assert_eq!(err.root_cause().to_string(), ERROR); +} + +#[tokio::test] +async fn check_vote_error_updating_last_check_is_ignored() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(Some(setup_test_vote()))))); + db.expect_update_vote_last_check() + .with(eq(test_vote_id())) + .times(1) + .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let results = setup_test_vote_results(); + let expected_body = tmpl::VoteStatus::new(&results).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run and check the status posted makes the check succeed + let input = CheckVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + setup_cmds_handler(db, gh).check_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn check_vote_not_found() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(None)))); + let gh = MockGH::new(); + + // Run and check the missing vote is ignored + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_pr_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler.check_vote(&CheckVoteInput::new(&Event::PullRequest(event))).await.unwrap(); +} + +#[tokio::test] +async fn check_vote_success() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(Some(setup_test_vote()))))); + db.expect_update_vote_last_check() + .with(eq(test_vote_id())) + .times(1) + .returning(|_| Box::pin(future::ready(Ok(())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| { + Box::pin(future::ready(Ok(vec![Reaction { + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + user: User { + login: USER1.to_string(), + }, + }]))) + }); + expect_allowed_voters(&mut gh); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let results = setup_test_vote_results(); + let expected_body = tmpl::VoteStatus::new(&results).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run and check the vote status is posted + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_pr_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler.check_vote(&CheckVoteInput::new(&Event::PullRequest(event))).await.unwrap(); +} diff --git a/src/processor/tests/close_finished_vote.rs b/src/processor/tests/close_finished_vote.rs new file mode 100644 index 0000000..9b37559 --- /dev/null +++ b/src/processor/tests/close_finished_vote.rs @@ -0,0 +1,2076 @@ +use std::{ops::RangeInclusive, sync::Arc}; + +use anyhow::format_err; +use mockall::{Sequence, predicate::eq}; +use time::{ext::NumericalDuration, format_description::well_known::Rfc3339}; +use tokio::time::Instant; +use uuid::Uuid; + +use crate::{db::MockDB, github::*, results::REACTION_AGAINST, testutil::*}; + +use super::*; + +#[tokio::test] +async fn close_finished_vote_allowed_voters_not_found_does_not_close_vote() { + // Setup database expectations + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_vote()); + db.expect_close_vote().never(); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + + // Setup GitHub expectations + let not_found_error = setup_test_not_found_error().await; + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(vec![])))); + gh.expect_get_allowed_voters() + .withf(|inst_id, cfg, owner, repo, org| { + *inst_id == INST_ID + && *cfg == setup_test_vote().cfg + && owner == ORG + && repo == REPO + && *org == Some(ORG.to_string()).as_ref() + }) + .times(1) + .return_once(move |_, _, _, _, _| Box::pin(future::ready(Err(not_found_error)))); + gh.expect_post_comment().never(); + + // Run and check the failure is recorded, as only the comment can be missing + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_announcement_rejected_is_not_retried() { + // Setup database expectations + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_closed_vote(Some(setup_test_vote_results()))); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps( + &mut db, + &[ + VoteCloseStep::Announcement, + VoteCloseStep::Comment, + VoteCloseStep::Labels, + ], + ); + expect_complete_vote_close(&mut db); + db.expect_record_vote_close_failure().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_announcement_lookup(&mut gh, test_vote_id(), test_lookup_since(), || Ok(false)); + expect_comment(&mut gh, vote_closed_body(test_vote_id())); + expect_announcement_discussion(&mut gh, &mut Sequence::new(), test_vote_id(), || { + Err(format_err!(DiscussionRejectedError(format_err!(ERROR)))) + }); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels( + &mut gh, + &mut Sequence::new(), + &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], + || Ok(()), + ); + + // Run and check the vote is closed successfully + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test(start_paused = true)] +async fn close_finished_vote_attempt_timeout_records_failure() { + // Setup database expectations: the vote is not closed, and the attempt is recorded + // as failed once it times out + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_vote()); + db.expect_close_vote().never(); + db.expect_complete_vote_close().never(); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + + // Setup GitHub expectations: getting the reactions never completes + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::pending())); + gh.expect_post_comment().never(); + + // Run the votes closer + let started_at = Instant::now(); + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + + // Check the attempt was stopped once it timed out + assert_eq!(started_at.elapsed(), CLOSE_ATTEMPT_TIMEOUT); +} + +#[tokio::test] +async fn close_finished_vote_calculates_and_stores_results() { + // Setup database expectations + let mut seq = Sequence::new(); + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_vote()); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + db.expect_close_vote() + .withf(|vote_id, results, head_sha| { + *vote_id == test_vote_id() && results == &Some(&setup_test_vote_results()) && head_sha.is_none() + }) + .times(1) + .in_sequence(&mut seq) + .returning(|_, _, _| Box::pin(future::ready(Ok(true)))); + expect_close_steps( + &mut db, + &[ + VoteCloseStep::Comment, + VoteCloseStep::Announcement, + VoteCloseStep::Labels, + ], + ); + db.expect_complete_vote_close() + .with(eq(test_vote_id())) + .times(1) + .in_sequence(&mut seq) + .returning(|_| Box::pin(future::ready(Ok(())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_no_marker_lookups(&mut gh); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + gh.expect_get_pr_head_sha().never(); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = vote_closed_body(test_vote_id()); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + expect_announcement_discussion(&mut gh, &mut Sequence::new(), test_vote_id(), || Ok(())); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_cancellation_abandoned_is_not_an_error() { + // Setup database expectations: the failure was the last attempt, so the + // cancellation was abandoned when recording it + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_cancelled_vote(false)); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Labels]); + db.expect_complete_vote_cancellation().never(); + expect_close_failure(&mut db, VoteCloseFailureOutcome::CancellationAbandoned); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::CancellationComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_vote_cancelled_comment_fails(&mut gh, test_vote_id(), false); + expect_remove_open_label(&mut gh, || Ok(())); + + // Run and check the votes closer can continue with the next vote + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_cancelled_vote_error_recording_skipped_step_records_failure() { + // Setup database expectations: the comment was posted by a previous attempt, and + // the labels step was superseded by a newer vote, failing to record it as done + let mut db = MockDB::new(); + let mut vote = setup_test_cancelled_vote(false); + vote.close_comment_posted_at = Some(test_closed_at()); + expect_claim_vote(&mut db, vote); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], false); + db.expect_record_vote_close_step() + .with(eq(test_vote_id()), eq(VoteCloseStep::Labels)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + db.expect_complete_vote_cancellation().never(); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + + // Setup GitHub expectations: nothing is published + let mut gh = MockGH::new(); + expect_no_marker_lookups(&mut gh); + gh.expect_post_comment().never(); + gh.expect_remove_label().never(); + + // Run and check the failure is recorded, leaving the step for the retry + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_cancelled_vote_error_records_failure() { + // Setup database expectations + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_cancelled_vote(false)); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Labels]); + db.expect_complete_vote_cancellation().never(); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::CancellationComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_vote_cancelled_comment_fails(&mut gh, test_vote_id(), false); + expect_remove_open_label(&mut gh, || Ok(())); + + // Run and check the failure is recorded + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_cancelled_vote_publishes_missing_cancellation_steps() { + // Setup database expectations: the comment was posted by a previous attempt + let mut db = MockDB::new(); + let mut vote = setup_test_cancelled_vote(true); + vote.close_attempts = 1; + vote.close_comment_posted_at = Some(test_closed_at()); + expect_claim_vote(&mut db, vote); + expect_state_steps_pending(&mut db, &[VoteCloseStep::CheckRun, VoteCloseStep::Labels], true); + db.expect_close_vote().never(); + expect_close_steps(&mut db, &[VoteCloseStep::CheckRun, VoteCloseStep::Labels]); + expect_complete_vote_cancellation(&mut db); + db.expect_complete_vote_close().never(); + + // Setup GitHub expectations: no results are calculated nor published + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions().never(); + gh.expect_post_comment().never(); + gh.expect_create_discussion().never(); + gh.expect_get_pr_head_sha().never(); + gh.expect_create_commit_check_run().never(); + expect_check_run(&mut gh, cancelled_check_details()); + expect_remove_open_label(&mut gh, || Ok(())); + gh.expect_add_labels().never(); + + // Run and check the cancellation is published + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_cancelled_vote_retry_lookup_error_records_failure() { + // Setup database expectations: the comment is not recorded, as it is unknown whether + // the previous attempt posted it + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_cancelled_vote(false)); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Labels]); + db.expect_complete_vote_cancellation().never(); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + + // Setup GitHub expectations: the comment is not posted blindly + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::CancellationComment, + test_vote_id(), + test_lookup_since(), + || Err(format_err!(ERROR)), + ); + gh.expect_post_comment().never(); + expect_remove_open_label(&mut gh, || Ok(())); + + // Run and check the failure is recorded + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_cancelled_vote_retry_records_comment_found_published() { + // Setup database expectations: the comment posted by the previous attempt is + // recorded as done + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_cancelled_vote(false)); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Comment, VoteCloseStep::Labels]); + expect_complete_vote_cancellation(&mut db); + db.expect_record_vote_close_failure().never(); + + // Setup GitHub expectations: the comment is found, so it is not posted again + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::CancellationComment, + test_vote_id(), + test_lookup_since(), + || Ok(true), + ); + gh.expect_post_comment().never(); + expect_remove_open_label(&mut gh, || Ok(())); + + // Run and check the cancellation is published + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_claim_taken_over_is_not_an_error() { + // Setup database expectations: the claim was taken over when recording the failure + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_closed_vote(None)); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_failure(&mut db, VoteCloseFailureOutcome::ClaimLost); + db.expect_complete_vote_close().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_remove_open_label(&mut gh, || Ok(())); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run and check the votes closer can continue with the next vote + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_claimed_vote_without_claim_returns_error() { + // Setup database expectations: the vote claimed has no claim, so the failure cannot + // be recorded + let mut db = MockDB::new(); + db.expect_claim_vote_to_close() + .times(1) + .return_once(|| Box::pin(future::ready(Ok(Some(setup_test_closed_vote(None)))))); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + db.expect_record_vote_close_step().never(); + db.expect_complete_vote_close().never(); + db.expect_record_vote_close_failure().never(); + + // Setup GitHub expectations: updating the labels fails + let mut gh = MockGH::new(); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels(&mut gh, &mut Sequence::new(), &[VOTE_CLOSED_LABEL], || { + Err(format_err!(ERROR)) + }); + + // Run and check the error is returned, so that the votes closer pauses + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + let err = votes_closer.close_finished_vote().await.unwrap_err(); + assert_eq!(err.to_string(), "claimed vote without claim"); +} + +#[tokio::test] +async fn close_finished_vote_comment_deleted_closes_vote_without_results() { + // Setup database expectations + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_vote()); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + db.expect_close_vote() + .withf(|vote_id, results, head_sha| { + *vote_id == test_vote_id() && results.is_none() && head_sha.is_none() + }) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Ok(true)))); + expect_close_steps(&mut db, &[VoteCloseStep::Labels]); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(CommentNotFoundError.into())))); + gh.expect_get_allowed_voters().never(); + gh.expect_post_comment().never(); + gh.expect_create_discussion().never(); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_error_adding_labels() { + // Setup database expectations + let mut db = MockDB::new(); + let mut vote = setup_test_closed_vote(Some(setup_test_vote_results())); + vote.cfg.announcements = None; + expect_claim_vote(&mut db, vote); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Comment]); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + db.expect_complete_vote_close().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_comment(&mut gh, vote_closed_body(test_vote_id())); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels( + &mut gh, + &mut Sequence::new(), + &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], + || Err(format_err!(ERROR)), + ); + + // Run and check the failure is recorded and does not stop the votes closer + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_error_calculating_results_does_not_close_vote() { + // Setup database expectations + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_vote()); + db.expect_close_vote().never(); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + db.expect_complete_vote_close().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_post_comment().never(); + + // Run and check the failure is recorded and does not stop the votes closer + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_error_checking_state_step_runs_next_step() { + // Setup database expectations: only the check of the first step fails + let mut db = MockDB::new(); + let mut seq = Sequence::new(); + let mut vote = setup_test_closed_vote(Some(setup_test_vote_results())); + vote.cfg.announcements = None; + vote.is_pull_request = true; + expect_claim_vote(&mut db, vote); + db.expect_is_close_state_step_pending() + .with(eq(test_vote_id()), eq(VoteCloseStep::CheckRun)) + .times(1) + .in_sequence(&mut seq) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + db.expect_is_close_state_step_pending() + .with(eq(test_vote_id()), eq(VoteCloseStep::Labels)) + .times(1) + .in_sequence(&mut seq) + .returning(|_, _| Box::pin(future::ready(Ok(true)))); + expect_close_steps(&mut db, &[VoteCloseStep::Comment, VoteCloseStep::Labels]); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + db.expect_complete_vote_close().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_comment(&mut gh, vote_closed_body(test_vote_id())); + gh.expect_create_check_run().never(); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels( + &mut gh, + &mut Sequence::new(), + &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], + || Ok(()), + ); + + // Run and check the failure is recorded, leaving the check run for the retry + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_error_checking_state_steps_publishes_no_state() { + // Setup database expectations + let mut db = MockDB::new(); + let mut vote = setup_test_closed_vote(Some(setup_test_vote_results())); + vote.cfg.announcements = None; + vote.is_pull_request = true; + expect_claim_vote(&mut db, vote); + for step in [VoteCloseStep::CheckRun, VoteCloseStep::Labels] { + db.expect_is_close_state_step_pending() + .with(eq(test_vote_id()), eq(step)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + } + expect_close_steps(&mut db, &[VoteCloseStep::Comment]); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + db.expect_complete_vote_close().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_comment(&mut gh, vote_closed_body(test_vote_id())); + gh.expect_create_check_run().never(); + gh.expect_remove_label().never(); + gh.expect_add_labels().never(); + + // Run and check the failure is recorded, leaving the state for the retry + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_error_claiming_vote() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_claim_vote_to_close() + .times(1) + .returning(|| Box::pin(future::ready(Err(format_err!(ERROR))))); + let gh = MockGH::new(); + + // Run and check the error is propagated + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + let err = votes_closer.close_finished_vote().await.unwrap_err(); + assert_eq!(err.to_string(), "error claiming vote to close"); + assert_eq!(err.root_cause().to_string(), ERROR); +} + +#[tokio::test] +async fn close_finished_vote_error_closing_vote_records_failure() { + // Setup database expectations: closing the vote fails, so nothing is recorded as + // published and the close is not completed + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_vote()); + db.expect_close_vote() + .withf(|vote_id, results, head_sha| { + *vote_id == test_vote_id() && results == &Some(&setup_test_vote_results()) && head_sha.is_none() + }) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + db.expect_is_close_state_step_pending().never(); + db.expect_record_vote_close_step().never(); + db.expect_complete_vote_close().never(); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + + // Setup GitHub expectations: the results are calculated, and nothing is published + let mut gh = MockGH::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + expect_no_marker_lookups(&mut gh); + gh.expect_post_comment().never(); + gh.expect_create_discussion().never(); + gh.expect_create_check_run().never(); + gh.expect_create_commit_check_run().never(); + gh.expect_remove_label().never(); + gh.expect_add_labels().never(); + + // Run and check the failure is recorded and does not stop the votes closer + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_error_completing_close() { + // Setup database expectations + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_closed_vote(None)); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Labels]); + db.expect_complete_vote_close() + .with(eq(test_vote_id())) + .times(1) + .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels(&mut gh, &mut Sequence::new(), &[VOTE_CLOSED_LABEL], || Ok(())); + + // Run and check the failure is recorded so that the close is completed later + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_error_creating_announcement_is_retried() { + // Setup database expectations + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_closed_vote(Some(setup_test_vote_results()))); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Comment, VoteCloseStep::Labels]); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + db.expect_complete_vote_close().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_announcement_lookup(&mut gh, test_vote_id(), test_lookup_since(), || Ok(false)); + expect_comment(&mut gh, vote_closed_body(test_vote_id())); + expect_announcement_discussion(&mut gh, &mut Sequence::new(), test_vote_id(), || { + Err(format_err!(ERROR)) + }); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels( + &mut gh, + &mut Sequence::new(), + &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], + || Ok(()), + ); + + // Run and check the failure is recorded, so that the announcement is retried + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_error_creating_check_run() { + // Setup database expectations + let mut db = MockDB::new(); + let mut vote = setup_test_closed_vote(Some(setup_test_vote_results())); + vote.cfg.announcements = None; + vote.is_pull_request = true; + expect_claim_vote(&mut db, vote); + expect_state_steps_pending(&mut db, &[VoteCloseStep::CheckRun, VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Comment, VoteCloseStep::Labels]); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + db.expect_complete_vote_close().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_comment(&mut gh, vote_closed_body(test_vote_id())); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(passed_check_details()), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels( + &mut gh, + &mut Sequence::new(), + &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], + || Ok(()), + ); + + // Run and check the failure is recorded and the later steps are done anyway + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_error_getting_head_closes_vote_with_its_results() { + // Setup database expectations + let mut db = MockDB::new(); + let mut vote = setup_test_vote(); + vote.is_pull_request = true; + expect_claim_vote(&mut db, vote); + expect_state_steps_pending(&mut db, &[VoteCloseStep::CheckRun, VoteCloseStep::Labels], true); + db.expect_close_vote() + .withf(|vote_id, results, head_sha| { + *vote_id == test_vote_id() && results == &Some(&setup_test_vote_results()) && head_sha.is_none() + }) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Ok(true)))); + expect_close_steps( + &mut db, + &[ + VoteCloseStep::Comment, + VoteCloseStep::Announcement, + VoteCloseStep::CheckRun, + VoteCloseStep::Labels, + ], + ); + expect_complete_vote_close(&mut db); + db.expect_record_vote_close_failure().never(); + + // Setup GitHub expectations: the check run falls back to the current head + let mut gh = MockGH::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + gh.expect_get_pr_head_sha() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_comment(&mut gh, vote_closed_body(test_vote_id())); + expect_announcement_discussion(&mut gh, &mut Sequence::new(), test_vote_id(), || Ok(())); + gh.expect_create_commit_check_run().never(); + expect_check_run(&mut gh, passed_check_details()); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels( + &mut gh, + &mut Sequence::new(), + &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], + || Ok(()), + ); + + // Run and check the vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_error_posting_comment() { + // Setup database expectations + let mut db = MockDB::new(); + let mut vote = setup_test_closed_vote(Some(setup_test_vote_results())); + vote.is_pull_request = true; + expect_claim_vote(&mut db, vote); + expect_state_steps_pending(&mut db, &[VoteCloseStep::CheckRun, VoteCloseStep::Labels], true); + expect_close_steps( + &mut db, + &[ + VoteCloseStep::Announcement, + VoteCloseStep::CheckRun, + VoteCloseStep::Labels, + ], + ); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + db.expect_complete_vote_close().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_announcement_lookup(&mut gh, test_vote_id(), test_lookup_since(), || Ok(false)); + expect_comment_in_sequence( + &mut gh, + &mut Sequence::new(), + vote_closed_body(test_vote_id()), + || Err(format_err!(ERROR)), + ); + expect_announcement_discussion(&mut gh, &mut Sequence::new(), test_vote_id(), || Ok(())); + expect_check_run(&mut gh, passed_check_details()); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels( + &mut gh, + &mut Sequence::new(), + &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], + || Ok(()), + ); + + // Run and check the failure is recorded and the later steps are done anyway + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_error_recording_failure() { + // Setup database expectations + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_closed_vote(None)); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + db.expect_record_vote_close_failure() + .with(eq(test_vote_id()), eq(test_claimed_until())) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels(&mut gh, &mut Sequence::new(), &[VOTE_CLOSED_LABEL], || { + Err(format_err!("labels error")) + }); + + // Run and check the error is propagated, so that the votes closer pauses + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + let err = votes_closer.close_finished_vote().await.unwrap_err(); + assert_eq!(err.to_string(), "error recording vote close failure"); + assert_eq!(err.root_cause().to_string(), ERROR); +} + +#[tokio::test] +async fn close_finished_vote_error_recording_skipped_step_records_failure() { + // Setup database expectations: the labels step was superseded by a newer vote, and + // recording it as done fails + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_closed_vote(None)); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], false); + db.expect_record_vote_close_step() + .with(eq(test_vote_id()), eq(VoteCloseStep::Labels)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + db.expect_complete_vote_close().never(); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + + // Setup GitHub expectations: the labels superseded are not updated + let mut gh = MockGH::new(); + gh.expect_remove_label().never(); + gh.expect_add_labels().never(); + + // Run and check the failure is recorded, leaving the step for the retry + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_error_recording_step_does_not_stop_later_steps() { + // Setup database expectations + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_closed_vote(Some(setup_test_vote_results()))); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + db.expect_record_vote_close_step() + .with(eq(test_vote_id()), eq(VoteCloseStep::Comment)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_close_steps(&mut db, &[VoteCloseStep::Announcement, VoteCloseStep::Labels]); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + db.expect_complete_vote_close().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_announcement_lookup(&mut gh, test_vote_id(), test_lookup_since(), || Ok(false)); + expect_comment(&mut gh, vote_closed_body(test_vote_id())); + expect_announcement_discussion(&mut gh, &mut Sequence::new(), test_vote_id(), || Ok(())); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels( + &mut gh, + &mut Sequence::new(), + &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], + || Ok(()), + ); + + // Run and check the failure is recorded and the later steps are done anyway + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_ignores_reactions_after_the_end() { + // Setup a vote finished some time ago + let vote = setup_test_vote(); + let late_reaction = Reaction { + content: REACTION_AGAINST.to_string(), + created_at: (vote.ends_at + 1.hours()).format(&Rfc3339).unwrap(), + ..in_favor_reaction(USER1) + }; + + // Setup database expectations: the results are the ones of the reaction in time + let mut db = MockDB::new(); + expect_claim_vote(&mut db, vote); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + db.expect_close_vote() + .withf(|vote_id, results, head_sha| { + *vote_id == test_vote_id() && results == &Some(&setup_test_vote_results()) && head_sha.is_none() + }) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Ok(true)))); + expect_close_steps( + &mut db, + &[ + VoteCloseStep::Comment, + VoteCloseStep::Announcement, + VoteCloseStep::Labels, + ], + ); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations: without the late reaction being ignored, the user + // would have voted twice and the vote would not count + let mut gh = MockGH::new(); + let reactions = vec![in_favor_reaction(USER1), late_reaction]; + expect_vote_results(&mut gh, COMMENT_ID, Ok(reactions)); + expect_vote_closed_published(&mut gh, test_vote_id()); + + // Run and check the vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_newer_vote_created_during_check_run_skips_labels() { + // Setup database expectations: the newer vote shows up after the first check + let mut db = MockDB::new(); + let mut seq = Sequence::new(); + let mut vote = setup_test_closed_vote(Some(setup_test_vote_results())); + vote.cfg.announcements = None; + vote.is_pull_request = true; + expect_claim_vote(&mut db, vote); + db.expect_is_close_state_step_pending() + .with(eq(test_vote_id()), eq(VoteCloseStep::CheckRun)) + .times(1) + .in_sequence(&mut seq) + .returning(|_, _| Box::pin(future::ready(Ok(true)))); + db.expect_is_close_state_step_pending() + .with(eq(test_vote_id()), eq(VoteCloseStep::Labels)) + .times(1) + .in_sequence(&mut seq) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + expect_close_steps( + &mut db, + &[ + VoteCloseStep::CheckRun, + VoteCloseStep::Comment, + VoteCloseStep::Labels, + ], + ); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_comment(&mut gh, vote_closed_body(test_vote_id())); + expect_check_run(&mut gh, passed_check_details()); + gh.expect_remove_label().never(); + gh.expect_add_labels().never(); + + // Run and check the labels of the newer vote were left alone + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_newer_vote_on_issue_skips_labels() { + // Setup database expectations + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_closed_vote(None)); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], false); + expect_close_steps(&mut db, &[VoteCloseStep::Labels]); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_create_check_run().never(); + gh.expect_remove_label().never(); + gh.expect_add_labels().never(); + + // Run and check the vote is closed successfully + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_newer_vote_on_pr_skips_check_run_and_labels() { + // Setup database expectations + let mut db = MockDB::new(); + let mut vote = setup_test_closed_vote(Some(setup_test_vote_results())); + vote.is_pull_request = true; + vote.close_head_sha = Some(HEAD_SHA.to_string()); + expect_claim_vote(&mut db, vote); + expect_state_steps_pending(&mut db, &[VoteCloseStep::CheckRun, VoteCloseStep::Labels], false); + expect_close_steps( + &mut db, + &[ + VoteCloseStep::Announcement, + VoteCloseStep::CheckRun, + VoteCloseStep::Comment, + VoteCloseStep::Labels, + ], + ); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_announcement_lookup(&mut gh, test_vote_id(), test_lookup_since(), || Ok(false)); + expect_comment(&mut gh, vote_closed_body(test_vote_id())); + expect_announcement_discussion(&mut gh, &mut Sequence::new(), test_vote_id(), || Ok(())); + gh.expect_get_pr_head_sha().never(); + gh.expect_create_check_run().never(); + gh.expect_create_commit_check_run().never(); + gh.expect_remove_label().never(); + gh.expect_add_labels().never(); + + // Run and check only the results were published + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_no_attempts_left() { + // Setup database expectations + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_closed_vote(None)); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_failure(&mut db, VoteCloseFailureOutcome::NoAttemptsLeft); + db.expect_complete_vote_close().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels(&mut gh, &mut Sequence::new(), &[VOTE_CLOSED_LABEL], || { + Err(format_err!(ERROR)) + }); + + // Run and check the votes closer can continue with the next vote + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_none_closed() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_claim_vote_to_close().times(1).returning(|| Box::pin(future::ready(Ok(None)))); + let gh = MockGH::new(); + + // Run and check no vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), None); +} + +#[tokio::test] +async fn close_finished_vote_on_issue() { + // Setup vote results + let results = setup_test_vote_results(); + let results_copy = results.clone(); + let results_copy2 = results.clone(); + + // Setup database expectations + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_closed_vote(Some(results_copy))); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps( + &mut db, + &[ + VoteCloseStep::Comment, + VoteCloseStep::Announcement, + VoteCloseStep::Labels, + ], + ); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_announcement_lookup(&mut gh, test_vote_id(), test_lookup_since(), || Ok(false)); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteClosed::new(test_vote_id(), &results_copy2).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_create_discussion() + .withf(move |inst_id, owner, repo, category, title, body| { + let expected_body = tmpl::VoteClosedAnnouncement::new(test_vote_id(), ISSUE_NUM, TITLE, &results) + .render() + .unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && category == DISCUSSIONS_CATEGORY + && title == build_announcement_title(ISSUE_NUM, TITLE) + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_on_issue_without_results() { + // Setup database expectations (vote comment was deleted) + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_closed_vote(None)); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Labels]); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_post_comment().never(); + gh.expect_create_discussion().never(); + gh.expect_create_check_run().never(); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_on_pr_closed_vote_not_passed() { + // Setup vote results + let mut results = setup_test_vote_results(); + results.against = 1; + results.in_favor = 0; + results.in_favor_percentage = 0.0; + results.passed = false; + let results_copy = results.clone(); + let results_copy2 = results.clone(); + + // Setup database expectations + let mut db = MockDB::new(); + let mut vote = setup_test_closed_vote(Some(results_copy)); + vote.is_pull_request = true; + expect_claim_vote(&mut db, vote); + expect_state_steps_pending(&mut db, &[VoteCloseStep::CheckRun, VoteCloseStep::Labels], true); + expect_close_steps( + &mut db, + &[ + VoteCloseStep::Comment, + VoteCloseStep::Announcement, + VoteCloseStep::CheckRun, + VoteCloseStep::Labels, + ], + ); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_announcement_lookup(&mut gh, test_vote_id(), test_lookup_since(), || Ok(false)); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteClosed::new(test_vote_id(), &results_copy2).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_create_discussion() + .withf(move |inst_id, owner, repo, category, title, body| { + let expected_body = tmpl::VoteClosedAnnouncement::new(test_vote_id(), ISSUE_NUM, TITLE, &results) + .render() + .unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && category == DISCUSSIONS_CATEGORY + && title == build_announcement_title(ISSUE_NUM, TITLE) + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _, _| Box::pin(future::ready(Ok(())))); + expect_check_run( + &mut gh, + CheckDetails { + status: "completed".to_string(), + summary: "The vote did not pass. 0 out of 1 voted in favor.".to_string(), + + conclusion: Some("failure".to_string()), + }, + ); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL, VOTE_FAILED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the PR vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_on_pr_closed_vote_passed() { + // Setup vote results + let results = setup_test_vote_results(); + let results_copy = results.clone(); + let results_copy2 = results.clone(); + + // Setup database expectations + let mut db = MockDB::new(); + let mut vote = setup_test_closed_vote(Some(results_copy)); + vote.is_pull_request = true; + expect_claim_vote(&mut db, vote); + expect_state_steps_pending(&mut db, &[VoteCloseStep::CheckRun, VoteCloseStep::Labels], true); + expect_close_steps( + &mut db, + &[ + VoteCloseStep::Comment, + VoteCloseStep::Announcement, + VoteCloseStep::CheckRun, + VoteCloseStep::Labels, + ], + ); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_announcement_lookup(&mut gh, test_vote_id(), test_lookup_since(), || Ok(false)); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteClosed::new(test_vote_id(), &results_copy2).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_create_discussion() + .withf(move |inst_id, owner, repo, category, title, body| { + let expected_body = tmpl::VoteClosedAnnouncement::new(test_vote_id(), ISSUE_NUM, TITLE, &results) + .render() + .unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && category == DISCUSSIONS_CATEGORY + && title == build_announcement_title(ISSUE_NUM, TITLE) + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _, _| Box::pin(future::ready(Ok(())))); + expect_check_run( + &mut gh, + CheckDetails { + status: "completed".to_string(), + summary: "The vote passed! 1 out of 1 voted in favor.".to_string(), + + conclusion: Some("success".to_string()), + }, + ); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the PR vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_on_pr_error_calculating_results_does_not_read_head() { + // Setup database expectations + let mut db = MockDB::new(); + let mut vote = setup_test_vote(); + vote.is_pull_request = true; + expect_claim_vote(&mut db, vote); + db.expect_close_vote().never(); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + db.expect_complete_vote_close().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_get_pr_head_sha().never(); + gh.expect_post_comment().never(); + + // Run and check the failure is recorded and does not stop the votes closer + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_on_pr_stored_head_is_used_by_retries() { + // Setup database expectations + let mut db = MockDB::new(); + let mut vote = setup_test_closed_vote(Some(setup_test_vote_results())); + vote.cfg.announcements = None; + vote.close_attempts = 1; + vote.is_pull_request = true; + vote.close_comment_posted_at = Some(test_closed_at()); + vote.close_head_sha = Some(HEAD_SHA.to_string()); + expect_claim_vote(&mut db, vote); + expect_state_steps_pending(&mut db, &[VoteCloseStep::CheckRun, VoteCloseStep::Labels], true); + db.expect_close_vote().never(); + expect_close_steps(&mut db, &[VoteCloseStep::CheckRun, VoteCloseStep::Labels]); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations: the head of the pull request is not read again + let mut gh = MockGH::new(); + gh.expect_get_pr_head_sha().never(); + gh.expect_create_check_run().never(); + expect_commit_check_run(&mut gh, HEAD_SHA, passed_check_details()); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels( + &mut gh, + &mut Sequence::new(), + &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], + || Ok(()), + ); + + // Run and check the vote is processed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_on_pr_stores_head_and_creates_check_run_on_it() { + // Setup database expectations + let mut db = MockDB::new(); + let mut vote = setup_test_vote(); + vote.is_pull_request = true; + expect_claim_vote(&mut db, vote); + expect_state_steps_pending(&mut db, &[VoteCloseStep::CheckRun, VoteCloseStep::Labels], true); + db.expect_close_vote() + .withf(|vote_id, results, head_sha| { + *vote_id == test_vote_id() + && results == &Some(&setup_test_vote_results()) + && *head_sha == Some(HEAD_SHA) + }) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Ok(true)))); + expect_close_steps( + &mut db, + &[ + VoteCloseStep::Comment, + VoteCloseStep::Announcement, + VoteCloseStep::CheckRun, + VoteCloseStep::Labels, + ], + ); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + expect_pr_head_sha(&mut gh, HEAD_SHA); + expect_comment(&mut gh, vote_closed_body(test_vote_id())); + expect_announcement_discussion(&mut gh, &mut Sequence::new(), test_vote_id(), || Ok(())); + gh.expect_create_check_run().never(); + expect_commit_check_run(&mut gh, HEAD_SHA, passed_check_details()); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels( + &mut gh, + &mut Sequence::new(), + &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], + || Ok(()), + ); + + // Run and check the vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_on_pr_without_results() { + // Setup database expectations (vote comment was deleted) + let mut db = MockDB::new(); + let mut vote = setup_test_closed_vote(None); + vote.is_pull_request = true; + expect_claim_vote(&mut db, vote); + expect_state_steps_pending(&mut db, &[VoteCloseStep::CheckRun, VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::CheckRun, VoteCloseStep::Labels]); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_post_comment().never(); + gh.expect_create_discussion().never(); + expect_check_run( + &mut gh, + CheckDetails { + status: "completed".to_string(), + summary: CHECK_SUMMARY_NO_RESULTS.to_string(), + + conclusion: Some("failure".to_string()), + }, + ); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_open_vote_retry_does_not_look_up_markers() { + // Setup database expectations: previous attempts failed before closing the vote + let mut db = MockDB::new(); + let mut vote = setup_test_vote(); + vote.close_attempts = 3; + expect_claim_vote(&mut db, vote); + db.expect_close_vote() + .withf(|vote_id, results, head_sha| { + *vote_id == test_vote_id() && results == &Some(&setup_test_vote_results()) && head_sha.is_none() + }) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Ok(true)))); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps( + &mut db, + &[ + VoteCloseStep::Announcement, + VoteCloseStep::Comment, + VoteCloseStep::Labels, + ], + ); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations: nothing can have been published before the vote was + // closed, so the results are posted without looking them up + let mut gh = MockGH::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + expect_no_marker_lookups(&mut gh); + expect_vote_closed_published(&mut gh, test_vote_id()); + + // Run and check the vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_removing_label_error_is_ignored() { + // Setup database expectations + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_closed_vote(None)); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Labels]); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is closed successfully + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_retry_announcement_lookup_error_records_failure() { + // Setup database expectations: the announcement is not recorded, as it is unknown + // whether the previous attempt created it + let mut db = MockDB::new(); + let mut vote = setup_test_closed_vote(Some(setup_test_vote_results())); + vote.close_comment_posted_at = Some(test_closed_at()); + vote.close_labels_updated_at = Some(test_closed_at()); + expect_claim_vote(&mut db, vote); + db.expect_record_vote_close_step().never(); + db.expect_complete_vote_close().never(); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + + // Setup GitHub expectations: the announcement is not created blindly + let mut gh = MockGH::new(); + gh.expect_has_comment_with_marker().never(); + expect_announcement_lookup(&mut gh, test_vote_id(), test_lookup_since(), || { + Err(format_err!(ERROR)) + }); + gh.expect_create_discussion().never(); + + // Run and check the failure is recorded + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_retry_announcement_lookup_rejected_is_not_retried() { + // Setup database expectations: the announcement is given up, as when its creation + // is rejected + let mut db = MockDB::new(); + let mut vote = setup_test_closed_vote(Some(setup_test_vote_results())); + vote.close_comment_posted_at = Some(test_closed_at()); + vote.close_labels_updated_at = Some(test_closed_at()); + expect_claim_vote(&mut db, vote); + expect_close_steps(&mut db, &[VoteCloseStep::Announcement]); + expect_complete_vote_close(&mut db); + db.expect_record_vote_close_failure().never(); + + // Setup GitHub expectations: GitHub rejects the lookup for a reason that retrying + // will not solve + let mut gh = MockGH::new(); + expect_announcement_lookup(&mut gh, test_vote_id(), test_lookup_since(), || { + Err(DiscussionRejectedError(format_err!(ERROR)).into()) + }); + gh.expect_create_discussion().never(); + + // Run and check the vote close is complete + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_retry_comment_lookup_error_records_failure() { + // Setup database expectations: the comment is not recorded, as it is unknown whether + // the previous attempt posted it, while the other steps are done + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_closed_vote(Some(setup_test_vote_results()))); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Announcement, VoteCloseStep::Labels]); + db.expect_complete_vote_close().never(); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + + // Setup GitHub expectations: the comment is not posted blindly + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Err(format_err!(ERROR)), + ); + gh.expect_post_comment().never(); + expect_announcement_lookup(&mut gh, test_vote_id(), test_lookup_since(), || Ok(false)); + expect_announcement_discussion(&mut gh, &mut Sequence::new(), test_vote_id(), || Ok(())); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels( + &mut gh, + &mut Sequence::new(), + &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], + || Ok(()), + ); + + // Run and check the failure is recorded + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_retry_looks_up_steps_before_posting_them() { + // Setup database expectations + let mut db = MockDB::new(); + let mut vote = setup_test_closed_vote(Some(setup_test_vote_results())); + vote.close_labels_updated_at = Some(test_closed_at()); + expect_claim_vote(&mut db, vote); + expect_close_steps(&mut db, &[VoteCloseStep::Announcement, VoteCloseStep::Comment]); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations: the comment and the announcement are not found, so they + // are posted after looking them up + let mut seq = Sequence::new(); + let mut gh = MockGH::new(); + expect_comment_lookup_in_sequence( + &mut gh, + &mut seq, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_comment_in_sequence(&mut gh, &mut seq, vote_closed_body(test_vote_id()), || { + Ok(COMMENT_ID) + }); + expect_announcement_lookup_in_sequence(&mut gh, &mut seq, test_vote_id(), test_lookup_since(), || { + Ok(false) + }); + expect_announcement_discussion(&mut gh, &mut seq, test_vote_id(), || Ok(())); + + // Run and check the vote close is complete + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_retry_only_runs_missing_steps() { + use VoteCloseStep as Step; + + // Setup the cases: the steps done already and the ones the retry has to run + let done_at = test_closed_at(); + let cases = [ + ( + vec![], + vec![Step::Comment, Step::Announcement, Step::CheckRun, Step::Labels], + ), + ( + vec![Step::Comment], + vec![Step::Announcement, Step::CheckRun, Step::Labels], + ), + ( + vec![Step::Announcement], + vec![Step::Comment, Step::CheckRun, Step::Labels], + ), + ( + vec![Step::Comment, Step::Announcement], + vec![Step::CheckRun, Step::Labels], + ), + ( + vec![Step::Comment, Step::Announcement, Step::CheckRun], + vec![Step::Labels], + ), + ( + vec![Step::CheckRun, Step::Labels], + vec![Step::Comment, Step::Announcement], + ), + ( + vec![Step::Comment, Step::Announcement, Step::CheckRun, Step::Labels], + vec![], + ), + ]; + + for (done, missing) in cases { + // Setup a pull request vote closed already with the steps done recorded + let mut vote = setup_test_closed_vote(Some(setup_test_vote_results())); + vote.close_attempts = 1; + vote.is_pull_request = true; + vote.close_announcement_posted_at = done.contains(&Step::Announcement).then_some(done_at); + vote.close_check_run_created_at = done.contains(&Step::CheckRun).then_some(done_at); + vote.close_comment_posted_at = done.contains(&Step::Comment).then_some(done_at); + vote.close_labels_updated_at = done.contains(&Step::Labels).then_some(done_at); + + // Setup database expectations + let mut db = MockDB::new(); + expect_claim_vote(&mut db, vote); + let state_steps: Vec<_> = + [Step::CheckRun, Step::Labels].into_iter().filter(|s| missing.contains(s)).collect(); + expect_state_steps_pending(&mut db, &state_steps, true); + db.expect_close_vote().never(); + expect_close_steps(&mut db, &missing); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations: the comment and the announcement missing are looked + // up before posting them, as a previous attempt may have posted them, and none + // of the steps done is published again + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions().never(); + if missing.contains(&Step::Comment) { + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_comment(&mut gh, vote_closed_body(test_vote_id())); + } else { + gh.expect_has_comment_with_marker().never(); + gh.expect_post_comment().never(); + } + if missing.contains(&Step::Announcement) { + expect_announcement_lookup(&mut gh, test_vote_id(), test_lookup_since(), || Ok(false)); + expect_announcement_discussion(&mut gh, &mut Sequence::new(), test_vote_id(), || Ok(())); + } else { + gh.expect_has_discussion_with_marker().never(); + gh.expect_create_discussion().never(); + } + if missing.contains(&Step::CheckRun) { + expect_check_run(&mut gh, passed_check_details()); + } else { + gh.expect_create_check_run().never(); + } + if missing.contains(&Step::Labels) { + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels( + &mut gh, + &mut Sequence::new(), + &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], + || Ok(()), + ); + } else { + gh.expect_remove_label().never(); + gh.expect_add_labels().never(); + } + + // Run and check the vote is processed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + } +} + +#[tokio::test] +async fn close_finished_vote_retry_records_steps_found_published() { + // Setup database expectations: a vote closed by a worker that died before recording + // anything else, so its claim expired without a failure being recorded + let mut db = MockDB::new(); + let vote = setup_test_closed_vote(Some(setup_test_vote_results())); + assert_eq!(vote.close_attempts, 0); + expect_claim_vote(&mut db, vote); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps( + &mut db, + &[ + VoteCloseStep::Announcement, + VoteCloseStep::Comment, + VoteCloseStep::Labels, + ], + ); + expect_complete_vote_close(&mut db); + db.expect_record_vote_close_failure().never(); + + // Setup GitHub expectations: the comment and the announcement are found, so they are + // not posted again + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(true), + ); + expect_announcement_lookup(&mut gh, test_vote_id(), test_lookup_since(), || Ok(true)); + gh.expect_post_comment().never(); + gh.expect_create_discussion().never(); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels( + &mut gh, + &mut Sequence::new(), + &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], + || Ok(()), + ); + + // Run and check the vote close is complete + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_vote_cancelled_after_being_claimed() { + // Setup database expectations + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_vote()); + db.expect_close_vote() + .withf(|vote_id, results, head_sha| { + *vote_id == test_vote_id() && results == &Some(&setup_test_vote_results()) && head_sha.is_none() + }) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Ok(false)))); + db.expect_record_vote_close_step().never(); + db.expect_complete_vote_close().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + gh.expect_post_comment().never(); + gh.expect_add_labels().never(); + + // Run and check nothing is published + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_without_announcements() { + // Setup database expectations + let mut db = MockDB::new(); + let mut vote = setup_test_closed_vote(Some(setup_test_vote_results())); + vote.cfg.announcements = None; + expect_claim_vote(&mut db, vote); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Comment, VoteCloseStep::Labels]); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_comment(&mut gh, vote_closed_body(test_vote_id())); + gh.expect_has_discussion_with_marker().never(); + gh.expect_create_discussion().never(); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels( + &mut gh, + &mut Sequence::new(), + &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], + || Ok(()), + ); + + // Run and check the vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +#[tokio::test] +async fn close_finished_vote_without_issue_title() { + // Setup database expectations + let mut db = MockDB::new(); + let mut vote = setup_test_closed_vote(Some(setup_test_vote_results())); + vote.issue_title = None; + expect_claim_vote(&mut db, vote); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps( + &mut db, + &[ + VoteCloseStep::Comment, + VoteCloseStep::Announcement, + VoteCloseStep::Labels, + ], + ); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_announcement_lookup(&mut gh, test_vote_id(), test_lookup_since(), || Ok(false)); + expect_comment(&mut gh, vote_closed_body(test_vote_id())); + gh.expect_create_discussion() + .withf(|inst_id, owner, repo, category, title, body| { + let results = setup_test_vote_results(); + let expected_body = tmpl::VoteClosedAnnouncement::new(test_vote_id(), ISSUE_NUM, "", &results) + .render() + .unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && category == DISCUSSIONS_CATEGORY + && title == build_announcement_title(ISSUE_NUM, "") + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is closed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); +} + +// Helpers. + +/// Expects a lookup of the results announcement of the vote provided in the test +/// discussions category once, in the sequence given, from a time in the range provided, +/// with the result given. +fn expect_announcement_lookup_in_sequence( + gh: &mut MockGH, + seq: &mut Sequence, + vote_id: Uuid, + since: RangeInclusive, + result: fn() -> Result, +) { + let expected_marker = tmpl::VoteMarker::ResultsAnnouncement.render(vote_id); + gh.expect_has_discussion_with_marker() + .withf(move |inst_id, owner, repo, category, marker, lookup_since| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && category == DISCUSSIONS_CATEGORY + && marker == expected_marker + && since.contains(lookup_since) + }) + .times(1) + .in_sequence(seq) + .returning(move |_, _, _, _, _, _| Box::pin(future::ready(result()))); +} + +/// Expects a lookup of the comment with the marker provided of the vote given on the +/// test issue once, in the sequence provided, from a time in the range given, with the +/// result provided. +fn expect_comment_lookup_in_sequence( + gh: &mut MockGH, + seq: &mut Sequence, + marker: tmpl::VoteMarker, + vote_id: Uuid, + since: RangeInclusive, + result: fn() -> Result, +) { + let expected_marker = marker.render(vote_id); + gh.expect_has_comment_with_marker() + .withf(move |inst_id, owner, repo, issue_number, marker, lookup_since| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && marker == expected_marker + && since.contains(lookup_since) + }) + .times(1) + .in_sequence(seq) + .returning(move |_, _, _, _, _, _| Box::pin(future::ready(result()))); +} diff --git a/src/processor/tests/close_vote.rs b/src/processor/tests/close_vote.rs new file mode 100644 index 0000000..0fbbdf2 --- /dev/null +++ b/src/processor/tests/close_vote.rs @@ -0,0 +1,180 @@ +use std::sync::Arc; + +use anyhow::format_err; +use mockall::{Sequence, predicate::eq}; + +use crate::{db::MockDB, github::*, testutil::*}; + +use super::*; + +#[tokio::test] +async fn close_vote_cancelled_vote_error_completing_cancellation_is_returned_with_context() { + // Setup a cancelled vote with all its steps done + let mut vote = setup_test_cancelled_vote(false); + vote.close_comment_posted_at = Some(test_closed_at()); + vote.close_labels_updated_at = Some(test_closed_at()); + + // Setup database expectations: completing the cancellation fails + let mut db = MockDB::new(); + db.expect_complete_vote_cancellation() + .with(eq(test_vote_id())) + .times(1) + .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run and check the error says what failed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(MockGH::new())); + let err = votes_closer.close_vote(&vote).await.unwrap_err(); + assert_eq!(err.to_string(), "error completing vote cancellation"); + assert_eq!(err.root_cause().to_string(), ERROR); +} + +#[tokio::test] +async fn close_vote_error_checking_state_step_is_returned_with_context() { + // Setup database expectations: checking if the labels step is pending fails + let mut db = MockDB::new(); + db.expect_is_close_state_step_pending() + .with(eq(test_vote_id()), eq(VoteCloseStep::Labels)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + db.expect_record_vote_close_step().never(); + db.expect_complete_vote_close().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_remove_label().never(); + gh.expect_add_labels().never(); + + // Run and check the error says which step failed and why + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + let err = votes_closer.close_vote(&setup_test_closed_vote(None)).await.unwrap_err(); + assert_eq!(err.to_string(), "error running labels step"); + assert_eq!( + err.chain().nth(1).unwrap().to_string(), + "error checking if vote close step is pending" + ); + assert_eq!(err.root_cause().to_string(), ERROR); +} + +#[tokio::test] +async fn close_vote_error_closing_vote_in_database_is_returned_with_context() { + // Setup database expectations: closing the vote fails + let mut db = MockDB::new(); + db.expect_close_vote() + .withf(|vote_id, results, head_sha| { + *vote_id == test_vote_id() && results == &Some(&setup_test_vote_results()) && head_sha.is_none() + }) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + db.expect_record_vote_close_step().never(); + db.expect_complete_vote_close().never(); + + // Setup GitHub expectations: the results are calculated, and nothing is published + let mut gh = MockGH::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + gh.expect_post_comment().never(); + + // Run and check the error says what failed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + let err = votes_closer.close_vote(&setup_test_vote()).await.unwrap_err(); + assert_eq!(err.to_string(), "error closing vote in database"); + assert_eq!(err.root_cause().to_string(), ERROR); +} + +#[tokio::test] +async fn close_vote_error_completing_close_is_returned_with_context() { + // Setup a closed vote with all its steps done + let mut vote = setup_test_closed_vote(None); + vote.close_labels_updated_at = Some(test_closed_at()); + + // Setup database expectations: completing the close fails + let mut db = MockDB::new(); + db.expect_complete_vote_close() + .with(eq(test_vote_id())) + .times(1) + .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run and check the error says what failed + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(MockGH::new())); + let err = votes_closer.close_vote(&vote).await.unwrap_err(); + assert_eq!(err.to_string(), "error completing vote close"); + assert_eq!(err.root_cause().to_string(), ERROR); +} + +#[tokio::test] +async fn close_vote_error_recording_step_is_returned_with_context() { + // Setup database expectations: the labels step was superseded by a newer vote, and + // recording it as done fails + let mut db = MockDB::new(); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], false); + db.expect_record_vote_close_step() + .with(eq(test_vote_id()), eq(VoteCloseStep::Labels)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + db.expect_complete_vote_close().never(); + + // Run and check the error says which step failed and why + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(MockGH::new())); + let err = votes_closer.close_vote(&setup_test_closed_vote(None)).await.unwrap_err(); + assert_eq!(err.to_string(), "error running labels step"); + assert_eq!( + err.chain().nth(1).unwrap().to_string(), + "error recording vote close step" + ); + assert_eq!(err.root_cause().to_string(), ERROR); +} + +#[tokio::test] +async fn close_vote_returns_the_first_error_after_trying_all_the_steps() { + // Setup a closed vote on a pull request with all its steps pending + let mut vote = setup_test_closed_vote(Some(setup_test_vote_results())); + vote.is_pull_request = true; + + // Setup database expectations + let mut db = MockDB::new(); + expect_state_steps_pending(&mut db, &[VoteCloseStep::CheckRun, VoteCloseStep::Labels], true); + db.expect_record_vote_close_step().never(); + db.expect_complete_vote_close().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + test_vote_id(), + test_lookup_since(), + || Ok(false), + ); + expect_announcement_lookup(&mut gh, test_vote_id(), test_lookup_since(), || Ok(false)); + expect_comment_in_sequence( + &mut gh, + &mut Sequence::new(), + vote_closed_body(test_vote_id()), + || Err(format_err!("comment error")), + ); + expect_announcement_discussion(&mut gh, &mut Sequence::new(), test_vote_id(), || { + Err(format_err!("discussion error")) + }); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(passed_check_details()), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!("check run error"))))); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels( + &mut gh, + &mut Sequence::new(), + &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], + || Err(format_err!("labels error")), + ); + + // Run and check the error returned is the one from the first step + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + let err = votes_closer.close_vote(&vote).await.unwrap_err(); + assert_eq!(err.to_string(), "error running comment step"); + assert_eq!(err.root_cause().to_string(), "comment error"); +} diff --git a/src/processor/tests/commands_handler.rs b/src/processor/tests/commands_handler.rs new file mode 100644 index 0000000..57a39a6 --- /dev/null +++ b/src/processor/tests/commands_handler.rs @@ -0,0 +1,220 @@ +use std::sync::Arc; + +use anyhow::format_err; +use mockall::predicate::eq; +use tokio::sync::oneshot; + +use crate::{db::MockDB, github::*, testutil::*}; + +use super::*; + +#[tokio::test] +async fn commands_handler_continues_after_failed_cmd() { + // Setup the failing commands and the expectations that make them fail + type ExpectFailure = fn(&mut MockDB, &mut MockGH); + let event = Event::Issue(setup_test_issue_event()); + let cases: [(Command, ExpectFailure); 3] = [ + (Command::CancelVote(CancelVoteInput::new(&event)), |db, gh| { + expect_user_is_collaborator(gh, true); + db.expect_cancel_vote() + .withf(|repository_full_name, issue_number, cancelled_by| { + repository_full_name == REPOFN && *issue_number == ISSUE_NUM && cancelled_by == USER + }) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + }), + (Command::CheckVote(CheckVoteInput::new(&event)), |db, _| { + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + }), + ( + Command::CreateVote(CreateVoteInput::new(None, &event)), + |_, gh| { + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + }, + ), + ]; + + for (failed_cmd, expect_failure) in cases { + // Setup expectations: reporting the failure is best effort + let mut db = MockDB::new(); + let mut gh = MockGH::new(); + expect_failure(&mut db, &mut gh); + expect_command_failed_comment(&mut gh, || Err(format_err!(ERROR))); + + // Setup the expectations of the next command + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(None)))); + + // Queue commands + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + cmds_tx.send(failed_cmd).await.unwrap(); + cmds_tx.send(Command::CheckVote(CheckVoteInput::new(&event))).await.unwrap(); + + // Run the commands handler until it's asked to stop + let cancel_token = CancellationToken::new(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx.clone()); + let cmds_handler_handle = cmds_handler.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the command after the failed one was processed + assert!(cmds_handler_handle.await.is_ok()); + assert!(cmds_rx.is_empty()); + } +} + +#[tokio::test] +async fn commands_handler_finishes_cmd_in_progress_when_requested() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + expect_store_vote(&mut db, false, || Ok(test_vote_id())); + + // Setup GitHub expectations: the vote comment is held until released + let (posting_tx, posting_rx) = oneshot::channel(); + let (release_tx, release_rx) = oneshot::channel::<()>(); + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == vote_created_body(false) + }) + .times(1) + .return_once(move |_, _, _, _, _| { + posting_tx.send(()).unwrap(); + Box::pin(async move { + release_rx.await.unwrap(); + Ok(COMMENT_ID) + }) + }); + expect_vote_open_labels(&mut gh); + + // Run the commands handler until the command is in progress + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let event = Event::Issue(setup_test_issue_event()); + cmds_tx.send(Command::CreateVote(CreateVoteInput::new(None, &event))).await.unwrap(); + let cancel_token = CancellationToken::new(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + let cmds_handler_handle = cmds_handler.run(cancel_token.clone()); + posting_rx.await.unwrap(); + + // Ask it to stop, letting the worker notice before the command can complete + cancel_token.cancel(); + tokio::task::yield_now().await; + release_tx.send(()).unwrap(); + + // Check the command completed (expectations are verified on drop) + assert!(cmds_handler_handle.await.is_ok()); +} + +#[tokio::test] +async fn commands_handler_processes_create_and_check_vote_cmds() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(None)))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Ok(None)))); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::ConfigNotFound {}.render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Queue commands + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let event = Event::Issue(setup_test_issue_event()); + cmds_tx.send(Command::CreateVote(CreateVoteInput::new(None, &event))).await.unwrap(); + cmds_tx.send(Command::CheckVote(CheckVoteInput::new(&event))).await.unwrap(); + + // Run the commands handler until it's asked to stop + let cancel_token = CancellationToken::new(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx.clone()); + let cmds_handler_handle = cmds_handler.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check all commands were processed + assert!(cmds_handler_handle.await.is_ok()); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn commands_handler_stops_after_processing_queued_cmd() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_cancel_vote() + .withf(|repository_full_name, issue_number, cancelled_by| { + repository_full_name == REPOFN && *issue_number == ISSUE_NUM && cancelled_by == USER + }) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + expect_command_failed_comment(&mut gh, || Ok(COMMENT_ID)); + + // Queue command + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let cancel_token = CancellationToken::new(); + let event = setup_test_issue_event(); + let cmd = Command::CancelVote(CancelVoteInput::new(&Event::Issue(event))); + cmds_tx.send(cmd).await.unwrap(); + + // Run the commands handler until it's asked to stop + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx.clone()); + let cmds_handler_handle = cmds_handler.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the command was processed + assert!(cmds_handler_handle.await.is_ok()); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test] +async fn commands_handler_stops_when_requested() { + // Setup mocks + let db = MockDB::new(); + let gh = MockGH::new(); + + // Run the commands handler until it's asked to stop + let (_, cmds_rx) = async_channel::unbounded(); + let cancel_token = CancellationToken::new(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + let cmds_handler_handle = cmds_handler.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the commands handler completed + assert!(cmds_handler_handle.await.is_ok()); +} diff --git a/src/processor/tests/create_vote.rs b/src/processor/tests/create_vote.rs new file mode 100644 index 0000000..dc105a4 --- /dev/null +++ b/src/processor/tests/create_vote.rs @@ -0,0 +1,1350 @@ +use std::sync::Arc; + +use anyhow::format_err; +use mockall::{Sequence, predicate::eq}; + +use crate::{cfg_repo::AllowedVoters, db::MockDB, github::*, testutil::*}; + +use super::*; + +#[tokio::test] +async fn create_vote_automatic_error_reporting_failure_is_not_reported_again() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + gh.expect_is_check_required().never(); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(cmd::automation_failed_check()), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + let details = tmpl::ConfigProfileNotFound {}.render().unwrap(); + expect_comment( + &mut gh, + tmpl::AutomationFailed::new(Some(&details)).render().unwrap(), + ); + + // Setup database expectations: the pull request has no vote + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + + // Run the command and check the comment was still posted, and is the only one + let event = Event::PullRequest(setup_test_pr_event()); + let input = CreateVoteInput::new_automatic("typo", &event); + let cmd = Command::CreateVote(input); + setup_cmds_handler(db, gh).process_command(cmd).await; +} + +#[tokio::test] +async fn create_vote_automatic_invalid_profile_reports_automation_failure() { + // Setup cases (profile, expected reason) + let test_cases = [ + ( + "duration: 36501d\n pass_threshold: 50", + "duration must be at least 1 second and not greater than 36500 days", + ), + ( + "duration: 5m\n pass_threshold: 50\n periodic_status_check: 12 hours", + "periodic_status_check", + ), + ( + "duration: 5m\n pass_threshold: 0", + "pass threshold must be greater than 0", + ), + ]; + for (profile, expected_reason) in test_cases { + // Setup event of a pull request matching an automation rule + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Opened; + let event = Event::PullRequest(event); + + // Setup GitHub expectations: the check does not depend on being required + let config = format!( + r#" +automation: + enabled: true + rules: + - patterns: ["README.md"] + profile: default +profiles: + default: + {profile} +"# + ); + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(2) + .returning(move |_, _, _| Box::pin(future::ready(Ok(Some(config.clone()))))); + gh.expect_get_pr_files() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _, _, _| { + let file = File { + filename: "README.md".to_string(), + }; + Box::pin(future::ready(Ok(vec![file]))) + }); + gh.expect_is_check_required().never(); + gh.expect_user_is_collaborator().never(); + expect_check_run(&mut gh, cmd::automation_failed_check()); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body.contains("Something went wrong while processing the automation rules") + && body.contains("Something went wrong while processing the configuration file") + && body.contains(expected_reason) + && body.contains("`/vote`") + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + let gh = Arc::new(gh); + + // Setup database expectations: the pull request has no vote + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + db.expect_store_vote().never(); + + // Run the automatic command created from the event + let cmd = Command::from_event(gh.clone(), &event).await.unwrap().unwrap(); + let (_, cmds_rx) = async_channel::unbounded(); + CommandsHandler::new(Arc::new(db), gh, cmds_rx).process_command(cmd).await; + } +} + +#[tokio::test] +async fn create_vote_automatic_missing_profile_reports_automation_failure() { + // Setup event of a pull request matching an automation rule + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Opened; + let event = Event::PullRequest(event); + + // Setup GitHub expectations: the check does not depend on being required + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(2) + .returning(|_, _, _| { + let config = r#" +automation: + enabled: true + rules: + - patterns: ["README.md"] + profile: typo +profiles: + default: + duration: 5m + pass_threshold: 50 +"#; + Box::pin(future::ready(Ok(Some(config.to_string())))) + }); + gh.expect_get_pr_files() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _, _, _| { + let file = File { + filename: "README.md".to_string(), + }; + Box::pin(future::ready(Ok(vec![file]))) + }); + gh.expect_is_check_required().never(); + expect_check_run(&mut gh, cmd::automation_failed_check()); + let details = tmpl::ConfigProfileNotFound {}.render().unwrap(); + expect_comment( + &mut gh, + tmpl::AutomationFailed::new(Some(&details)).render().unwrap(), + ); + let gh = Arc::new(gh); + + // Setup database expectations: the pull request has no vote + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + + // Run the automatic command created from the event + let cmd = Command::from_event(gh.clone(), &event).await.unwrap().unwrap(); + let (_, cmds_rx) = async_channel::unbounded(); + CommandsHandler::new(Arc::new(db), gh, cmds_rx).process_command(cmd).await; +} + +#[tokio::test] +async fn create_vote_error_adding_labels() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + expect_store_vote(&mut db, false, || Ok(test_vote_id())); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + expect_vote_created_comment(&mut gh, &mut Sequence::new(), false); + for label in [VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL, VOTE_FAILED_LABEL] { + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(label)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + } + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![GITVOTE_LABEL, VOTE_OPEN_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run and check the setup error is propagated with its step + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); + assert!(err.is::()); + assert_eq!(err.to_string(), "error running labels step"); + assert_eq!(err.root_cause().to_string(), ERROR); +} + +#[tokio::test] +async fn create_vote_error_checking_if_user_is_collaborator() { + // Setup GitHub expectations + let db = MockDB::new(); + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_post_comment().never(); + + // Run and check the error is propagated + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn create_vote_error_checking_if_vote_is_open() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + db.expect_store_vote().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + gh.expect_post_comment().never(); + + // Run and check the error is propagated + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn create_vote_error_getting_configuration_file() { + // Setup GitHub expectations + let db = MockDB::new(); + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_user_is_collaborator().never(); + gh.expect_post_comment().never(); + + // Run and check the error is propagated instead of reporting a missing config + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn create_vote_error_getting_configuration_profile() { + // Setup GitHub expectations + let db = MockDB::new(); + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Ok(None)))); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::ConfigNotFound {}.render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run and check the configuration error is posted + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_issue_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler.create_vote(&CreateVoteInput::new(None, &Event::Issue(event))).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_error_posting_vote_created_comment() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + db.expect_store_vote().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == vote_created_body(false) + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_add_labels().never(); + + // Run and check the error is propagated + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn create_vote_error_storing_vote() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + expect_store_vote(&mut db, true, || Err(format_err!(ERROR))); + expect_has_vote_with_comment(&mut db, Ok(false)); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + expect_vote_created_comment(&mut gh, &mut Sequence::new(), true); + gh.expect_delete_comment() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_create_check_run().never(); + gh.expect_remove_label().never(); + gh.expect_add_labels().never(); + + // Run and check the error is propagated + let input = CreateVoteInput::new(None, &Event::PullRequest(setup_test_pr_event())); + let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn create_vote_error_storing_vote_and_deleting_comment() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + expect_store_vote(&mut db, false, || Err(format_err!(ERROR))); + expect_has_vote_with_comment(&mut db, Ok(false)); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + expect_vote_created_comment(&mut gh, &mut Sequence::new(), false); + gh.expect_delete_comment() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!("delete error"))))); + + // Run and check the error storing the vote is the one propagated + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn create_vote_error_storing_vote_lookup_error_keeps_comment() { + // Setup database expectations: it is unknown whether the vote was stored + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + expect_store_vote(&mut db, false, || Err(format_err!(ERROR))); + expect_has_vote_with_comment(&mut db, Err(format_err!("lookup error"))); + + // Setup GitHub expectations: the vote is not set up and its comment is kept + let mut gh = MockGH::new(); + let mut seq = Sequence::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + expect_vote_created_comment(&mut gh, &mut seq, false); + gh.expect_delete_comment().never(); + gh.expect_remove_label().never(); + gh.expect_add_labels().never(); + + // Run and check the error storing the vote is the one propagated + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} + +#[tokio::test] +async fn create_vote_error_storing_vote_stored_anyway_sets_it_up() { + // Setup database expectations: the vote was stored despite the error + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + expect_store_vote(&mut db, false, || Err(format_err!(ERROR))); + expect_has_vote_with_comment(&mut db, Ok(true)); + + // Setup GitHub expectations: the comment is kept and the vote is set up + let mut gh = MockGH::new(); + let mut seq = Sequence::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + expect_vote_created_comment(&mut gh, &mut seq, false); + gh.expect_delete_comment().never(); + gh.expect_create_check_run().never(); + expect_vote_open_labels(&mut gh); + + // Run and check the vote is created successfully + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_invalid_config_duration_too_long() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_store_vote().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r" +profiles: + default: + duration: 36501d + pass_threshold: 50 +"; + Box::pin(future::ready(Ok(Some(config.to_string())))) + }); + gh.expect_user_is_collaborator().never(); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body.contains("Something went wrong while processing the configuration file") + && body.contains("duration must be at least 1 second and not greater than 36500 days") + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run the command and check only the invalid configuration comment is posted + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_invalid_config_duration_zero() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_store_vote().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r" +profiles: + default: + duration: 0s + pass_threshold: 50 +"; + Box::pin(future::ready(Ok(Some(config.to_string())))) + }); + gh.expect_user_is_collaborator().never(); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body.contains("Something went wrong while processing the configuration file") + && body.contains("duration must be at least 1 second and not greater than 36500 days") + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run the command and check only the invalid configuration comment is posted + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_invalid_config_periodic_status_check() { + // Setup GitHub expectations + let db = MockDB::new(); + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| { + let config = r#" +profiles: + default: + duration: 5m + pass_threshold: 50 + periodic_status_check: "5 dayz" +"#; + Box::pin(future::ready(Ok(Some(config.to_string())))) + }); + gh.expect_user_is_collaborator().never(); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body.contains("Something went wrong while processing the configuration file") + && body.contains("periodic_status_check must be a number of days or weeks") + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run the command and check no vote is created + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_invalid_config_teams_owner_not_org() { + // Setup GitHub expectations + let db = MockDB::new(); + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + gh.expect_user_is_collaborator().never(); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = + tmpl::InvalidConfig::new("teams in allowed voters can only be used in organizations") + .render() + .unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run the command on a repository that does not belong to an organization + let mut event = setup_test_issue_event(); + event.organization = None; + let input = CreateVoteInput::new(Some(PROFILE_NAME), &Event::Issue(event)); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_issue_already_has_a_vote() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(true)))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteInProgress::new(USER, false).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run and check the vote is not created + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_issue_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler.create_vote(&CreateVoteInput::new(None, &Event::Issue(event))).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_lost_race_error_deleting_comment_is_ignored() { + // Setup database expectations + let mut db = MockDB::new(); + expect_store_vote_lost_race(&mut db, false); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + let mut seq = Sequence::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + expect_vote_created_comment(&mut gh, &mut seq, false); + gh.expect_delete_comment() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .in_sequence(&mut seq) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_vote_in_progress_comment(&mut gh, &mut seq, false); + + // Run and check the vote is reported as already in progress + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_lost_race_on_issue_and_pr() { + for is_pull_request in [false, true] { + // Setup database expectations + let mut db = MockDB::new(); + expect_store_vote_lost_race(&mut db, is_pull_request); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + let mut seq = Sequence::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + expect_vote_created_comment(&mut gh, &mut seq, is_pull_request); + gh.expect_delete_comment() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .in_sequence(&mut seq) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(())))); + expect_vote_in_progress_comment(&mut gh, &mut seq, is_pull_request); + gh.expect_create_check_run().never(); + gh.expect_remove_label().never(); + gh.expect_add_labels().never(); + + // Run and check the vote is reported as already in progress + let event = if is_pull_request { + Event::PullRequest(setup_test_pr_event()) + } else { + Event::Issue(setup_test_issue_event()) + }; + let input = CreateVoteInput::new(None, &event); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); + } +} + +#[tokio::test] +async fn create_vote_non_collaborator() { + // Setup GitHub expectations + let db = MockDB::new(); + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(false)))); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteRestricted::new(USER).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run and check the vote is not created + let (_, cmds_rx) = async_channel::unbounded(); + let event = setup_test_issue_event(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler.create_vote(&CreateVoteInput::new(None, &Event::Issue(event))).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_pr_error_creating_check_run() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + expect_store_vote(&mut db, true, || Ok(test_vote_id())); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + expect_vote_created_comment(&mut gh, &mut Sequence::new(), true); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(CheckDetails { + status: "in_progress".to_string(), + summary: "Vote open".to_string(), + + conclusion: None, + }), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + for label in [VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL, VOTE_FAILED_LABEL] { + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(label)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + } + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![GITVOTE_LABEL, VOTE_OPEN_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the labels were updated and the setup error is propagated + let input = CreateVoteInput::new(None, &Event::PullRequest(setup_test_pr_event())); + let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); + assert!(err.is::()); + assert_eq!(err.to_string(), "error running check run step"); + assert_eq!(err.root_cause().to_string(), ERROR); +} + +#[tokio::test] +async fn create_vote_pr_error_creating_check_run_and_adding_labels() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + expect_store_vote(&mut db, true, || Ok(test_vote_id())); + + // Setup GitHub expectations: both setup steps are tried and fail + let mut gh = MockGH::new(); + let mut seq = Sequence::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + expect_vote_created_comment(&mut gh, &mut seq, true); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(CheckDetails { + status: "in_progress".to_string(), + summary: "Vote open".to_string(), + + conclusion: None, + }), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!("check run error"))))); + for label in [VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL, VOTE_FAILED_LABEL] { + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(label)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + } + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![GITVOTE_LABEL, VOTE_OPEN_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!("labels error"))))); + + // Run and check the first setup error is the one propagated + let input = CreateVoteInput::new(None, &Event::PullRequest(setup_test_pr_event())); + let err = setup_cmds_handler(db, gh).create_vote(&input).await.unwrap_err(); + assert!(err.is::()); + assert_eq!(err.to_string(), "error running check run step"); + assert_eq!(err.root_cause().to_string(), "check run error"); +} + +#[tokio::test] +async fn create_vote_pr_success() { + // Setup input + let event = setup_test_pr_event(); + let create_vote_input = CreateVoteInput::new(None, &Event::PullRequest(event)); + let cfg = CfgProfile { + duration: Duration::from_mins(5), + pass_threshold: 50.0, + + allowed_voters: Some(AllowedVoters::default()), + ..Default::default() + }; + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + let cfg_copy = cfg.clone(); + let create_vote_input_copy = create_vote_input.clone(); + db.expect_store_vote() + .withf(move |vote_comment_id, input, cfg| { + *vote_comment_id == COMMENT_ID && *input == create_vote_input_copy && *cfg == cfg_copy + }) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Ok(test_vote_id())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + let create_vote_input_copy = create_vote_input.clone(); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteCreated::new(&create_vote_input_copy, &cfg).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + expect_check_run( + &mut gh, + CheckDetails { + status: "in_progress".to_string(), + summary: "Vote open".to_string(), + + conclusion: None, + }, + ); + gh.expect_remove_label() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(VOTE_CLOSED_LABEL), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_remove_label() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(VOTE_PASSED_LABEL), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_remove_label() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(VOTE_FAILED_LABEL), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![GITVOTE_LABEL, VOTE_OPEN_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is created successfully + let (_, cmds_rx) = async_channel::unbounded(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler.create_vote(&create_vote_input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_profile_not_found() { + // Setup GitHub expectations + let db = MockDB::new(); + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + gh.expect_user_is_collaborator().never(); + gh.expect_post_comment() + .withf(|inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::ConfigProfileNotFound {}.render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + + // Run the command using a profile that does not exist + let input = CreateVoteInput::new(Some("profile9"), &Event::Issue(setup_test_issue_event())); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_rejected_on_issue_does_not_update_check() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, false); + expect_comment(&mut gh, tmpl::VoteRestricted::new(USER).render().unwrap()); + gh.expect_is_check_required().never(); + gh.expect_create_check_run().never(); + + // Run the command + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + setup_cmds_handler(MockDB::new(), gh).create_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_rejected_on_opened_pr_automatic_cmd_from_non_collaborator() { + // Setup event of a pull request opened by a user who is not a collaborator + let sender = "external-contributor"; + let mut event = setup_test_pr_event(); + event.action = PullRequestEventAction::Opened; + event.sender.login = sender.to_string(); + let event = Event::PullRequest(event); + + // Setup GitHub expectations: the rules are evaluated for the command and the check + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(3) + .returning(|_, _, _| Box::pin(future::ready(Ok(Some(get_test_valid_config()))))); + gh.expect_get_pr_files() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) + .times(2) + .returning(|_, _, _, _| { + let file = File { + filename: "README.md".to_string(), + }; + Box::pin(future::ready(Ok(vec![file]))) + }); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(sender)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(false)))); + expect_comment(&mut gh, tmpl::VoteRestricted::new(sender).render().unwrap()); + expect_is_check_required(&mut gh, true); + expect_check_run( + &mut gh, + CheckDetails { + status: "completed".to_string(), + summary: "An automation rule matches but there is no vote, maintainers should run /vote" + .to_string(), + + conclusion: Some("failure".to_string()), + }, + ); + let gh = Arc::new(gh); + + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + db.expect_store_vote().never(); + + // Run the automatic command created from the event + let Some(Command::CreateVote(input)) = Command::from_event(gh.clone(), &event).await.unwrap() else { + panic!("expected a create vote command"); + }; + let (_, cmds_rx) = async_channel::unbounded(); + CommandsHandler::new(Arc::new(db), gh, cmds_rx).create_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_rejected_on_opened_pr_check_not_required() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_comment(&mut gh, tmpl::ConfigProfileNotFound {}.render().unwrap()); + expect_is_check_required(&mut gh, false); + gh.expect_create_check_run().never(); + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote().never(); + + // Run the command using a profile that does not exist + let input = CreateVoteInput::new(Some("typo"), &Event::PullRequest(setup_test_pr_event())); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_rejected_on_opened_pr_config_not_found_updates_check() { + // Setup GitHub expectations: the configuration is also needed to decide the check + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(2) + .returning(|_, _, _| Box::pin(future::ready(Ok(None)))); + let expected_body = tmpl::ConfigNotFound {}.render().unwrap(); + expect_comment(&mut gh, expected_body); + expect_is_check_required(&mut gh, true); + expect_check_run(&mut gh, cmd::no_vote_found_check()); + + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + + // Run the command + let input = CreateVoteInput::new(None, &Event::PullRequest(setup_test_pr_event())); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_rejected_on_opened_pr_error_updating_check_is_ignored() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_comment(&mut gh, tmpl::ConfigProfileNotFound {}.render().unwrap()); + gh.expect_is_check_required() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(BRANCH)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_create_check_run().never(); + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote().never(); + + // Run the command using a profile that does not exist + let input = CreateVoteInput::new(Some("typo"), &Event::PullRequest(setup_test_pr_event())); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_rejected_on_opened_pr_non_collaborator_rule_matches_reports_failure() { + // Setup GitHub expectations: the configuration is also needed to decide the check + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(2) + .returning(|_, _, _| Box::pin(future::ready(Ok(Some(get_test_valid_config()))))); + expect_user_is_collaborator(&mut gh, false); + expect_comment(&mut gh, tmpl::VoteRestricted::new(USER).render().unwrap()); + expect_is_check_required(&mut gh, true); + gh.expect_get_pr_files() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _, _, _| { + let file = File { + filename: "README.md".to_string(), + }; + Box::pin(future::ready(Ok(vec![file]))) + }); + expect_check_run( + &mut gh, + CheckDetails { + status: "completed".to_string(), + summary: "An automation rule matches but there is no vote, maintainers should run /vote" + .to_string(), + + conclusion: Some("failure".to_string()), + }, + ); + + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + + // Run the command + let input = CreateVoteInput::new(None, &Event::PullRequest(setup_test_pr_event())); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_rejected_on_opened_pr_profile_not_found_updates_check() { + // Setup GitHub expectations: the configuration is also needed to decide the check + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(2) + .returning(|_, _, _| Box::pin(future::ready(Ok(Some(get_test_valid_config()))))); + expect_comment(&mut gh, tmpl::ConfigProfileNotFound {}.render().unwrap()); + expect_is_check_required(&mut gh, true); + gh.expect_get_pr_files() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _, _, _| { + let file = File { + filename: "main.rs".to_string(), + }; + Box::pin(future::ready(Ok(vec![file]))) + }); + expect_check_run(&mut gh, cmd::no_vote_found_check()); + + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + + // Run the command using a profile that does not exist + let input = CreateVoteInput::new(Some("typo"), &Event::PullRequest(setup_test_pr_event())); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_rejected_on_opened_pr_with_vote_does_not_update_check() { + // A vote created concurrently by a maintainer owns the check, whatever the outcome + // of the lookup: a failed one is ignored like other errors updating the check + let cases: [fn() -> Result; 2] = [|| Ok(true), || Err(format_err!(ERROR))]; + for has_vote_result in cases { + // Setup GitHub expectations: the check is also decided from the configuration + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(2) + .returning(|_, _, _| Box::pin(future::ready(Ok(Some(get_test_valid_config()))))); + expect_user_is_collaborator(&mut gh, false); + expect_comment(&mut gh, tmpl::VoteRestricted::new(USER).render().unwrap()); + expect_is_check_required(&mut gh, true); + gh.expect_get_pr_files() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _, _, _| { + let file = File { + filename: "main.rs".to_string(), + }; + Box::pin(future::ready(Ok(vec![file]))) + }); + gh.expect_create_check_run().never(); + + // Setup database expectations + let mut db = MockDB::new(); + expect_has_vote(&mut db, has_vote_result()); + db.expect_store_vote().never(); + + // Run the command and check it succeeds without reporting the check + let input = CreateVoteInput::new(None, &Event::PullRequest(setup_test_pr_event())); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); + } +} + +#[tokio::test] +async fn create_vote_removing_labels_errors_are_ignored() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + expect_store_vote(&mut db, false, || Ok(test_vote_id())); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + expect_vote_created_comment(&mut gh, &mut Sequence::new(), false); + for label in [VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL, VOTE_FAILED_LABEL] { + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(label)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + } + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![GITVOTE_LABEL, VOTE_OPEN_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is created successfully + let input = CreateVoteInput::new(None, &Event::Issue(setup_test_issue_event())); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_success() { + // Setup input + let event = setup_test_issue_event(); + let create_vote_input = CreateVoteInput::new(None, &Event::Issue(event)); + let cfg = CfgProfile { + duration: Duration::from_mins(5), + pass_threshold: 50.0, + + allowed_voters: Some(AllowedVoters::default()), + ..Default::default() + }; + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + let cfg_copy = cfg.clone(); + let create_vote_input_copy = create_vote_input.clone(); + db.expect_store_vote() + .withf(move |vote_comment_id, input, cfg| { + *vote_comment_id == COMMENT_ID && *input == create_vote_input_copy && *cfg == cfg_copy + }) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Ok(test_vote_id())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + let create_vote_input_copy = create_vote_input.clone(); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + let expected_body = tmpl::VoteCreated::new(&create_vote_input_copy, &cfg).render().unwrap(); + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body.as_str() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_remove_label() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(VOTE_CLOSED_LABEL), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_remove_label() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(VOTE_PASSED_LABEL), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_remove_label() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(VOTE_FAILED_LABEL), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![GITVOTE_LABEL, VOTE_OPEN_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run and check the vote is created successfully + let (_, cmds_rx) = async_channel::unbounded(); + let cmds_handler = CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx); + cmds_handler.create_vote(&create_vote_input).await.unwrap(); +} + +#[tokio::test] +async fn create_vote_vote_in_progress_on_opened_pr_keeps_check() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(true)))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + expect_comment(&mut gh, vote_in_progress_body(true)); + gh.expect_is_check_required().never(); + gh.expect_create_check_run().never(); + + // Run the command + let input = CreateVoteInput::new(None, &Event::PullRequest(setup_test_pr_event())); + setup_cmds_handler(db, gh).create_vote(&input).await.unwrap(); +} + +// Helpers. + +/// Expects storing a vote to fail once because the issue/pr has a vote open already. +fn expect_store_vote_lost_race(db: &mut MockDB, is_pull_request: bool) { + // Expect the open vote check to pass and storing the vote to lose the race + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + expect_store_vote(db, is_pull_request, || Err(VoteAlreadyOpenError.into())); + + // Expect no lookup of the vote, as the error says that nothing was stored + db.expect_has_vote_with_comment().never(); +} diff --git a/src/processor/tests/db_contracts.rs b/src/processor/tests/db_contracts.rs new file mode 100644 index 0000000..d9f0280 --- /dev/null +++ b/src/processor/tests/db_contracts.rs @@ -0,0 +1,1430 @@ +use std::{ + ops::RangeInclusive, + sync::{ + Arc, + atomic::{AtomicI64, Ordering}, + }, +}; + +use anyhow::format_err; +use mockall::{Sequence, predicate::eq}; +use time::{ext::NumericalDuration, format_description::well_known::Rfc3339}; +use tokio::{ + sync::{Barrier, oneshot}, + time::timeout, +}; +use uuid::Uuid; + +use crate::{ + db::{CLOSE_MAX_ATTEMPTS, DB}, + github::*, + testutil::*, +}; + +use super::*; + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_cancel_vote_db_failure_publishes_nothing() { + // Setup database with an open vote on a pull request that cannot be updated + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + is_pull_request: true, + ..Default::default() + }) + .await; + let vote = test_db.read_vote(vote_id).await; + test_db.fail_vote_operation("update").await; + + // Setup GitHub expectations: only the failure comment is posted, so no success check + // can exist while the vote is open + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + expect_command_failed_comment(&mut gh, || Ok(COMMENT_ID2)); + gh.expect_create_check_run().never(); + gh.expect_remove_label().never(); + + // Try cancelling the vote + let input = CancelVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + setup_real_db_cmds_handler(&test_db, gh).process_command(Command::CancelVote(input)).await; + + // Check the vote is still open and did not change + assert_eq!(test_db.read_vote(vote_id).await, vote); + assert!(db.has_vote_open(REPOFN, ISSUE_NUM).await.unwrap()); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_cancel_vote_failed_delete_is_retried_by_the_closer() { + // Setup database with an open vote that cannot be deleted + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db.seed_vote(SeedVote::default()).await; + test_db.fail_vote_operation("delete").await; + + // Setup GitHub expectations: the cancellation is published, and the failure to + // delete the vote does not ask for the command to be tried again + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + expect_comment(&mut gh, vote_cancelled_body(vote_id, false)); + expect_command_failed_comment_never(&mut gh); + expect_remove_open_label(&mut gh, || Ok(())); + + // Cancel the vote + let input = CancelVoteInput::new(&Event::IssueComment(setup_test_issue_comment_event())); + setup_real_db_cmds_handler(&test_db, gh).process_command(Command::CancelVote(input)).await; + + // Check the cancellation is published and the failure deleting the vote recorded + let vote = test_db.read_vote(vote_id).await.unwrap(); + assert_eq!(vote.close_attempts, 1); + assert!(vote.close_pending); + assert!(vote.closed); + assert_eq!(vote.cancelled_by.as_deref(), Some(USER)); + assert!(vote.close_comment_posted_at.is_some()); + assert!(vote.close_labels_updated_at.is_some()); + assert_eq!(db.get_vote(vote_id).await.unwrap(), None); + + // Check the retry publishes nothing again (the mock rejects any call), failing to + // delete the vote once more + test_db.expire_close_claims().await; + let votes_closer = setup_real_db_votes_closer(&test_db, MockGH::new()); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + assert_eq!(test_db.read_vote(vote_id).await.unwrap().close_attempts, 2); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_cancel_vote_new_vote_supersedes_pending_cancellation_state() { + // Setup database with an open vote on a pull request + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + is_pull_request: true, + ..Default::default() + }) + .await; + + // Setup GitHub expectations: the comment and the check run of the cancellation fail + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + expect_vote_cancelled_comment_fails(&mut gh, vote_id, true); + expect_command_failed_comment_never(&mut gh); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(cancelled_check_details()), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_remove_open_label(&mut gh, || Ok(())); + + // Cancel the vote, leaving its comment and check run pending + let input = CancelVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + setup_real_db_cmds_handler(&test_db, gh).process_command(Command::CancelVote(input)).await; + let vote = test_db.read_vote(vote_id).await.unwrap(); + assert_eq!(vote.close_check_run_created_at, None); + assert_eq!(vote.close_comment_posted_at, None); + + // Create a new vote while the cancellation is pending + let input = CreateVoteInput::new(None, &Event::PullRequest(setup_test_pr_event())); + let new_vote_id = db.store_vote(COMMENT_ID2, &input, &setup_test_vote().cfg).await.unwrap(); + + // Check the new vote supersedes the pending check run but not the comment + let vote = test_db.read_vote(vote_id).await.unwrap(); + assert!(vote.close_check_run_created_at.is_some()); + assert_eq!(vote.close_comment_posted_at, None); + + // Setup GitHub expectations of the retry: only the comment is posted, once it is + // known that the first attempt did not post it + let since = lookup_since(vote.closed_at.unwrap()); + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::CancellationComment, + vote_id, + since, + || Ok(false), + ); + expect_comment(&mut gh, vote_cancelled_body(vote_id, true)); + gh.expect_create_check_run().never(); + gh.expect_remove_label().never(); + + // Run the retry + test_db.expire_close_claims().await; + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + + // Check the cancelled vote is gone and the new vote is still open + assert_eq!(test_db.read_vote(vote_id).await, None); + let open_vote = db.get_open_vote(REPOFN, ISSUE_NUM).await.unwrap().unwrap(); + assert_eq!(open_vote.vote_id, new_vote_id); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_cancel_vote_publish_failure_is_retried_by_the_closer() { + // Setup cases: outcome of posting the comment and creating the check run the first + // time, as well as the number of times each one is retried + type CommentOutcome = fn() -> Result; + type CheckRunOutcome = fn() -> Result<()>; + let cases: [(CommentOutcome, CheckRunOutcome, usize, usize); 2] = [ + (|| Err(format_err!(ERROR)), || Ok(()), 1, 0), + (|| Ok(COMMENT_ID), || Err(format_err!(ERROR)), 0, 1), + ]; + + for (comment_result, check_run_result, comment_retries, check_run_retries) in cases { + // Setup database with an open vote on a pull request + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + is_pull_request: true, + ..Default::default() + }) + .await; + + // Setup GitHub expectations: the failure does not ask for the command to be + // tried again, as the vote is cancelled already + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + expect_comment_in_sequence( + &mut gh, + &mut Sequence::new(), + vote_cancelled_body(vote_id, true), + comment_result, + ); + expect_command_failed_comment_never(&mut gh); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(cancelled_check_details()), + ) + .times(1) + .returning(move |_, _, _, _, _| Box::pin(future::ready(check_run_result()))); + expect_remove_open_label(&mut gh, || Ok(())); + + // Cancel the vote + let input = CancelVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + setup_real_db_cmds_handler(&test_db, gh).process_command(Command::CancelVote(input)).await; + + // Check the vote is cancelled, with the steps done and the failure recorded + let vote = test_db.read_vote(vote_id).await.unwrap(); + assert_eq!(vote.close_attempts, 1); + assert!(vote.close_pending); + assert!(vote.closed); + assert_eq!(vote.cancelled_by.as_deref(), Some(USER)); + assert_eq!(vote.close_check_run_created_at.is_some(), check_run_retries == 0); + assert_eq!(vote.close_comment_posted_at.is_some(), comment_retries == 0); + assert!(vote.close_labels_updated_at.is_some()); + + // Check the vote is not open nor listed, and not retried before the backoff ends + assert_eq!(db.get_open_vote(REPOFN, ISSUE_NUM).await.unwrap(), None); + assert_eq!(db.get_vote(vote_id).await.unwrap(), None); + assert_eq!(db.list_votes(REPOFN).await.unwrap(), vec![]); + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); + + // Setup GitHub expectations of the retry: only the missing steps are run, and + // the comment is only posted once it is known that the first attempt did not + // post it + let expected_marker = tmpl::VoteMarker::CancellationComment.render(vote_id); + let since = lookup_since(vote.closed_at.unwrap()); + let mut gh = MockGH::new(); + gh.expect_has_comment_with_marker() + .withf(move |inst_id, owner, repo, issue_number, marker, lookup_since| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && marker == expected_marker + && since.contains(lookup_since) + }) + .times(comment_retries) + .returning(|_, _, _, _, _, _| Box::pin(future::ready(Ok(false)))); + let expected_body = vote_cancelled_body(vote_id, true); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body + }) + .times(comment_retries) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(cancelled_check_details()), + ) + .times(check_run_retries) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_remove_label().never(); + + // Run the retry and check the vote is deleted once the cancellation is published + test_db.expire_close_claims().await; + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + assert_eq!(test_db.read_vote(vote_id).await, None); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), None); + } +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_cancel_vote_publishes_once_closed_and_deletes_the_vote() { + // Setup database with an open vote on a pull request + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + is_pull_request: true, + ..Default::default() + }) + .await; + + // Setup GitHub expectations: the success check is created once the vote is closed + let check_db = Arc::new(test_db.db()); + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + expect_comment(&mut gh, vote_cancelled_body(vote_id, true)); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(cancelled_check_details()), + ) + .times(1) + .returning(move |_, _, _, _, _| { + let check_db = check_db.clone(); + Box::pin(async move { + assert!(!check_db.has_vote_open(REPOFN, ISSUE_NUM).await.unwrap()); + Ok(()) + }) + }); + expect_remove_open_label(&mut gh, || Ok(())); + + // Cancel the vote + let input = CancelVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + setup_real_db_cmds_handler(&test_db, gh).cancel_vote(&input).await.unwrap(); + + // Check the vote was deleted and nothing is left for the votes closer + assert_eq!(test_db.read_vote(vote_id).await, None); + assert!(!db.has_vote(REPOFN, ISSUE_NUM).await.unwrap()); + test_db.expire_close_claims().await; + let votes_closer = setup_real_db_votes_closer(&test_db, MockGH::new()); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_announcement_failure_is_retried() { + // Setup database with a finished vote + let started_at = OffsetDateTime::now_utc(); + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db.seed_vote(seed_finished_vote()).await; + + // Setup GitHub expectations: the announcement fails, just the first time, and the + // retry looks it up before creating it again + let since = lookup_since_closed_after(started_at); + let mut gh = MockGH::new(); + let mut seq = Sequence::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + expect_comment(&mut gh, vote_closed_body(vote_id)); + expect_announcement_discussion(&mut gh, &mut seq, vote_id, || Err(format_err!(ERROR))); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels(&mut gh, &mut seq, &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], || { + Ok(()) + }); + expect_announcement_lookup(&mut gh, vote_id, since, || Ok(false)); + expect_announcement_discussion(&mut gh, &mut seq, vote_id, || Ok(())); + + // Try closing the vote, checking the attempt failed with the rest done + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert_eq!(vote.close_attempts, 1); + assert!(vote.close_pending); + assert_eq!(vote.close_announcement_posted_at, None); + assert!(vote.close_comment_posted_at.is_some()); + assert!(vote.close_labels_updated_at.is_some()); + + // Run the retry (the mock rejects repeating any of the completed steps) + test_db.expire_close_claims().await; + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + + // Check nothing else is pending + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(!vote.close_pending); + assert!(vote.close_announcement_posted_at.is_some()); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_announcement_rejected_completes_the_close() { + // Setup database with a finished vote + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db.seed_vote(seed_finished_vote()).await; + + // Setup GitHub expectations: the announcement is rejected + let mut gh = MockGH::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + expect_comment(&mut gh, vote_closed_body(vote_id)); + expect_announcement_discussion(&mut gh, &mut Sequence::new(), vote_id, || { + Err(format_err!(DiscussionRejectedError(format_err!(ERROR)))) + }); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels( + &mut gh, + &mut Sequence::new(), + &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], + || Ok(()), + ); + + // Close the vote + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + + // Check the close is complete and there is nothing to retry + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert_eq!(vote.close_attempts, 0); + assert!(!vote.close_pending); + assert!(vote.close_announcement_posted_at.is_some()); + test_db.expire_close_claims().await; + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_cancellation_last_attempt_is_abandoned() { + // Setup database with a cancelled pull request vote on its last attempt to publish + // the cancellation, with all its steps pending + let started_at = OffsetDateTime::now_utc(); + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + close_attempts: CLOSE_MAX_ATTEMPTS - 1, + close_pending: true, + closed: true, + is_pull_request: true, + + cancelled_ago: Some("1 day"), + close_next_attempt_in: Some("-1 minute"), + ..Default::default() + }) + .await; + assert!(db.has_vote(REPOFN, ISSUE_NUM).await.unwrap()); + + // Setup GitHub expectations: the comment is not found, and posting it and the check + // run fail + let since = lookup_since_closed_after(started_at); + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::CancellationComment, + vote_id, + since, + || Ok(false), + ); + expect_vote_cancelled_comment_fails(&mut gh, vote_id, true); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(cancelled_check_details()), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_remove_open_label(&mut gh, || Ok(())); + + // Run the last attempt + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + + // Check the cancellation is abandoned, deleting the vote, so the pull request has + // no vote that owns its check and nothing else is retried + assert_eq!(test_db.read_vote(vote_id).await, None); + assert!(!db.has_vote(REPOFN, ISSUE_NUM).await.unwrap()); + test_db.expire_close_claims().await; + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_cancelled_while_closing_publishes_nothing() { + // Setup database with a finished vote + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db.seed_vote(seed_finished_vote()).await; + + // Setup GitHub expectations: nothing is published once the vote has been cancelled + let mut gh = MockGH::new(); + let (calculating_rx, release_tx) = expect_held_vote_results(&mut gh); + gh.expect_post_comment().never(); + gh.expect_create_discussion().never(); + gh.expect_remove_label().never(); + gh.expect_add_labels().never(); + + // Start closing the vote and wait until its results are being calculated + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + let closing = tokio::spawn(async move { votes_closer.close_finished_vote().await }); + calculating_rx.await.unwrap(); + + // Cancel the vote before the results are ready + let cancelled_vote = db.cancel_vote(REPOFN, ISSUE_NUM, USER).await.unwrap().unwrap(); + assert_eq!(cancelled_vote.vote_id, vote_id); + + // Check the closer finishes leaving the cancelled vote as it was + release_tx.send(()).unwrap(); + assert_eq!(closing.await.unwrap().unwrap(), Some(())); + assert_eq!(test_db.read_vote(vote_id).await, Some(cancelled_vote)); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_cancelled_while_failing_keeps_the_cancellation_claim() { + // Setup database with a finished vote + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db.seed_vote(seed_finished_vote()).await; + + // Setup GitHub expectations: the results calculation fails once released + let mut gh = MockGH::new(); + let (calculating_tx, calculating_rx) = oneshot::channel(); + let (release_tx, release_rx) = oneshot::channel::<()>(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .return_once(move |_, _, _, _| { + calculating_tx.send(()).unwrap(); + Box::pin(async move { + release_rx.await.unwrap(); + Err(format_err!(ERROR)) + }) + }); + gh.expect_post_comment().never(); + + // Start closing the vote and wait until its results are being calculated + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + let closing = tokio::spawn(async move { votes_closer.close_finished_vote().await }); + calculating_rx.await.unwrap(); + + // Cancel the vote before the results calculation fails + let cancelled_vote = db.cancel_vote(REPOFN, ISSUE_NUM, USER).await.unwrap().unwrap(); + assert_eq!(cancelled_vote.vote_id, vote_id); + + // Check the failure of the closer leaves the claim of the cancellation as it was + release_tx.send(()).unwrap(); + assert_eq!(closing.await.unwrap().unwrap(), Some(())); + assert_eq!(test_db.read_vote(vote_id).await, Some(cancelled_vote)); + assert_eq!(db.claim_vote_to_close().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_closed_with_unknown_head_completes() { + // Setup database with a vote closed without knowing the head of its pull request + let started_at = OffsetDateTime::now_utc(); + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + close_pending: true, + closed: true, + is_pull_request: true, + + results: Some(serde_json::to_value(setup_test_vote_results()).unwrap()), + ..seed_finished_vote() + }) + .await; + + // Setup GitHub expectations: the results published by a previous attempt are looked + // up, and the check run is created on the current head + let since = lookup_since_closed_after(started_at); + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + vote_id, + since.clone(), + || Ok(false), + ); + expect_announcement_lookup(&mut gh, vote_id, since, || Ok(false)); + expect_vote_closed_published(&mut gh, vote_id); + gh.expect_get_pr_head_sha().never(); + gh.expect_create_commit_check_run().never(); + expect_check_run(&mut gh, passed_check_details()); + + // Run the pending steps + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + + // Check the close is complete + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(!vote.close_pending); + assert!(vote.close_check_run_created_at.is_some()); + assert_eq!(vote.close_head_sha, None); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_comment_deleted_closes_vote_without_results() { + // Setup database with a finished vote + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db.seed_vote(seed_finished_vote()).await; + + // Setup GitHub expectations: the vote comment is gone, so only labels are updated + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(CommentNotFoundError.into())))); + gh.expect_post_comment().never(); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels(&mut gh, &mut Sequence::new(), &[VOTE_CLOSED_LABEL], || Ok(())); + + // Close the vote + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + + // Check it can be read back, closed and without results + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(!vote.close_pending); + assert!(vote.closed); + assert!(vote.closed_at.is_some()); + assert_eq!(vote.results, None); + assert_eq!(db.list_votes(REPOFN).await.unwrap(), vec![vote]); + + // Check there is nothing else to do + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_comment_deleted_on_pr_reports_failure_check() { + // Setup database with a finished vote on a pull request + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + is_pull_request: true, + ..seed_finished_vote() + }) + .await; + + // Setup GitHub expectations: the vote comment is gone, so a failure check is created + // on the head of the pull request + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(CommentNotFoundError.into())))); + expect_pr_head_sha(&mut gh, HEAD_SHA); + expect_commit_check_run( + &mut gh, + HEAD_SHA, + CheckDetails { + status: "completed".to_string(), + summary: CHECK_SUMMARY_NO_RESULTS.to_string(), + + conclusion: Some("failure".to_string()), + }, + ); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels(&mut gh, &mut Sequence::new(), &[VOTE_CLOSED_LABEL], || Ok(())); + + // Close the vote + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + + // Check it is closed without results + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(!vote.close_pending); + assert!(vote.closed); + assert_eq!(vote.close_head_sha.as_deref(), Some(HEAD_SHA)); + assert_eq!(vote.results, None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_concurrent_closers_close_the_vote_once() { + // Setup database with a finished vote + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db.seed_vote(seed_finished_vote()).await; + + // Setup GitHub expectations + let mut gh = MockGH::new(); + let (calculating_rx, release_tx) = expect_held_vote_results(&mut gh); + expect_vote_closed_published(&mut gh, vote_id); + + // Start closing the vote and wait until its results are being calculated + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + let closing = tokio::spawn(async move { votes_closer.close_finished_vote().await }); + calculating_rx.await.unwrap(); + + // Check another closer does not get the vote (its mock rejects all the calls) + let other_votes_closer = setup_real_db_votes_closer(&test_db, MockGH::new()); + assert_eq!(other_votes_closer.close_finished_vote().await.unwrap(), None); + + // Check the first closer completes the close + release_tx.send(()).unwrap(); + assert_eq!(closing.await.unwrap().unwrap(), Some(())); + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(!vote.close_pending); + assert!(vote.closed); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_github_error_keeps_vote_open_until_retried() { + // Setup database with a finished vote + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db.seed_vote(seed_finished_vote()).await; + + // Setup GitHub expectations: the results can only be calculated the second time + let mut gh = MockGH::new(); + expect_vote_results_fail_once(&mut gh); + expect_vote_closed_published(&mut gh, vote_id); + + // Try closing the vote, checking the failure leaves it open and recorded + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert_eq!(vote.close_attempts, 1); + assert!(!vote.closed); + assert_eq!(vote.closed_at, None); + assert_eq!(vote.results, None); + + // Check it is not retried until the delay is over + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), None); + + // Check the retry closes it, storing the results calculated + test_db.expire_close_claims().await; + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(!vote.close_pending); + assert!(vote.closed); + assert!(vote.closed_at.is_some()); + assert_eq!(vote.results, Some(setup_test_vote_results())); + + // Check there is nothing else to do + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_moved_end_ignores_later_reactions() { + // Setup database with an open vote that is closed early + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + ends_in: "10 days", + ..seed_finished_vote() + }) + .await; + db.update_vote_ends_at(vote_id).await.unwrap(); + + // Setup GitHub expectations: the reaction in favor is the only one in time, as the + // other one was added after the new end of the vote and before the original one + let late_reaction = Reaction { + created_at: (OffsetDateTime::now_utc() + 1.hours()).format(&Rfc3339).unwrap(), + ..in_favor_reaction(USER2) + }; + let mut gh = MockGH::new(); + let reactions = vec![in_favor_reaction(USER1), late_reaction]; + expect_vote_results(&mut gh, COMMENT_ID, Ok(reactions)); + expect_vote_closed_published(&mut gh, vote_id); + + // Close the vote + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + + // Check the results only include the reaction in time + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert_eq!(vote.results, Some(setup_test_vote_results())); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_newer_vote_cancelled_while_closing_publishes_no_state() { + // Setup database with a finished vote on a pull request + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + is_pull_request: true, + ..seed_finished_vote() + }) + .await; + + // Setup GitHub expectations: the results comment is held until released, and no + // check run or labels are published after that + let mut gh = MockGH::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + expect_pr_head_sha(&mut gh, HEAD_SHA); + let (posting_tx, posting_rx) = oneshot::channel(); + let (release_tx, release_rx) = oneshot::channel::<()>(); + gh.expect_post_comment() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(vote_closed_body(vote_id)), + ) + .times(1) + .return_once(move |_, _, _, _, _| { + posting_tx.send(()).unwrap(); + Box::pin(async move { + release_rx.await.unwrap(); + Ok(COMMENT_ID) + }) + }); + expect_announcement_discussion(&mut gh, &mut Sequence::new(), vote_id, || Ok(())); + gh.expect_create_commit_check_run().never(); + gh.expect_create_check_run().never(); + gh.expect_remove_label().never(); + gh.expect_add_labels().never(); + + // Start closing the vote and wait until its results comment is being posted + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + let closing = tokio::spawn(async move { votes_closer.close_finished_vote().await }); + posting_rx.await.unwrap(); + + // Create and cancel a newer vote, deleting it as if its cancellation was published + let input = CreateVoteInput::new(None, &Event::PullRequest(setup_test_pr_event())); + let newer_vote_id = db.store_vote(COMMENT_ID2, &input, &setup_test_vote().cfg).await.unwrap(); + db.cancel_vote(REPOFN, ISSUE_NUM, USER).await.unwrap().unwrap(); + db.complete_vote_cancellation(newer_vote_id).await.unwrap(); + assert_eq!(test_db.read_vote(newer_vote_id).await, None); + + // Check the close completes without publishing the state superseded + release_tx.send(()).unwrap(); + assert_eq!(closing.await.unwrap().unwrap(), Some(())); + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(!vote.close_pending); + assert!(vote.close_announcement_posted_at.is_some()); + assert!(vote.close_check_run_created_at.is_some()); + assert!(vote.close_comment_posted_at.is_some()); + assert!(vote.close_labels_updated_at.is_some()); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_not_found_error_keeps_vote_open() { + // Setup database with a finished vote + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db.seed_vote(seed_finished_vote()).await; + + // Setup GitHub expectations: a 404 that does not say the comment was deleted, as + // the one returned when the installation token cannot be created + let not_found_error = setup_test_not_found_error().await; + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .return_once(move |_, _, _, _| Box::pin(future::ready(Err(not_found_error)))); + gh.expect_post_comment().never(); + + // Try closing the vote + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + + // Check the vote stays open with the failure recorded, so that it is retried + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert_eq!(vote.close_attempts, 1); + assert!(!vote.closed); + assert_eq!(vote.closed_at, None); + assert_eq!(vote.results, None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_open_vote_keeps_retrying_after_the_attempts_limit() { + // Setup database with a finished vote that failed all the attempts of the limit + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + close_attempts: CLOSE_MAX_ATTEMPTS, + ..seed_finished_vote() + }) + .await; + + // Setup GitHub expectations: the results can only be calculated the second time + let mut gh = MockGH::new(); + expect_vote_results_fail_once(&mut gh); + expect_vote_closed_published(&mut gh, vote_id); + + // Try closing the vote, checking the failure leaves it open with a retry scheduled + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert_eq!(vote.close_attempts, CLOSE_MAX_ATTEMPTS + 1); + assert!(!vote.closed); + assert_eq!(vote.results, None); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), None); + + // Check the retry closes it, restarting its attempts, and publishes the results + test_db.expire_close_claims().await; + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert_eq!(vote.close_attempts, 0); + assert!(!vote.close_pending); + assert!(vote.closed); + assert_eq!(vote.results, Some(setup_test_vote_results())); + + // Check there is nothing else to do + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_retry_after_newer_vote_cancelled_publishes_no_state() { + // Setup database with a finished vote on a pull request + let started_at = OffsetDateTime::now_utc(); + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + is_pull_request: true, + ..seed_finished_vote() + }) + .await; + + // Setup GitHub expectations: all the steps fail the first time, the retry looks up + // the results before posting them, and no check run or labels can be published by it + let since = lookup_since_closed_after(started_at); + let mut gh = MockGH::new(); + let mut seq = Sequence::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + expect_pr_head_sha(&mut gh, HEAD_SHA); + expect_close_steps_fail_once(&mut gh, &mut seq, vote_id); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + vote_id, + since.clone(), + || Ok(false), + ); + expect_announcement_lookup(&mut gh, vote_id, since, || Ok(false)); + expect_comment_in_sequence(&mut gh, &mut seq, vote_closed_body(vote_id), || Ok(COMMENT_ID)); + expect_announcement_discussion(&mut gh, &mut seq, vote_id, || Ok(())); + + // Try closing the vote, which fails + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + + // Create and cancel a newer vote, deleting it as if its cancellation was published + let input = CreateVoteInput::new(None, &Event::PullRequest(setup_test_pr_event())); + let newer_vote_id = db.store_vote(COMMENT_ID2, &input, &setup_test_vote().cfg).await.unwrap(); + db.cancel_vote(REPOFN, ISSUE_NUM, USER).await.unwrap().unwrap(); + db.complete_vote_cancellation(newer_vote_id).await.unwrap(); + assert_eq!(test_db.read_vote(newer_vote_id).await, None); + + // Run the retry once the newer vote is gone + test_db.expire_close_claims().await; + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + + // Check the close is complete, with the steps superseded recorded as done + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(!vote.close_pending); + assert!(vote.close_announcement_posted_at.is_some()); + assert!(vote.close_check_run_created_at.is_some()); + assert!(vote.close_comment_posted_at.is_some()); + assert!(vote.close_labels_updated_at.is_some()); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_retry_only_runs_missing_steps() { + // Setup database with a finished vote on a pull request + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + is_pull_request: true, + ..seed_finished_vote() + }) + .await; + + // Setup GitHub expectations: only the check run fails, and just the first time. The + // head is read only once, so the retry cannot land on a commit pushed since then + let mut seq = Sequence::new(); + let mut gh = MockGH::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + expect_pr_head_sha(&mut gh, HEAD_SHA); + expect_vote_closed_published(&mut gh, vote_id); + gh.expect_create_check_run().never(); + gh.expect_create_commit_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(HEAD_SHA), + eq(passed_check_details()), + ) + .times(1) + .in_sequence(&mut seq) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_create_commit_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(HEAD_SHA), + eq(passed_check_details()), + ) + .times(1) + .in_sequence(&mut seq) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Try closing the vote, checking what was done is recorded + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert_eq!(vote.close_attempts, 1); + assert!(vote.close_pending); + assert!(vote.closed); + assert!(vote.close_announcement_posted_at.is_some()); + assert_eq!(vote.close_check_run_created_at, None); + assert!(vote.close_comment_posted_at.is_some()); + assert_eq!(vote.close_head_sha.as_deref(), Some(HEAD_SHA)); + assert!(vote.close_labels_updated_at.is_some()); + assert_eq!(vote.results, Some(setup_test_vote_results())); + + // Run the retry (the mock rejects repeating any of the completed steps) + test_db.expire_close_claims().await; + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + + // Check nothing else is pending + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(!vote.close_pending); + assert!(vote.close_check_run_created_at.is_some()); + assert!(vote.close_labels_updated_at.is_some()); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_retry_with_closed_newer_vote_publishes_no_state() { + run_close_retry_with_newer_vote(SeedVote { + closed: true, + created_ago: "1 minute", + is_pull_request: true, + ..Default::default() + }) + .await; +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_retry_with_open_newer_vote_publishes_no_state() { + run_close_retry_with_newer_vote(SeedVote { + created_ago: "1 minute", + is_pull_request: true, + ..Default::default() + }) + .await; +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_retry_without_newer_vote_publishes_state() { + // Setup database with a finished vote on a pull request and an older one + let started_at = OffsetDateTime::now_utc(); + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + is_pull_request: true, + ..seed_finished_vote() + }) + .await; + test_db + .seed_vote(SeedVote { + closed: true, + created_ago: "3 days", + is_pull_request: true, + ..Default::default() + }) + .await; + + // Setup GitHub expectations: all the steps fail the first time, and the retry looks + // up the results before posting them + let since = lookup_since_closed_after(started_at); + let mut gh = MockGH::new(); + let mut seq = Sequence::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + expect_pr_head_sha(&mut gh, HEAD_SHA); + expect_close_steps_fail_once(&mut gh, &mut seq, vote_id); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + vote_id, + since.clone(), + || Ok(false), + ); + expect_announcement_lookup(&mut gh, vote_id, since, || Ok(false)); + expect_comment_in_sequence(&mut gh, &mut seq, vote_closed_body(vote_id), || Ok(COMMENT_ID)); + expect_announcement_discussion(&mut gh, &mut seq, vote_id, || Ok(())); + gh.expect_create_commit_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(HEAD_SHA), + eq(passed_check_details()), + ) + .times(1) + .in_sequence(&mut seq) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels(&mut gh, &mut seq, &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], || { + Ok(()) + }); + + // Try closing the vote, which fails + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + + // Check the retry publishes the state of the vote, the latest one in the issue/pr + test_db.expire_close_claims().await; + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(!vote.close_pending); + assert!(vote.close_check_run_created_at.is_some()); + assert!(vote.close_labels_updated_at.is_some()); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_close_finished_vote_takeover_records_steps_found_published() { + // Setup database with a vote closed by a worker that died holding its claim, which + // expired without a failure being recorded + let started_at = OffsetDateTime::now_utc(); + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + close_pending: true, + closed: true, + + close_next_attempt_in: Some("-1 minute"), + results: Some(serde_json::to_value(setup_test_vote_results()).unwrap()), + ..seed_finished_vote() + }) + .await; + + // Setup GitHub expectations: the comment and the announcement the dead worker posted + // are found, so they are not posted again + let since = lookup_since_closed_after(started_at); + let mut gh = MockGH::new(); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + vote_id, + since.clone(), + || Ok(true), + ); + expect_announcement_lookup(&mut gh, vote_id, since, || Ok(true)); + gh.expect_post_comment().never(); + gh.expect_create_discussion().never(); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels( + &mut gh, + &mut Sequence::new(), + &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], + || Ok(()), + ); + + // Take over the vote + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + + // Check the steps found are recorded and the close is complete + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert_eq!(vote.close_attempts, 0); + assert!(!vote.close_pending); + assert!(vote.close_announcement_posted_at.is_some()); + assert!(vote.close_comment_posted_at.is_some()); + assert!(vote.close_labels_updated_at.is_some()); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), None); +} + +#[tokio::test] +#[ignore = "needs a migrated PostgreSQL database in GITVOTE_TEST_DB_URL"] +async fn db_contracts_commands_handlers_concurrent_create_vote_stores_one_vote() { + // Setup database + let test_db = TestDB::new().await; + let db = test_db.db(); + + // Setup GitHub expectations: both vote comments are posted before storing the votes + let barrier = Arc::new(Barrier::new(2)); + let deleted_comment_id = Arc::new(AtomicI64::new(0)); + let deleted_comment_id_copy = deleted_comment_id.clone(); + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(2) + .returning(|_, _, _| Box::pin(future::ready(Ok(Some(get_test_valid_config()))))); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(2) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(true)))); + for comment_id in [COMMENT_ID, COMMENT_ID + 1] { + let barrier = barrier.clone(); + let expected_body = vote_created_body(false); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body + }) + .times(1) + .return_once(move |_, _, _, _, _| { + Box::pin(async move { + timeout(WORKER_TIMEOUT, barrier.wait()).await.unwrap(); + Ok(comment_id) + }) + }); + } + gh.expect_delete_comment() + .withf(|inst_id, owner, repo, _| *inst_id == INST_ID && owner == ORG && repo == REPO) + .times(1) + .returning(move |_, _, _, comment_id| { + deleted_comment_id_copy.store(comment_id, Ordering::SeqCst); + Box::pin(future::ready(Ok(()))) + }); + expect_vote_in_progress_comment(&mut gh, &mut Sequence::new(), false); + expect_vote_open_labels(&mut gh); + + // Queue the same command twice + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let event = Event::Issue(setup_test_issue_event()); + for _ in 0..2 { + cmds_tx.send(Command::CreateVote(CreateVoteInput::new(None, &event))).await.unwrap(); + } + + // Run all the commands handlers until the commands have been processed + let gh: DynGH = Arc::new(gh); + let cancel_token = CancellationToken::new(); + let handles = (0..COMMANDS_HANDLERS_WORKERS).map(|_| { + let db: DynDB = Arc::new(test_db.db()); + CommandsHandler::new(db, gh.clone(), cmds_rx.clone()).run(cancel_token.clone()) + }); + let handles: Vec<_> = handles.collect(); + cancel_token.cancel(); + assert!(future::join_all(handles).await.iter().all(Result::is_ok)); + + // Check the vote stored tracks the comment that was not deleted + let votes = db.list_votes(REPOFN).await.unwrap(); + assert_eq!(votes.len(), 1); + let vote_comment_ids = [ + votes[0].vote_comment_id, + deleted_comment_id.load(Ordering::SeqCst), + ]; + assert!(vote_comment_ids.contains(&COMMENT_ID)); + assert!(vote_comment_ids.contains(&(COMMENT_ID + 1))); + + // Check the vote can still be read and cancelled + let vote = db.get_open_vote(REPOFN, ISSUE_NUM).await.unwrap().unwrap(); + assert_eq!(vote.vote_id, votes[0].vote_id); + let cancelled_vote = db.cancel_vote(REPOFN, ISSUE_NUM, USER).await.unwrap().unwrap(); + assert_eq!(cancelled_vote.vote_id, vote.vote_id); +} + +// Helpers. + +/// Expects all the closing steps of the vote provided, which passed, to be tried and +/// fail once, in the sequence given. Removing the vote open label is best effort, so it +/// succeeds. +fn expect_close_steps_fail_once(gh: &mut MockGH, seq: &mut Sequence, vote_id: Uuid) { + expect_comment_in_sequence(gh, seq, vote_closed_body(vote_id), || Err(format_err!(ERROR))); + expect_announcement_discussion(gh, seq, vote_id, || Err(format_err!(ERROR))); + gh.expect_create_commit_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(HEAD_SHA), + eq(passed_check_details()), + ) + .times(1) + .in_sequence(seq) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_remove_open_label(gh, || Ok(())); + expect_close_labels(gh, seq, &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], || { + Err(format_err!(ERROR)) + }); +} + +/// Expects the GitHub calls needed to calculate the results of a vote whose allowed +/// voters only include `USER1`, holding them until released. It returns the receiver +/// notified when the calculation starts and the sender to release it. +fn expect_held_vote_results(gh: &mut MockGH) -> (oneshot::Receiver<()>, oneshot::Sender<()>) { + // Expect the reactions to be requested once, holding them until released + let (calculating_tx, calculating_rx) = oneshot::channel(); + let (release_tx, release_rx) = oneshot::channel::<()>(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .return_once(move |_, _, _, _| { + calculating_tx.send(()).unwrap(); + Box::pin(async move { + release_rx.await.unwrap(); + Ok(vec![in_favor_reaction(USER1)]) + }) + }); + + // Expect the allowed voters to be looked up + expect_allowed_voters(gh); + + (calculating_rx, release_tx) +} + +/// Expects getting the reactions of the test vote comment to fail once, and the results +/// of the vote, whose allowed voters only include `USER1`, to be calculated by the next +/// attempt. +fn expect_vote_results_fail_once(gh: &mut MockGH) { + // Expect getting the reactions to fail the first time only + let mut seq = Sequence::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .in_sequence(&mut seq) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .in_sequence(&mut seq) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(vec![in_favor_reaction(USER1)])))); + + // Expect the allowed voters to be looked up by the attempt that gets the reactions + expect_allowed_voters(gh); +} + +/// Returns the time from which the comments and discussions published by a previous +/// attempt to close or cancel a vote closed at the time provided are looked up. +fn lookup_since(closed_at: OffsetDateTime) -> RangeInclusive { + let since = closed_at - MARKER_LOOKUP_MARGIN; + since..=since +} + +/// Returns the range of times from which the comments and discussions published by a +/// previous attempt are looked up, for a vote closed in the test database during a test +/// that started at the time provided. +fn lookup_since_closed_after(started_at: OffsetDateTime) -> RangeInclusive { + let since = started_at - MARKER_LOOKUP_MARGIN; + (since - 1.seconds())..=(since + 1.minutes()) +} + +/// Runs the retry of a close that failed on all its steps once the vote provided, newer +/// than the one being closed, exists. Only the results must be published by the retry. +async fn run_close_retry_with_newer_vote(newer_vote: SeedVote) { + // Setup database with a finished vote on a pull request + let started_at = OffsetDateTime::now_utc(); + let test_db = TestDB::new().await; + let db = test_db.db(); + let vote_id = test_db + .seed_vote(SeedVote { + is_pull_request: true, + ..seed_finished_vote() + }) + .await; + + // Setup GitHub expectations: all the steps fail the first time, the retry looks up + // the results before posting them, and no check run or labels can be published by it + let since = lookup_since_closed_after(started_at); + let mut gh = MockGH::new(); + let mut seq = Sequence::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + expect_pr_head_sha(&mut gh, HEAD_SHA); + expect_close_steps_fail_once(&mut gh, &mut seq, vote_id); + expect_comment_lookup( + &mut gh, + tmpl::VoteMarker::ResultsComment, + vote_id, + since.clone(), + || Ok(false), + ); + expect_announcement_lookup(&mut gh, vote_id, since, || Ok(false)); + expect_comment_in_sequence(&mut gh, &mut seq, vote_closed_body(vote_id), || Ok(COMMENT_ID)); + expect_announcement_discussion(&mut gh, &mut seq, vote_id, || Ok(())); + + // Try closing the vote, which fails + let votes_closer = setup_real_db_votes_closer(&test_db, gh); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(vote.close_pending); + assert_eq!(vote.close_check_run_created_at, None); + assert_eq!(vote.close_labels_updated_at, None); + + // Run the retry with the newer vote in place + let newer_vote_is_closed = newer_vote.closed; + let newer_vote_id = test_db.seed_vote(newer_vote).await; + test_db.expire_close_claims().await; + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), Some(())); + + // Check the close is complete, with the steps skipped recorded as done + let vote = db.get_vote(vote_id).await.unwrap().unwrap(); + assert!(!vote.close_pending); + assert!(vote.close_announcement_posted_at.is_some()); + assert!(vote.close_check_run_created_at.is_some()); + assert!(vote.close_comment_posted_at.is_some()); + assert!(vote.close_labels_updated_at.is_some()); + assert_eq!(votes_closer.close_finished_vote().await.unwrap(), None); + + // Check the newer vote was not affected + let newer_vote = db.get_vote(newer_vote_id).await.unwrap().unwrap(); + assert_eq!(newer_vote.closed, newer_vote_is_closed); +} + +/// Returns a seed vote ready to be closed, using the configuration of the test vote. +fn seed_finished_vote() -> SeedVote { + SeedVote { + cfg: serde_json::to_value(setup_test_vote().cfg).unwrap(), + ends_in: "-1 minute", + ..Default::default() + } +} + +/// Sets up a commands handler using the test database and the GitHub mock provided. +fn setup_real_db_cmds_handler(test_db: &TestDB, gh: MockGH) -> CommandsHandler { + let (_, cmds_rx) = async_channel::unbounded(); + CommandsHandler::new(Arc::new(test_db.db()), Arc::new(gh), cmds_rx) +} + +/// Sets up a votes closer using the test database and the GitHub mock provided. +fn setup_real_db_votes_closer(test_db: &TestDB, gh: MockGH) -> VotesCloser { + VotesCloser::new(Arc::new(test_db.db()), Arc::new(gh)) +} diff --git a/src/processor/tests/helpers.rs b/src/processor/tests/helpers.rs new file mode 100644 index 0000000..76761bd --- /dev/null +++ b/src/processor/tests/helpers.rs @@ -0,0 +1,528 @@ +use std::{ops::RangeInclusive, sync::Arc}; + +use anyhow::format_err; +use mockall::{Sequence, predicate::eq}; +use time::{ext::NumericalDuration, format_description::well_known::Rfc3339}; +use uuid::Uuid; + +use crate::{ + cfg_repo::AllowedVoters, + db::MockDB, + github::*, + results::{REACTION_IN_FAVOR, Vote}, + testutil::*, +}; + +use super::*; + +/// Returns the details of the check run of a cancelled vote. +pub(super) fn cancelled_check_details() -> CheckDetails { + CheckDetails { + status: "completed".to_string(), + summary: "Vote cancelled".to_string(), + + conclusion: Some("success".to_string()), + } +} + +/// Expects the allowed voters of the test vote to be looked up once, returning only +/// `USER1`. +pub(super) fn expect_allowed_voters(gh: &mut MockGH) { + gh.expect_get_allowed_voters() + .withf(|inst_id, cfg, owner, repo, org| { + *inst_id == INST_ID + && *cfg == setup_test_vote().cfg + && owner == ORG + && repo == REPO + && *org == Some(ORG.to_string()).as_ref() + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(vec![USER1.to_string()])))); +} + +/// Expects the results announcement of the vote provided to be created once in the test +/// discussions category, in the sequence given, with the result provided. +pub(super) fn expect_announcement_discussion( + gh: &mut MockGH, + seq: &mut Sequence, + vote_id: Uuid, + result: fn() -> Result<()>, +) { + let expected_marker = tmpl::VoteMarker::ResultsAnnouncement.render(vote_id); + gh.expect_create_discussion() + .withf(move |inst_id, owner, repo, category, _, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && category == DISCUSSIONS_CATEGORY + && body.contains(&expected_marker) + }) + .times(1) + .in_sequence(seq) + .returning(move |_, _, _, _, _, _| Box::pin(future::ready(result()))); +} + +/// Expects a lookup of the results announcement of the vote provided in the test +/// discussions category once, from a time in the range given, with the result provided. +pub(super) fn expect_announcement_lookup( + gh: &mut MockGH, + vote_id: Uuid, + since: RangeInclusive, + result: fn() -> Result, +) { + let expected_marker = tmpl::VoteMarker::ResultsAnnouncement.render(vote_id); + gh.expect_has_discussion_with_marker() + .withf(move |inst_id, owner, repo, category, marker, lookup_since| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && category == DISCUSSIONS_CATEGORY + && marker == expected_marker + && since.contains(lookup_since) + }) + .times(1) + .returning(move |_, _, _, _, _, _| Box::pin(future::ready(result()))); +} + +/// Expects the open vote of the test issue to be cancelled once by the test user, +/// returning the cancelled vote provided (none when there is no open vote). +pub(super) fn expect_cancel_vote(db: &mut MockDB, vote: Option) { + db.expect_cancel_vote() + .withf(|repository_full_name, issue_number, cancelled_by| { + repository_full_name == REPOFN && *issue_number == ISSUE_NUM && cancelled_by == USER + }) + .times(1) + .return_once(move |_, _, _| Box::pin(future::ready(Ok(vote)))); +} + +/// Expects the vote provided to be claimed once, until the end of the test claim, as +/// claiming a vote sets it. +pub(super) fn expect_claim_vote(db: &mut MockDB, mut vote: Vote) { + vote.close_next_attempt_at = Some(test_claimed_until()); + db.expect_claim_vote_to_close() + .times(1) + .return_once(move || Box::pin(future::ready(Ok(Some(vote))))); +} + +/// Expects a failed attempt to close the test vote to be recorded once with the test +/// claim, with the outcome provided. +pub(super) fn expect_close_failure(db: &mut MockDB, outcome: VoteCloseFailureOutcome) { + db.expect_record_vote_close_failure() + .with(eq(test_vote_id()), eq(test_claimed_until())) + .times(1) + .returning(move |_, _| Box::pin(future::ready(Ok(outcome)))); +} + +/// Expects the labels provided to be added to the test issue once, closing its vote, in +/// the sequence given, with the result provided. +pub(super) fn expect_close_labels( + gh: &mut MockGH, + seq: &mut Sequence, + expected_labels: &'static [&'static str], + result: fn() -> Result<()>, +) { + gh.expect_add_labels() + .withf(move |inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == expected_labels + }) + .times(1) + .in_sequence(seq) + .returning(move |_, _, _, _, _| Box::pin(future::ready(result()))); +} + +/// Expects each of the closing steps provided to be recorded once for the test vote. +pub(super) fn expect_close_steps(db: &mut MockDB, steps: &[VoteCloseStep]) { + for step in steps { + db.expect_record_vote_close_step() + .with(eq(test_vote_id()), eq(*step)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(())))); + } +} + +/// Expects the command failed comment to be posted once, with the result provided. +pub(super) fn expect_command_failed_comment(gh: &mut MockGH, result: fn() -> Result) { + let expected_body = tmpl::CommandFailed {}.render().unwrap(); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body + }) + .times(1) + .returning(move |_, _, _, _, _| Box::pin(future::ready(result()))); +} + +/// Expects the command failed comment never to be posted. +pub(super) fn expect_command_failed_comment_never(gh: &mut MockGH) { + let expected_body = tmpl::CommandFailed {}.render().unwrap(); + gh.expect_post_comment().withf(move |_, _, _, _, body| body == expected_body).never(); +} + +/// Expects a comment with the body provided to be posted once. +pub(super) fn expect_comment(gh: &mut MockGH, expected_body: String) { + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); +} + +/// Expects a comment with the body provided to be posted once, in the sequence given, +/// with the result provided. +pub(super) fn expect_comment_in_sequence( + gh: &mut MockGH, + seq: &mut Sequence, + expected_body: String, + result: fn() -> Result, +) { + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body + }) + .times(1) + .in_sequence(seq) + .returning(move |_, _, _, _, _| Box::pin(future::ready(result()))); +} + +/// Expects a lookup of the comment with the marker provided of the vote given on the +/// test issue once, from a time in the range provided, with the result given. +pub(super) fn expect_comment_lookup( + gh: &mut MockGH, + marker: tmpl::VoteMarker, + vote_id: Uuid, + since: RangeInclusive, + result: fn() -> Result, +) { + let expected_marker = marker.render(vote_id); + gh.expect_has_comment_with_marker() + .withf(move |inst_id, owner, repo, issue_number, marker, lookup_since| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && marker == expected_marker + && since.contains(lookup_since) + }) + .times(1) + .returning(move |_, _, _, _, _, _| Box::pin(future::ready(result()))); +} + +/// Expects a check run to be created once on the commit provided, with the details +/// given. +pub(super) fn expect_commit_check_run(gh: &mut MockGH, head_sha: &'static str, check_details: CheckDetails) { + gh.expect_create_commit_check_run() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(head_sha), eq(check_details)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); +} + +/// Expects the cancellation of the test vote to be completed once. +pub(super) fn expect_complete_vote_cancellation(db: &mut MockDB) { + db.expect_complete_vote_cancellation() + .with(eq(test_vote_id())) + .times(1) + .returning(|_| Box::pin(future::ready(Ok(())))); +} + +/// Expects the close of the test vote to be marked as complete once. +pub(super) fn expect_complete_vote_close(db: &mut MockDB) { + db.expect_complete_vote_close() + .with(eq(test_vote_id())) + .times(1) + .returning(|_| Box::pin(future::ready(Ok(())))); +} + +/// Expects a lookup of the vote referencing the test comment once, with the result +/// given. +pub(super) fn expect_has_vote_with_comment(db: &mut MockDB, result: Result) { + db.expect_has_vote_with_comment() + .with(eq(COMMENT_ID)) + .times(1) + .return_once(move |_| Box::pin(future::ready(result))); +} + +/// Expects no lookups of what a previous attempt to close or cancel a vote published. +pub(super) fn expect_no_marker_lookups(gh: &mut MockGH) { + gh.expect_has_comment_with_marker().never(); + gh.expect_has_discussion_with_marker().never(); +} + +/// Expects the head of the test pull request to be read once, returning the one +/// provided. +pub(super) fn expect_pr_head_sha(gh: &mut MockGH, head_sha: &'static str) { + gh.expect_get_pr_head_sha() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM)) + .times(1) + .returning(move |_, _, _, _| Box::pin(future::ready(Ok(head_sha.to_string())))); +} + +/// Expects the vote open label to be removed from the test issue once, with the result +/// provided. +pub(super) fn expect_remove_open_label(gh: &mut MockGH, result: fn() -> Result<()>) { + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(move |_, _, _, _, _| Box::pin(future::ready(result()))); +} + +/// Expects a check of whether each of the state steps provided of the test vote is +/// pending, once per step, with the result provided. +pub(super) fn expect_state_steps_pending(db: &mut MockDB, steps: &[VoteCloseStep], is_pending: bool) { + for step in steps { + db.expect_is_close_state_step_pending() + .with(eq(test_vote_id()), eq(*step)) + .times(1) + .returning(move |_, _| Box::pin(future::ready(Ok(is_pending)))); + } +} + +/// Expects the vote created on the test issue or pull request with the default test +/// profile to be stored once, tracking `COMMENT_ID`. +pub(super) fn expect_store_vote(db: &mut MockDB, is_pull_request: bool, result: fn() -> Result) { + let expected_input = test_create_vote_input(is_pull_request); + let expected_cfg = test_cfg_profile(); + db.expect_store_vote() + .withf(move |vote_comment_id, input, cfg| { + *vote_comment_id == COMMENT_ID && *input == expected_input && *cfg == expected_cfg + }) + .times(1) + .returning(move |_, _, _| Box::pin(future::ready(result()))); +} + +/// Expects the user to be checked as a repository collaborator once. +pub(super) fn expect_user_is_collaborator(gh: &mut MockGH, is_collaborator: bool) { + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(move |_, _, _, _| Box::pin(future::ready(Ok(is_collaborator)))); +} + +/// Expects posting the vote cancelled comment of the vote provided to be tried once, +/// failing. +pub(super) fn expect_vote_cancelled_comment_fails(gh: &mut MockGH, vote_id: Uuid, is_pull_request: bool) { + let expected_body = vote_cancelled_body(vote_id, is_pull_request); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); +} + +/// Expects the results of the vote provided, which are the test ones, to be published +/// once. +pub(super) fn expect_vote_closed_published(gh: &mut MockGH, vote_id: Uuid) { + let mut seq = Sequence::new(); + expect_comment(gh, vote_closed_body(vote_id)); + expect_announcement_discussion(gh, &mut seq, vote_id, || Ok(())); + expect_remove_open_label(gh, || Ok(())); + expect_close_labels(gh, &mut seq, &[VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL], || Ok(())); +} + +/// Expects the vote created comment to be posted once, returning `COMMENT_ID`. +pub(super) fn expect_vote_created_comment(gh: &mut MockGH, seq: &mut Sequence, is_pull_request: bool) { + let expected_body = vote_created_body(is_pull_request); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body + }) + .times(1) + .in_sequence(seq) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID)))); +} + +/// Expects the vote in progress comment to be posted once. +pub(super) fn expect_vote_in_progress_comment(gh: &mut MockGH, seq: &mut Sequence, is_pull_request: bool) { + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == vote_in_progress_body(is_pull_request) + }) + .times(1) + .in_sequence(seq) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(COMMENT_ID2)))); +} + +/// Expects the labels of the test issue to be updated once for a vote just opened. +pub(super) fn expect_vote_open_labels(gh: &mut MockGH) { + // Expect the labels of a previous vote to be removed + for label in [VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL, VOTE_FAILED_LABEL] { + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(label)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + } + + // Expect the labels of the vote just opened to be added + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![GITVOTE_LABEL, VOTE_OPEN_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); +} + +/// Expects the GitHub calls needed to calculate the results of a vote whose +/// allowed voters only include `USER1`. +pub(super) fn expect_vote_results(gh: &mut MockGH, comment_id: i64, reactions: Result>) { + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(comment_id)) + .times(1) + .return_once(move |_, _, _, _| Box::pin(future::ready(reactions))); + expect_allowed_voters(gh); +} + +/// Creates an in favor reaction from the user provided. +pub(super) fn in_favor_reaction(user: &str) -> Reaction { + Reaction { + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + user: User { + login: user.to_string(), + }, + } +} + +/// Returns the details of the closing check run of the test vote, which passed. +pub(super) fn passed_check_details() -> CheckDetails { + CheckDetails { + status: "completed".to_string(), + summary: "The vote passed! 1 out of 1 voted in favor.".to_string(), + + conclusion: Some("success".to_string()), + } +} + +/// Sets up a commands handler using the mocks provided. +pub(super) fn setup_cmds_handler(db: MockDB, gh: MockGH) -> CommandsHandler { + let (_, cmds_rx) = async_channel::unbounded(); + CommandsHandler::new(Arc::new(db), Arc::new(gh), cmds_rx) +} + +/// Sets up a vote cancelled by the test user, with the publication of its cancellation +/// pending. +pub(super) fn setup_test_cancelled_vote(is_pull_request: bool) -> Vote { + Vote { + close_pending: true, + closed: true, + is_pull_request, + + cancelled_at: Some(test_closed_at()), + cancelled_by: Some(USER.to_string()), + close_next_attempt_at: Some(test_claimed_until()), + closed_at: Some(test_closed_at()), + ..setup_test_vote() + } +} + +/// Sets up a vote closed already, with all its closing side effects pending. +pub(super) fn setup_test_closed_vote(results: Option) -> Vote { + Vote { + close_pending: true, + closed: true, + + closed_at: Some(test_closed_at()), + results, + ..setup_test_vote() + } +} + +/// Returns the time the claim of the votes claimed in the tests ends at. +pub(super) fn test_claimed_until() -> OffsetDateTime { + OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap() + 30.minutes() +} + +/// Returns the time the votes closed or cancelled in the tests were closed at. +pub(super) fn test_closed_at() -> OffsetDateTime { + OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap() +} + +/// Returns the time from which the comments and discussions published by a previous +/// attempt to close or cancel the votes closed in the tests are looked up: ten minutes +/// before they were closed. It is written out rather than derived from the lookup +/// margin, so that the margin applied is checked as well. +pub(super) fn test_lookup_since() -> RangeInclusive { + let since = OffsetDateTime::parse("2022-11-30T09:50:00Z", &Rfc3339).unwrap(); + since..=since +} + +/// Returns the identifier of the test vote. +pub(super) fn test_vote_id() -> Uuid { + Uuid::parse_str(VOTE_ID).unwrap() +} + +/// Renders the vote cancelled comment body of the vote provided. +pub(super) fn vote_cancelled_body(vote_id: Uuid, is_pull_request: bool) -> String { + tmpl::VoteCancelled::new(vote_id, USER, is_pull_request).render().unwrap() +} + +/// Renders the vote closed comment body of the vote provided, with the test results. +pub(super) fn vote_closed_body(vote_id: Uuid) -> String { + tmpl::VoteClosed::new(vote_id, &setup_test_vote_results()).render().unwrap() +} + +/// Renders the vote created comment body of the vote created on the test issue or pull +/// request with the default profile. +pub(super) fn vote_created_body(is_pull_request: bool) -> String { + tmpl::VoteCreated::new(&test_create_vote_input(is_pull_request), &test_cfg_profile()) + .render() + .unwrap() +} + +/// Renders the vote in progress comment body. +pub(super) fn vote_in_progress_body(is_pull_request: bool) -> String { + tmpl::VoteInProgress::new(USER, is_pull_request).render().unwrap() +} + +/// Returns the default profile of the valid test configuration file. +fn test_cfg_profile() -> CfgProfile { + CfgProfile { + duration: Duration::from_mins(5), + pass_threshold: 50.0, + + allowed_voters: Some(AllowedVoters::default()), + ..Default::default() + } +} + +/// Returns the input of a vote created on the test issue or pull request with the +/// default profile. +fn test_create_vote_input(is_pull_request: bool) -> CreateVoteInput { + let event = if is_pull_request { + Event::PullRequest(setup_test_pr_event()) + } else { + Event::Issue(setup_test_issue_event()) + }; + CreateVoteInput::new(None, &event) +} diff --git a/src/processor/tests/module_functions.rs b/src/processor/tests/module_functions.rs new file mode 100644 index 0000000..d45d51f --- /dev/null +++ b/src/processor/tests/module_functions.rs @@ -0,0 +1,32 @@ +use anyhow::format_err; +use tokio::time::Instant; + +use crate::testutil::*; + +use super::*; + +#[test] +fn build_announcement_title_includes_issue_number() { + assert_eq!( + build_announcement_title(ISSUE_NUM, TITLE), + "Test title #1 (vote closed)" + ); +} + +#[tokio::test(start_paused = true)] +async fn run_close_attempt_fails_once_the_attempt_times_out() { + // Run an attempt that never completes + let started_at = Instant::now(); + let err = run_close_attempt(future::pending()).await.unwrap_err(); + + // Check it failed after the attempt timeout + assert_eq!(err.to_string(), "attempt timed out after 1200 seconds"); + assert_eq!(started_at.elapsed(), CLOSE_ATTEMPT_TIMEOUT); +} + +#[tokio::test] +async fn run_close_attempt_returns_the_attempt_outcome() { + assert!(run_close_attempt(future::ready(Ok(()))).await.is_ok()); + let err = run_close_attempt(future::ready(Err(format_err!(ERROR)))).await.unwrap_err(); + assert_eq!(err.to_string(), ERROR); +} diff --git a/src/processor/tests/process_command.rs b/src/processor/tests/process_command.rs new file mode 100644 index 0000000..31bbd79 --- /dev/null +++ b/src/processor/tests/process_command.rs @@ -0,0 +1,445 @@ +use anyhow::format_err; +use mockall::{Sequence, predicate::eq}; + +use crate::{db::MockDB, github::*, testutil::*}; + +use super::*; + +#[tokio::test] +async fn process_command_cancel_vote_error_cancelling_posts_failure_comment() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_cancel_vote() + .withf(|repository_full_name, issue_number, cancelled_by| { + repository_full_name == REPOFN && *issue_number == ISSUE_NUM && cancelled_by == USER + }) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Setup GitHub expectations: nothing is published but the failure comment + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + expect_command_failed_comment(&mut gh, || Ok(COMMENT_ID)); + gh.expect_create_check_run().never(); + gh.expect_remove_label().never(); + + // Run the command + let input = CancelVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + setup_cmds_handler(db, gh).process_command(Command::CancelVote(input)).await; +} + +#[tokio::test] +async fn process_command_cancel_vote_failed_posts_comment() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_command_failed_comment(&mut gh, || Ok(COMMENT_ID)); + + // Run the command + let event = Event::IssueComment(setup_test_issue_comment_event()); + let cmd = Command::CancelVote(CancelVoteInput::new(&event)); + setup_cmds_handler(MockDB::new(), gh).process_command(cmd).await; +} + +#[tokio::test] +async fn process_command_cancel_vote_publish_failed_does_not_post_failure_comment() { + // Setup cases: outcome of posting the comment, creating the check run and recording + // the labels step. The vote is cancelled already, so trying again would not help + type Outcome = fn() -> Result<()>; + let ok: Outcome = || Ok(()); + let err: Outcome = || Err(format_err!(ERROR)); + let cases = [(err, ok, ok), (ok, err, ok), (ok, ok, err)]; + + for (comment_result, check_run_result, labels_step_result) in cases { + // Setup database expectations + let mut db = MockDB::new(); + expect_cancel_vote(&mut db, Some(setup_test_cancelled_vote(true))); + expect_state_steps_pending(&mut db, &[VoteCloseStep::CheckRun, VoteCloseStep::Labels], true); + let recorded_steps = [ + (VoteCloseStep::Comment, comment_result().is_ok()), + (VoteCloseStep::CheckRun, check_run_result().is_ok()), + ]; + for (step, recorded) in recorded_steps { + db.expect_record_vote_close_step() + .with(eq(test_vote_id()), eq(step)) + .times(usize::from(recorded)) + .returning(|_, _| Box::pin(future::ready(Ok(())))); + } + db.expect_record_vote_close_step() + .with(eq(test_vote_id()), eq(VoteCloseStep::Labels)) + .times(1) + .returning(move |_, _| Box::pin(future::ready(labels_step_result()))); + db.expect_complete_vote_cancellation().never(); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_user_is_collaborator(&mut gh, true); + let expected_body = vote_cancelled_body(test_vote_id(), true); + gh.expect_post_comment() + .withf(move |inst_id, owner, repo, issue_number, body| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && body == expected_body + }) + .times(1) + .returning(move |_, _, _, _, _| Box::pin(future::ready(comment_result().map(|()| COMMENT_ID)))); + let failed_body = tmpl::CommandFailed {}.render().unwrap(); + gh.expect_post_comment().withf(move |_, _, _, _, body| body == failed_body).never(); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(cancelled_check_details()), + ) + .times(1) + .returning(move |_, _, _, _, _| Box::pin(future::ready(check_run_result()))); + expect_remove_open_label(&mut gh, || Ok(())); + + // Run and check the expectations are met + let input = CancelVoteInput::new(&Event::PullRequest(setup_test_pr_event())); + setup_cmds_handler(db, gh).process_command(Command::CancelVote(input)).await; + } +} + +#[tokio::test] +async fn process_command_check_vote_error_updating_last_check_posts_no_failure_comment() { + // Setup database expectations: the last check cannot be updated + let mut db = MockDB::new(); + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(Some(setup_test_vote()))))); + db.expect_update_vote_last_check() + .with(eq(test_vote_id())) + .times(1) + .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Setup GitHub expectations: only the vote status comment is posted + let mut gh = MockGH::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![in_favor_reaction(USER1)])); + expect_comment( + &mut gh, + tmpl::VoteStatus::new(&setup_test_vote_results()).render().unwrap(), + ); + expect_command_failed_comment_never(&mut gh); + + // Run the command + let event = Event::IssueComment(setup_test_issue_comment_event()); + let cmd = Command::CheckVote(CheckVoteInput::new(&event)); + setup_cmds_handler(db, gh).process_command(cmd).await; +} + +#[tokio::test] +async fn process_command_check_vote_failed_posts_comment() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_command_failed_comment(&mut gh, || Ok(COMMENT_ID)); + + // Run the command + let event = Event::IssueComment(setup_test_issue_comment_event()); + let cmd = Command::CheckVote(CheckVoteInput::new(&event)); + setup_cmds_handler(db, gh).process_command(cmd).await; +} + +#[tokio::test] +async fn process_command_create_vote_automatic_failed_reports_automation_failure() { + // Each case sets up one of the failures of a command that nobody ran + let test_cases: [fn(&mut MockDB, &mut MockGH); 3] = [ + // Configuration lookup + |_, gh| { + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + }, + // Collaborator lookup + |_, gh| { + expect_valid_config(gh); + gh.expect_user_is_collaborator() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(USER)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + }, + // Open vote lookup + |db, gh| { + expect_valid_config(gh); + expect_user_is_collaborator(gh, true); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + }, + ]; + for setup_failure in test_cases { + // Setup expectations: the only comment posted and the only check reported, as + // the pull request has no vote + let mut db = MockDB::new(); + let mut gh = MockGH::new(); + setup_failure(&mut db, &mut gh); + expect_has_vote(&mut db, Ok(false)); + gh.expect_is_check_required().never(); + expect_check_run(&mut gh, cmd::automation_failed_check()); + expect_comment(&mut gh, tmpl::AutomationFailed::new(None).render().unwrap()); + + // Run the command + let event = Event::PullRequest(setup_test_pr_event()); + let cmd = Command::CreateVote(CreateVoteInput::new_automatic(PROFILE_NAME, &event)); + setup_cmds_handler(db, gh).process_command(cmd).await; + } +} + +#[tokio::test] +async fn process_command_create_vote_automatic_failed_with_vote_reports_nothing() { + // Setup expectations: the configuration lookup fails on a pull request with a vote + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(true)); + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + gh.expect_create_check_run().never(); + gh.expect_post_comment().never(); + + // Run the command + let event = Event::PullRequest(setup_test_pr_event()); + let cmd = Command::CreateVote(CreateVoteInput::new_automatic(PROFILE_NAME, &event)); + setup_cmds_handler(db, gh).process_command(cmd).await; +} + +#[tokio::test] +async fn process_command_create_vote_failed_on_issue_posts_comment() { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_command_failed_comment(&mut gh, || Ok(COMMENT_ID)); + gh.expect_create_check_run().never(); + + // Run the command + let event = Event::Issue(setup_test_issue_event()); + let cmd = Command::CreateVote(CreateVoteInput::new(None, &event)); + setup_cmds_handler(MockDB::new(), gh).process_command(cmd).await; +} + +#[tokio::test] +async fn process_command_create_vote_failed_on_opened_pr_error_checking_vote_reports_check() { + // Setup GitHub expectations: the check is reported even if the vote is unknown + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_command_failed_comment(&mut gh, || Ok(COMMENT_ID)); + expect_check_run(&mut gh, cmd::vote_not_created_check()); + + // Setup database expectations: the vote lookup fails + let mut db = MockDB::new(); + expect_has_vote(&mut db, Err(format_err!(ERROR))); + + // Run the command + let event = Event::PullRequest(setup_test_pr_event()); + let cmd = Command::CreateVote(CreateVoteInput::new(None, &event)); + setup_cmds_handler(db, gh).process_command(cmd).await; +} + +#[tokio::test] +async fn process_command_create_vote_failed_on_opened_pr_reports_failure_check() { + // Setup GitHub expectations: the check must be tried even if the comment fails + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_command_failed_comment(&mut gh, || Err(format_err!(ERROR))); + gh.expect_is_check_required().never(); + expect_check_run(&mut gh, cmd::vote_not_created_check()); + + // Setup database expectations: the pull request has no vote + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(false)); + + // Run the command + let event = Event::PullRequest(setup_test_pr_event()); + let cmd = Command::CreateVote(CreateVoteInput::new(None, &event)); + setup_cmds_handler(db, gh).process_command(cmd).await; +} + +#[tokio::test] +async fn process_command_create_vote_failed_on_opened_pr_with_vote_reports_no_check() { + // Setup GitHub expectations: the comment is posted, but the vote owns the check + let mut gh = MockGH::new(); + gh.expect_get_config_file() + .with(eq(INST_ID), eq(ORG), eq(REPO)) + .times(1) + .returning(|_, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_command_failed_comment(&mut gh, || Ok(COMMENT_ID)); + gh.expect_create_check_run().never(); + + // Setup database expectations: the pull request has a vote + let mut db = MockDB::new(); + expect_has_vote(&mut db, Ok(true)); + + // Run the command + let event = Event::PullRequest(setup_test_pr_event()); + let cmd = Command::CreateVote(CreateVoteInput::new(None, &event)); + setup_cmds_handler(db, gh).process_command(cmd).await; +} + +#[tokio::test] +async fn process_command_create_vote_pr_check_run_failed_does_not_ask_to_try_again() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + expect_store_vote(&mut db, true, || Ok(test_vote_id())); + + // Setup GitHub expectations: the vote created comment is posted, creating the check + // run fails, the labels are updated anyway, and no failure is reported + let mut gh = MockGH::new(); + let mut seq = Sequence::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + expect_vote_created_comment(&mut gh, &mut seq, true); + expect_command_failed_comment_never(&mut gh); + gh.expect_create_check_run() + .with( + eq(INST_ID), + eq(ORG), + eq(REPO), + eq(ISSUE_NUM), + eq(CheckDetails { + status: "in_progress".to_string(), + summary: "Vote open".to_string(), + + conclusion: None, + }), + ) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_vote_open_labels(&mut gh); + + // Run the command + let event = Event::PullRequest(setup_test_pr_event()); + let cmd = Command::CreateVote(CreateVoteInput::new(None, &event)); + setup_cmds_handler(db, gh).process_command(cmd).await; +} + +#[tokio::test] +async fn process_command_create_vote_setup_failed_does_not_ask_to_try_again() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + expect_store_vote(&mut db, false, || Ok(test_vote_id())); + + // Setup GitHub expectations: only the vote created comment is posted + let mut gh = MockGH::new(); + let mut seq = Sequence::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + expect_vote_created_comment(&mut gh, &mut seq, false); + expect_command_failed_comment_never(&mut gh); + for label in [VOTE_CLOSED_LABEL, VOTE_PASSED_LABEL, VOTE_FAILED_LABEL] { + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(label)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + } + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![GITVOTE_LABEL, VOTE_OPEN_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run the command + let event = Event::Issue(setup_test_issue_event()); + let cmd = Command::CreateVote(CreateVoteInput::new(None, &event)); + setup_cmds_handler(db, gh).process_command(cmd).await; +} + +#[tokio::test] +async fn process_command_create_vote_stored_despite_error_sets_up_vote() { + // Setup database expectations: the vote was stored despite the error + let mut db = MockDB::new(); + db.expect_has_vote_open() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Ok(false)))); + expect_store_vote(&mut db, true, || Err(format_err!(ERROR))); + expect_has_vote_with_comment(&mut db, Ok(true)); + db.expect_has_vote().never(); + + // Setup GitHub expectations: the vote is set up as open and no failure is reported + let mut gh = MockGH::new(); + let mut seq = Sequence::new(); + expect_valid_config(&mut gh); + expect_user_is_collaborator(&mut gh, true); + expect_vote_created_comment(&mut gh, &mut seq, true); + expect_command_failed_comment_never(&mut gh); + gh.expect_delete_comment().never(); + expect_check_run( + &mut gh, + CheckDetails { + status: "in_progress".to_string(), + summary: "Vote open".to_string(), + + conclusion: None, + }, + ); + expect_vote_open_labels(&mut gh); + + // Run the command + let event = Event::PullRequest(setup_test_pr_event()); + let cmd = Command::CreateVote(CreateVoteInput::new(None, &event)); + setup_cmds_handler(db, gh).process_command(cmd).await; +} + +#[tokio::test] +async fn process_command_periodic_check_vote_failed_posts_no_comment() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .returning(|_, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_post_comment().never(); + + // Run the command, as queued by the status checker + let cmd = Command::CheckVote(CheckVoteInput { + issue_number: ISSUE_NUM, + repository_full_name: REPOFN.to_string(), + + installation_id: None, + }); + setup_cmds_handler(db, gh).process_command(cmd).await; +} diff --git a/src/processor/tests/status_checker.rs b/src/processor/tests/status_checker.rs new file mode 100644 index 0000000..9f1bbd1 --- /dev/null +++ b/src/processor/tests/status_checker.rs @@ -0,0 +1,145 @@ +use std::sync::Arc; + +use anyhow::format_err; +use mockall::Sequence; +use tokio::{sync::oneshot, time::Instant}; + +use crate::{db::MockDB, testutil::*}; + +use super::*; + +#[tokio::test(start_paused = true)] +async fn status_checker_enqueues_all_pending_status_checks() { + // Setup database expectations + let first_input = CheckVoteInput { + issue_number: 1, + repository_full_name: REPOFN.to_string(), + + installation_id: None, + }; + let second_input = CheckVoteInput { + issue_number: 2, + repository_full_name: REPOFN.to_string(), + + installation_id: None, + }; + let mut db = MockDB::new(); + db.expect_get_pending_status_checks().times(1).returning({ + let inputs = vec![first_input.clone(), second_input.clone()]; + move || Box::pin(future::ready(Ok(inputs.clone()))) + }); + + // Run the status checker until it's asked to stop + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let cancel_token = CancellationToken::new(); + let status_checker = StatusChecker::new(Arc::new(db), cmds_tx); + let status_checker_handle = status_checker.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check commands were enqueued in order + assert!(status_checker_handle.await.is_ok()); + assert_eq!(cmds_rx.recv().await.unwrap(), Command::CheckVote(first_input)); + assert_eq!(cmds_rx.recv().await.unwrap(), Command::CheckVote(second_input)); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test(start_paused = true)] +async fn status_checker_pauses_and_resumes_after_error() { + // Setup database expectations + let (resumed_tx, resumed_rx) = oneshot::channel(); + let mut seq = Sequence::new(); + let mut db = MockDB::new(); + db.expect_get_pending_status_checks() + .times(1) + .in_sequence(&mut seq) + .returning(|| Box::pin(future::ready(Err(format_err!(ERROR))))); + db.expect_get_pending_status_checks().times(1).in_sequence(&mut seq).return_once(move || { + resumed_tx.send(Instant::now()).unwrap(); + Box::pin(future::ready(Ok(vec![]))) + }); + + // Run the status checker until it resumes + let started_at = Instant::now(); + let (cmds_tx, _cmds_rx) = async_channel::unbounded(); + let cancel_token = CancellationToken::new(); + let status_checker = StatusChecker::new(Arc::new(db), cmds_tx); + let status_checker_handle = status_checker.run(cancel_token.clone()); + let resumed_at = resumed_rx.await.unwrap(); + cancel_token.cancel(); + + // Check the worker waited until the next run + assert!(status_checker_handle.await.is_ok()); + assert_eq!(resumed_at - started_at, Duration::from_mins(30)); +} + +#[tokio::test(start_paused = true)] +async fn status_checker_stops_after_processing_pending_status_check() { + // Setup input + let check_vote_input = CheckVoteInput { + issue_number: 1, + repository_full_name: "repo_full_name".to_string(), + + installation_id: None, + }; + let check_vote_input_copy = check_vote_input.clone(); + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_pending_status_checks() + .times(1) + .returning(move || Box::pin(future::ready(Ok(vec![check_vote_input_copy.clone()])))); + + // Run the status checker until it's asked to stop + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let cancel_token = CancellationToken::new(); + let status_checker = StatusChecker::new(Arc::new(db), cmds_tx); + let status_checker_handle = status_checker.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the command was enqueued + assert!(status_checker_handle.await.is_ok()); + assert_eq!( + cmds_rx.recv().await.unwrap(), + Command::CheckVote(check_vote_input) + ); +} + +#[tokio::test(start_paused = true)] +async fn status_checker_stops_when_requested_error_getting_pending() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_pending_status_checks() + .times(1) + .returning(|| Box::pin(future::ready(Err(format_err!(ERROR))))); + + // Run the status checker until it's asked to stop + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let cancel_token = CancellationToken::new(); + let status_checker = StatusChecker::new(Arc::new(db), cmds_tx); + let status_checker_handle = status_checker.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check no commands were enqueued + assert!(status_checker_handle.await.is_ok()); + assert!(cmds_rx.is_empty()); +} + +#[tokio::test(start_paused = true)] +async fn status_checker_stops_when_requested_none_pending() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_pending_status_checks() + .times(1) + .returning(|| Box::pin(future::ready(Ok(vec![])))); + + // Run the status checker until it's asked to stop + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let cancel_token = CancellationToken::new(); + let status_checker = StatusChecker::new(Arc::new(db), cmds_tx); + let status_checker_handle = status_checker.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check no commands were enqueued + assert!(status_checker_handle.await.is_ok()); + assert!(cmds_rx.is_empty()); +} diff --git a/src/processor/tests/votes_auto_closer.rs b/src/processor/tests/votes_auto_closer.rs new file mode 100644 index 0000000..3584dc9 --- /dev/null +++ b/src/processor/tests/votes_auto_closer.rs @@ -0,0 +1,290 @@ +use std::sync::Arc; + +use anyhow::format_err; +use mockall::{Sequence, predicate::eq}; +use tokio::{sync::oneshot, time::Instant}; +use uuid::Uuid; + +use crate::{cfg_repo::PassThresholdBase, db::MockDB, github::*, results::REACTION_IN_FAVOR, testutil::*}; + +use super::*; + +#[tokio::test(start_paused = true)] +async fn votes_auto_closer_closes_vote_only_when_passing_over_all_allowed_voters() { + // Setup cases (exclude abstentions, allowed voters, expected ends at updates). In + // all cases USER1 votes in favor and the vote passes over the votes cast + let cases = [ + (None, vec![USER1, USER2, USER3], 0), + (None, vec![USER1, USER2], 1), + (Some(true), vec![USER1, USER2], 1), + (None, vec![], 0), + ]; + + for (exclude_abstentions, allowed_voters, expected_updates) in cases { + // Setup vote using the votes cast as the pass threshold base + let mut vote = setup_test_vote(); + vote.cfg.pass_threshold_base = Some(PassThresholdBase::VotesCast { exclude_abstentions }); + let expected_cfg = vote.cfg.clone(); + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_votes_with_close_on_passing() + .times(1) + .return_once(move || Box::pin(future::ready(Ok(vec![vote])))); + db.expect_update_vote_ends_at() + .with(eq(test_vote_id())) + .times(expected_updates) + .returning(|_| Box::pin(future::ready(Ok(())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(vec![in_favor_reaction(USER1)])))); + let allowed_voters: Vec = allowed_voters.into_iter().map(ToString::to_string).collect(); + gh.expect_get_allowed_voters() + .withf(move |inst_id, cfg, owner, repo, org| { + *inst_id == INST_ID + && *cfg == expected_cfg + && owner == ORG + && repo == REPO + && *org == Some(ORG.to_string()).as_ref() + }) + .times(1) + .return_once(move |_, _, _, _, _| Box::pin(future::ready(Ok(allowed_voters)))); + + // Run the votes auto closer until it's asked to stop + let cancel_token = CancellationToken::new(); + let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); + let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the worker completed (expectations are verified on drop) + assert!( + votes_auto_closer_handle.await.is_ok(), + "exclude abstentions: {exclude_abstentions:?}, expected updates: {expected_updates}" + ); + } +} + +#[tokio::test(start_paused = true)] +async fn votes_auto_closer_continues_after_error_calculating_results() { + // Setup votes + let first_vote = setup_test_vote(); + let mut second_vote = setup_test_vote(); + second_vote.vote_comment_id = COMMENT_ID2; + second_vote.vote_id = Uuid::parse_str(VOTE_ID2).unwrap(); + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_votes_with_close_on_passing().times(1).returning({ + let votes = vec![first_vote, second_vote]; + move || Box::pin(future::ready(Ok(votes.clone()))) + }); + db.expect_update_vote_ends_at() + .with(eq(Uuid::parse_str(VOTE_ID2).unwrap())) + .times(1) + .returning(|_| Box::pin(future::ready(Ok(())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); + expect_vote_results(&mut gh, COMMENT_ID2, Ok(vec![in_favor_reaction(USER1)])); + + // Run the votes auto closer until it's asked to stop + let cancel_token = CancellationToken::new(); + let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); + let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the worker completed (expectations are verified on drop) + assert!(votes_auto_closer_handle.await.is_ok()); +} + +#[tokio::test(start_paused = true)] +async fn votes_auto_closer_continues_after_error_updating_ends_at() { + // Setup votes + let first_vote = setup_test_vote(); + let mut second_vote = setup_test_vote(); + second_vote.vote_comment_id = COMMENT_ID2; + second_vote.vote_id = Uuid::parse_str(VOTE_ID2).unwrap(); + + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_votes_with_close_on_passing().times(1).returning({ + let votes = vec![first_vote, second_vote]; + move || Box::pin(future::ready(Ok(votes.clone()))) + }); + db.expect_update_vote_ends_at() + .with(eq(test_vote_id())) + .times(1) + .returning(|_| Box::pin(future::ready(Err(format_err!(ERROR))))); + db.expect_update_vote_ends_at() + .with(eq(Uuid::parse_str(VOTE_ID2).unwrap())) + .times(1) + .returning(|_| Box::pin(future::ready(Ok(())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(vec![in_favor_reaction(USER1)])))); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID2)) + .times(1) + .returning(|_, _, _, _| Box::pin(future::ready(Ok(vec![in_favor_reaction(USER1)])))); + gh.expect_get_allowed_voters() + .withf(|inst_id, cfg, owner, repo, org| { + *inst_id == INST_ID + && *cfg == setup_test_vote().cfg + && owner == ORG + && repo == REPO + && *org == Some(ORG.to_string()).as_ref() + }) + .times(2) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(vec![USER1.to_string()])))); + + // Run the votes auto closer until it's asked to stop + let cancel_token = CancellationToken::new(); + let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); + let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the worker completed (expectations are verified on drop) + assert!(votes_auto_closer_handle.await.is_ok()); +} + +#[tokio::test(start_paused = true)] +async fn votes_auto_closer_does_not_close_vote_not_passed() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_votes_with_close_on_passing() + .times(1) + .returning(|| Box::pin(future::ready(Ok(vec![setup_test_vote()])))); + db.expect_update_vote_ends_at().never(); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_vote_results(&mut gh, COMMENT_ID, Ok(vec![])); + + // Run the votes auto closer until it's asked to stop + let cancel_token = CancellationToken::new(); + let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); + let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the worker completed (expectations are verified on drop) + assert!(votes_auto_closer_handle.await.is_ok()); +} + +#[tokio::test(start_paused = true)] +async fn votes_auto_closer_pauses_and_resumes_after_error() { + // Setup database expectations + let (resumed_tx, resumed_rx) = oneshot::channel(); + let mut seq = Sequence::new(); + let mut db = MockDB::new(); + db.expect_get_open_votes_with_close_on_passing() + .times(1) + .in_sequence(&mut seq) + .returning(|| Box::pin(future::ready(Err(format_err!(ERROR))))); + db.expect_get_open_votes_with_close_on_passing() + .times(1) + .in_sequence(&mut seq) + .return_once(move || { + resumed_tx.send(Instant::now()).unwrap(); + Box::pin(future::ready(Ok(vec![]))) + }); + + // Run the votes auto closer until it resumes + let started_at = Instant::now(); + let cancel_token = CancellationToken::new(); + let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(MockGH::new())); + let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); + let resumed_at = resumed_rx.await.unwrap(); + cancel_token.cancel(); + + // Check the worker waited until the next run + assert!(votes_auto_closer_handle.await.is_ok()); + assert_eq!(resumed_at - started_at, Duration::from_hours(24)); +} + +#[tokio::test(start_paused = true)] +async fn votes_auto_closer_stops_after_processing_pending_vote() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_votes_with_close_on_passing() + .times(1) + .returning(move || Box::pin(future::ready(Ok(vec![setup_test_vote()])))); + db.expect_update_vote_ends_at() + .with(eq(test_vote_id())) + .times(1) + .returning(move |_| Box::pin(future::ready(Ok(())))); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(COMMENT_ID)) + .times(1) + .returning(|_, _, _, _| { + Box::pin(future::ready(Ok(vec![Reaction { + content: REACTION_IN_FAVOR.to_string(), + created_at: TIMESTAMP.to_string(), + user: User { + login: USER1.to_string(), + }, + }]))) + }); + expect_allowed_voters(&mut gh); + + // Run the votes auto closer until it's asked to stop + let cancel_token = CancellationToken::new(); + let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); + let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the worker completed + assert!(votes_auto_closer_handle.await.is_ok()); +} + +#[tokio::test(start_paused = true)] +async fn votes_auto_closer_stops_when_requested_error_getting_pending() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_votes_with_close_on_passing() + .times(1) + .returning(|| Box::pin(future::ready(Err(format_err!(ERROR))))); + let gh = MockGH::new(); + + // Run the votes auto closer until it's asked to stop + let cancel_token = CancellationToken::new(); + let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); + let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the worker completed + assert!(votes_auto_closer_handle.await.is_ok()); +} + +#[tokio::test(start_paused = true)] +async fn votes_auto_closer_stops_when_requested_none_pending() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_get_open_votes_with_close_on_passing() + .times(1) + .returning(|| Box::pin(future::ready(Ok(vec![])))); + let gh = MockGH::new(); + + // Run the votes auto closer until it's asked to stop + let cancel_token = CancellationToken::new(); + let votes_auto_closer = VotesAutoCloser::new(Arc::new(db), Arc::new(gh)); + let votes_auto_closer_handle = votes_auto_closer.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the worker completed + assert!(votes_auto_closer_handle.await.is_ok()); +} diff --git a/src/processor/tests/votes_closer.rs b/src/processor/tests/votes_closer.rs new file mode 100644 index 0000000..06ae6c0 --- /dev/null +++ b/src/processor/tests/votes_closer.rs @@ -0,0 +1,241 @@ +use std::sync::Arc; + +use anyhow::format_err; +use mockall::{Sequence, predicate::eq}; +use tokio::{sync::oneshot, time::Instant}; + +use crate::{db::MockDB, github::*, results::Vote, testutil::*}; + +use super::*; + +#[tokio::test] +async fn votes_closer_closes_pending_votes_until_none_left() { + // Setup database expectations + let (none_left_tx, none_left_rx) = oneshot::channel(); + let mut seq = Sequence::new(); + let mut db = MockDB::new(); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + db.expect_claim_vote_to_close() + .times(1) + .in_sequence(&mut seq) + .returning(|| Box::pin(future::ready(Ok(Some(setup_test_closed_vote(None)))))); + expect_close_steps(&mut db, &[VoteCloseStep::Labels]); + expect_complete_vote_close(&mut db); + db.expect_claim_vote_to_close().times(1).in_sequence(&mut seq).return_once(move || { + none_left_tx.send(()).unwrap(); + Box::pin(future::ready(Ok(None))) + }); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_remove_label() + .with(eq(INST_ID), eq(ORG), eq(REPO), eq(ISSUE_NUM), eq(VOTE_OPEN_LABEL)) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + gh.expect_add_labels() + .withf(|inst_id, owner, repo, issue_number, labels| { + *inst_id == INST_ID + && owner == ORG + && repo == REPO + && *issue_number == ISSUE_NUM + && labels == vec![VOTE_CLOSED_LABEL] + }) + .times(1) + .returning(|_, _, _, _, _| Box::pin(future::ready(Ok(())))); + + // Run the votes closer until no pending votes are left + let cancel_token = CancellationToken::new(); + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + let votes_closer_handle = votes_closer.run(cancel_token.clone()); + none_left_rx.await.unwrap(); + cancel_token.cancel(); + + // Check the worker completed (expectations are verified on drop) + assert!(votes_closer_handle.await.is_ok()); +} + +#[tokio::test(start_paused = true)] +async fn votes_closer_continues_without_pause_after_failing_to_close_vote() { + // Setup database expectations + let (next_vote_tx, next_vote_rx) = oneshot::channel(); + let mut seq = Sequence::new(); + let mut db = MockDB::new(); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + db.expect_claim_vote_to_close().times(1).in_sequence(&mut seq).returning(|| { + let vote = Vote { + close_next_attempt_at: Some(test_claimed_until()), + ..setup_test_closed_vote(None) + }; + Box::pin(future::ready(Ok(Some(vote)))) + }); + expect_close_failure(&mut db, VoteCloseFailureOutcome::RetryScheduled); + db.expect_claim_vote_to_close().times(1).in_sequence(&mut seq).return_once(move || { + next_vote_tx.send(Instant::now()).unwrap(); + Box::pin(future::ready(Ok(None))) + }); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels(&mut gh, &mut Sequence::new(), &[VOTE_CLOSED_LABEL], || { + Err(format_err!(ERROR)) + }); + + // Run the votes closer until it asks for the next vote + let started_at = Instant::now(); + let cancel_token = CancellationToken::new(); + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + let votes_closer_handle = votes_closer.run(cancel_token.clone()); + let next_vote_at = next_vote_rx.await.unwrap(); + cancel_token.cancel(); + + // Check the failing vote did not hold up the next one + assert!(votes_closer_handle.await.is_ok()); + assert_eq!(next_vote_at - started_at, Duration::ZERO); +} + +#[tokio::test(start_paused = true)] +async fn votes_closer_pauses_and_resumes_after_error() { + // Setup database expectations + let (resumed_tx, resumed_rx) = oneshot::channel(); + let mut seq = Sequence::new(); + let mut db = MockDB::new(); + db.expect_claim_vote_to_close() + .times(1) + .in_sequence(&mut seq) + .returning(|| Box::pin(future::ready(Err(format_err!(ERROR))))); + db.expect_claim_vote_to_close().times(1).in_sequence(&mut seq).return_once(move || { + resumed_tx.send(Instant::now()).unwrap(); + Box::pin(future::ready(Ok(None))) + }); + + // Run the votes closer until it resumes + let started_at = Instant::now(); + let cancel_token = CancellationToken::new(); + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(MockGH::new())); + let votes_closer_handle = votes_closer.run(cancel_token.clone()); + let resumed_at = resumed_rx.await.unwrap(); + cancel_token.cancel(); + + // Check the worker waited for the error pause + assert!(votes_closer_handle.await.is_ok()); + assert_eq!(resumed_at - started_at, Duration::from_secs(30)); +} + +#[tokio::test(start_paused = true)] +async fn votes_closer_pauses_and_resumes_when_none_pending() { + // Setup database expectations + let (resumed_tx, resumed_rx) = oneshot::channel(); + let mut seq = Sequence::new(); + let mut db = MockDB::new(); + db.expect_claim_vote_to_close() + .times(1) + .in_sequence(&mut seq) + .returning(|| Box::pin(future::ready(Ok(None)))); + db.expect_claim_vote_to_close().times(1).in_sequence(&mut seq).return_once(move || { + resumed_tx.send(Instant::now()).unwrap(); + Box::pin(future::ready(Ok(None))) + }); + + // Run the votes closer until it resumes + let started_at = Instant::now(); + let cancel_token = CancellationToken::new(); + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(MockGH::new())); + let votes_closer_handle = votes_closer.run(cancel_token.clone()); + let resumed_at = resumed_rx.await.unwrap(); + cancel_token.cancel(); + + // Check the worker waited for the pause used when there is nothing to do + assert!(votes_closer_handle.await.is_ok()); + assert_eq!(resumed_at - started_at, Duration::from_secs(15)); +} + +#[tokio::test(start_paused = true)] +async fn votes_closer_stops_during_pause_when_requested() { + // Setup the claim results that make the worker pause + let cases: [fn() -> Result>; 2] = [|| Ok(None), || Err(format_err!(ERROR))]; + + for claim_result in cases { + // Setup database expectations + let (claimed_tx, claimed_rx) = oneshot::channel(); + let mut db = MockDB::new(); + db.expect_claim_vote_to_close().times(1).return_once(move || { + claimed_tx.send(()).unwrap(); + Box::pin(future::ready(claim_result())) + }); + + // Run the votes closer until it pauses + let started_at = Instant::now(); + let cancel_token = CancellationToken::new(); + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(MockGH::new())); + let votes_closer_handle = votes_closer.run(cancel_token.clone()); + claimed_rx.await.unwrap(); + tokio::task::yield_now().await; + + // Ask it to stop + cancel_token.cancel(); + + // Check the worker stopped without waiting for the pause to end + assert!(votes_closer_handle.await.is_ok()); + assert_eq!(started_at.elapsed(), Duration::ZERO); + } +} + +#[tokio::test] +async fn votes_closer_stops_when_requested_error_closing() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_claim_vote_to_close() + .times(1) + .returning(|| Box::pin(future::ready(Err(format_err!(ERROR))))); + let gh = MockGH::new(); + + // Run the votes closer until it's asked to stop + let cancel_token = CancellationToken::new(); + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + let votes_closer_handle = votes_closer.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the worker completed + assert!(votes_closer_handle.await.is_ok()); +} + +#[tokio::test] +async fn votes_closer_stops_when_requested_none_closed() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_claim_vote_to_close().times(1).returning(|| Box::pin(future::ready(Ok(None)))); + let gh = MockGH::new(); + + // Run the votes closer until it's asked to stop + let cancel_token = CancellationToken::new(); + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + let votes_closer_handle = votes_closer.run(cancel_token.clone()); + cancel_token.cancel(); + + // Check the worker completed + assert!(votes_closer_handle.await.is_ok()); +} + +#[tokio::test] +async fn votes_closer_stops_when_requested_with_votes_pending() { + // Setup database expectations + let mut db = MockDB::new(); + expect_claim_vote(&mut db, setup_test_closed_vote(None)); + expect_state_steps_pending(&mut db, &[VoteCloseStep::Labels], true); + expect_close_steps(&mut db, &[VoteCloseStep::Labels]); + expect_complete_vote_close(&mut db); + + // Setup GitHub expectations + let mut gh = MockGH::new(); + expect_remove_open_label(&mut gh, || Ok(())); + expect_close_labels(&mut gh, &mut Sequence::new(), &[VOTE_CLOSED_LABEL], || Ok(())); + + // Run the votes closer, asking it to stop before it starts + let cancel_token = CancellationToken::new(); + cancel_token.cancel(); + let votes_closer = VotesCloser::new(Arc::new(db), Arc::new(gh)); + + // Check the worker completed without asking for another vote + assert!(votes_closer.run(cancel_token).await.is_ok()); +} diff --git a/src/processor/tests/votes_processor.rs b/src/processor/tests/votes_processor.rs new file mode 100644 index 0000000..a76cd2d --- /dev/null +++ b/src/processor/tests/votes_processor.rs @@ -0,0 +1,113 @@ +use std::sync::Arc; + +use mockall::predicate::eq; +use tokio::{sync::oneshot, time::timeout}; + +use crate::{db::MockDB, github::*, testutil::*}; + +use super::*; + +#[tokio::test(start_paused = true)] +async fn votes_processor_processes_commands_sent_while_running() { + // Setup database expectations + let (started_tx, started_rx) = oneshot::channel(); + let (processed_tx, processed_rx) = oneshot::channel(); + let mut db = MockDB::new(); + expect_periodic_workers_run(&mut db); + db.expect_get_pending_status_checks().times(1).return_once(move || { + started_tx.send(()).unwrap(); + Box::pin(future::ready(Ok(vec![]))) + }); + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .return_once(move |_, _| { + processed_tx.send(()).unwrap(); + Box::pin(future::ready(Ok(None))) + }); + + // Run the processor until all its workers have started + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let cancel_token = CancellationToken::new(); + let votes_processor = Processor::new(Arc::new(db), Arc::new(MockGH::new()), cmds_tx.clone(), cmds_rx); + let votes_processor_handle = votes_processor.run(&cancel_token); + started_rx.await.unwrap(); + + // Send a command, as the webhook handler does + let event = Event::Issue(setup_test_issue_event()); + cmds_tx.send(Command::CheckVote(CheckVoteInput::new(&event))).await.unwrap(); + + // Check an idle commands handler processes it + timeout(WORKER_TIMEOUT, processed_rx).await.unwrap().unwrap(); + cancel_token.cancel(); + assert!(votes_processor_handle.await.iter().all(Result::is_ok)); +} + +#[tokio::test(start_paused = true)] +async fn votes_processor_processes_pending_status_checks() { + // Setup database expectations + let (processed_tx, processed_rx) = oneshot::channel(); + let mut db = MockDB::new(); + expect_periodic_workers_run(&mut db); + db.expect_get_pending_status_checks().times(1).returning(|| { + let input = CheckVoteInput { + issue_number: ISSUE_NUM, + repository_full_name: REPOFN.to_string(), + + installation_id: None, + }; + Box::pin(future::ready(Ok(vec![input]))) + }); + db.expect_get_open_vote() + .with(eq(REPOFN), eq(ISSUE_NUM)) + .times(1) + .return_once(move |_, _| { + processed_tx.send(()).unwrap(); + Box::pin(future::ready(Ok(None))) + }); + + // Run the processor + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let cancel_token = CancellationToken::new(); + let votes_processor = Processor::new(Arc::new(db), Arc::new(MockGH::new()), cmds_tx, cmds_rx); + let votes_processor_handle = votes_processor.run(&cancel_token); + + // Check the status check reaches the commands handlers + timeout(WORKER_TIMEOUT, processed_rx).await.unwrap().unwrap(); + cancel_token.cancel(); + assert!(votes_processor_handle.await.iter().all(Result::is_ok)); +} + +#[tokio::test(start_paused = true)] +async fn votes_processor_stops_when_requested() { + // Setup database expectations + let mut db = MockDB::new(); + db.expect_claim_vote_to_close().times(1).returning(|| Box::pin(future::ready(Ok(None)))); + db.expect_get_pending_status_checks() + .times(1) + .returning(|| Box::pin(future::ready(Ok(vec![])))); + db.expect_get_open_votes_with_close_on_passing() + .times(1) + .returning(|| Box::pin(future::ready(Ok(vec![])))); + let gh = MockGH::new(); + + // Run the processor until it's asked to stop + let (cmds_tx, cmds_rx) = async_channel::unbounded(); + let cancel_token = CancellationToken::new(); + let votes_processor = Processor::new(Arc::new(db), Arc::new(gh), cmds_tx, cmds_rx); + let votes_processor_handle = votes_processor.run(&cancel_token); + cancel_token.cancel(); + + // Check all worker tasks completed + assert!(votes_processor_handle.await.iter().all(Result::is_ok)); +} + +// Helpers. + +/// Expects the votes closer and the votes auto closer to run once with nothing to do. +fn expect_periodic_workers_run(db: &mut MockDB) { + db.expect_claim_vote_to_close().times(1).returning(|| Box::pin(future::ready(Ok(None)))); + db.expect_get_open_votes_with_close_on_passing() + .times(1) + .returning(|| Box::pin(future::ready(Ok(vec![])))); +} diff --git a/src/results.rs b/src/results.rs index 74ad710..148fe55 100644 --- a/src/results.rs +++ b/src/results.rs @@ -2,7 +2,7 @@ use std::{collections::BTreeMap, fmt}; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tokio_postgres::{Row, types::Json}; @@ -13,112 +13,153 @@ use crate::{ github::{DynGH, UserName}, }; -/// Supported reactions. -pub(crate) const REACTION_IN_FAVOR: &str = "+1"; -pub(crate) const REACTION_AGAINST: &str = "-1"; +#[cfg(test)] +mod tests; + +/// Reaction used to abstain. pub(crate) const REACTION_ABSTAIN: &str = "eyes"; +/// Reaction used to vote against. +pub(crate) const REACTION_AGAINST: &str = "-1"; + +/// Reaction used to vote in favor. +pub(crate) const REACTION_IN_FAVOR: &str = "+1"; + /// Vote information. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[allow(clippy::struct_field_names)] pub(crate) struct Vote { - pub vote_id: Uuid, - pub vote_comment_id: i64, + /// Configuration profile the vote was created with. + pub cfg: CfgProfile, + /// Number of failed attempts to complete the vote closing. + pub close_attempts: i32, + /// Whether the vote is closed and some of its closing side effects are missing. + pub close_pending: bool, + /// Whether the vote is closed. + pub closed: bool, + /// When the vote was created. pub created_at: OffsetDateTime, + /// User who created the vote. pub created_by: String, + /// When the vote ends. Reactions created after this time are not counted. pub ends_at: OffsetDateTime, - pub closed: bool, - pub closed_at: Option, - pub checked_at: Option, - pub cfg: CfgProfile, + /// GitHub application installation id of the repository. pub installation_id: i64, + /// Whether the vote was created on a pull request rather than on an issue. + pub is_pull_request: bool, + /// Id of the issue or pull request. pub issue_id: i64, + /// Number of the issue or pull request. pub issue_number: i64, - pub issue_title: Option, - pub is_pull_request: bool, + /// Full name of the repository (owner/repo). pub repository_full_name: String, + /// Id of the comment where votes are cast as reactions. + pub vote_comment_id: i64, + /// Vote identifier. + pub vote_id: Uuid, + + /// When the vote was cancelled. A cancelled vote is closed, and it is deleted once + /// its cancellation has been published. + pub cancelled_at: Option, + /// User who cancelled the vote. + pub cancelled_by: Option, + /// When the status of the vote was last checked. + pub checked_at: Option, + /// When the results announcement was posted to discussions. + pub close_announcement_posted_at: Option, + /// When the closing check run was created. + pub close_check_run_created_at: Option, + /// When the closing comment was posted. + pub close_comment_posted_at: Option, + /// Head commit of the pull request when the vote was closed. Votes on issues have + /// none, nor do votes closed when the head could not be read. + pub close_head_sha: Option, + /// When the closing labels were updated. + pub close_labels_updated_at: Option, + /// Earliest time the next attempt to complete the vote closing can run. + pub close_next_attempt_at: Option, + /// When the vote was closed. + pub closed_at: Option, + /// Title of the issue or pull request, when known. + pub issue_title: Option, + /// Organization the repository belongs to, if any. pub organization: Option, + /// Results stored when the vote was closed, if any. pub results: Option, } -impl From<&Row> for Vote { - fn from(row: &Row) -> Self { - let Json(cfg): Json = row.get("cfg"); - let results: Option> = row.get("results"); - Self { - vote_id: row.get("vote_id"), - vote_comment_id: row.get("vote_comment_id"), - created_at: row.get("created_at"), - created_by: row.get("created_by"), - ends_at: row.get("ends_at"), - closed: row.get("closed"), - closed_at: row.get("closed_at"), - checked_at: row.get("checked_at"), - cfg, - installation_id: row.get("installation_id"), - issue_id: row.get("issue_id"), - issue_number: row.get("issue_number"), - issue_title: row.get("issue_title"), - is_pull_request: row.get("is_pull_request"), - repository_full_name: row.get("repository_full_name"), - organization: row.get("organization"), - results: results.map(|Json(results)| results), - } - } +/// Vote results as read from the database. Absent results are stored as SQL null, +/// but a previous version still running during an upgrade may write a JSON null. +type StoredVoteResults = Option>>; + +/// User's vote details. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct UserVote { + /// Whether the vote is binding, as it was cast by an allowed voter. + pub binding: bool, + /// When the vote was cast. + pub timestamp: OffsetDateTime, + /// Option voted. + pub vote_option: VoteOption, } /// Vote options. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub(crate) enum VoteOption { - InFavor, - Against, + /// Abstain from voting. Abstain, + /// Vote against. + Against, + /// Vote in favor. + InFavor, } impl VoteOption { - /// Create a new vote option from a reaction string. + /// Creates a new vote option from a reaction string. fn from_reaction(reaction: &str) -> Result { let vote_option = match reaction { - REACTION_IN_FAVOR => Self::InFavor, - REACTION_AGAINST => Self::Against, REACTION_ABSTAIN => Self::Abstain, + REACTION_AGAINST => Self::Against, + REACTION_IN_FAVOR => Self::InFavor, _ => bail!("reaction not supported"), }; Ok(vote_option) } } -impl fmt::Display for VoteOption { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - Self::InFavor => "In favor", - Self::Against => "Against", - Self::Abstain => "Abstain", - }; - write!(f, "{s}") - } -} - /// Vote results information. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub(crate) struct VoteResults { - pub passed: bool, - pub in_favor_percentage: f64, - pub pass_threshold: f64, - pub in_favor: i64, + /// Number of binding votes to abstain. + pub abstain: i64, + /// Number of binding votes against. pub against: i64, + /// Percentage of binding votes against over the pass threshold base. pub against_percentage: f64, - pub abstain: i64, - pub not_voted: i64, + /// Number of allowed voters (users with binding votes). + pub allowed_voters: i64, + /// Number of binding votes cast. pub binding: i64, + /// Number of binding votes in favor. + pub in_favor: i64, + /// Percentage of binding votes in favor over the pass threshold base. + pub in_favor_percentage: f64, + /// Number of non-binding votes cast. pub non_binding: i64, - pub allowed_voters: i64, - pub votes: BTreeMap, + /// Number of allowed voters who had not voted when the results were calculated. + pub not_voted: i64, + /// Percentage of binding votes in favor required for the vote to pass. + pub pass_threshold: f64, + /// Whether the vote passed. + pub passed: bool, + /// Allowed voters who had not voted when the results were calculated. pub pending_voters: Vec, + /// Votes cast, by user. + pub votes: BTreeMap, } impl VoteResults { - /// Check if the vote passes when calculated over all the allowed voters, so + /// Checks if the vote passes when calculated over all the allowed voters, so /// that further votes from pending voters cannot make it fail (assuming the /// votes already cast and the allowed voters do not change). pub(crate) fn passes_with_all_allowed_voters(&self) -> bool { @@ -126,15 +167,65 @@ impl VoteResults { } } -/// User's vote details. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct UserVote { - pub vote_option: VoteOption, - pub timestamp: OffsetDateTime, - pub binding: bool, +impl TryFrom<&Row> for Vote { + type Error = anyhow::Error; + + /// Decodes a vote from a row of the vote table. + fn try_from(row: &Row) -> Result { + // Decode the columns stored as JSON + let Json(cfg): Json = row.try_get("cfg")?; + let results: StoredVoteResults = row.try_get("results")?; + + // Decode the remaining columns into the vote + Ok(Self { + cfg, + close_attempts: row.try_get("close_attempts")?, + close_pending: row.try_get("close_pending")?, + closed: row.try_get("closed")?, + created_at: row.try_get("created_at")?, + created_by: row.try_get("created_by")?, + ends_at: row.try_get("ends_at")?, + installation_id: row.try_get("installation_id")?, + is_pull_request: row.try_get("is_pull_request")?, + issue_id: row.try_get("issue_id")?, + issue_number: row.try_get("issue_number")?, + repository_full_name: row.try_get("repository_full_name")?, + vote_comment_id: row.try_get("vote_comment_id")?, + vote_id: row.try_get("vote_id")?, + + cancelled_at: row.try_get("cancelled_at")?, + cancelled_by: row.try_get("cancelled_by")?, + checked_at: row.try_get("checked_at")?, + close_announcement_posted_at: row.try_get("close_announcement_posted_at")?, + close_check_run_created_at: row.try_get("close_check_run_created_at")?, + close_comment_posted_at: row.try_get("close_comment_posted_at")?, + close_head_sha: row.try_get("close_head_sha")?, + close_labels_updated_at: row.try_get("close_labels_updated_at")?, + close_next_attempt_at: row.try_get("close_next_attempt_at")?, + closed_at: row.try_get("closed_at")?, + issue_title: row.try_get("issue_title")?, + organization: row.try_get("organization")?, + results: results.and_then(|Json(results)| results), + }) + } } -/// Calculate vote results. +impl fmt::Display for VoteOption { + /// Formats the vote option as displayed to users. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Abstain => "Abstain", + Self::Against => "Against", + Self::InFavor => "In favor", + }; + write!(f, "{s}") + } +} + +/// Calculates vote results. Reactions created after the end of the vote are ignored, as +/// closing it may be retried long after that. Removing a reaction cannot be detected, so +/// a reaction removed after the end of the vote does not count if the results are +/// calculated after that. pub(crate) async fn calculate<'a>( gh: DynGH, owner: &'a str, @@ -159,6 +250,14 @@ pub(crate) async fn calculate<'a>( continue; }; + // Ignore votes cast after the end of the vote, so that they cannot affect the + // vote of a user who voted in time either + let timestamp = OffsetDateTime::parse(reaction.created_at.as_str(), &Rfc3339) + .context("invalid reaction timestamp")?; + if timestamp > vote.ends_at { + continue; + } + // Do not count votes of users voting for multiple options if multiple_options_voters.contains(&username) { continue; @@ -176,23 +275,22 @@ pub(crate) async fn calculate<'a>( votes.insert( username, UserVote { - vote_option, - timestamp: OffsetDateTime::parse(reaction.created_at.as_str(), &Rfc3339) - .expect("created_at timestamp to be valid"), binding, + timestamp, + vote_option, }, ); } // Count binding votes per option and the number of binding and // non-binding voters (only binding votes count towards the result) - let (mut in_favor, mut against, mut abstain, mut binding, mut non_binding) = (0, 0, 0, 0, 0); + let (mut abstain, mut against, mut binding, mut in_favor, mut non_binding) = (0, 0, 0, 0, 0); for user_vote in votes.values() { if user_vote.binding { match user_vote.vote_option { - VoteOption::InFavor => in_favor += 1, - VoteOption::Against => against += 1, VoteOption::Abstain => abstain += 1, + VoteOption::Against => against += 1, + VoteOption::InFavor => in_favor += 1, } binding += 1; } else { @@ -213,13 +311,13 @@ pub(crate) async fn calculate<'a>( } }; - // Calculate the in favor and against percentages over the threshold base - let mut in_favor_percentage = 0.0; + // Calculate the against and in favor percentages over the threshold base let mut against_percentage = 0.0; + let mut in_favor_percentage = 0.0; #[allow(clippy::cast_precision_loss)] if threshold_base > 0 { - in_favor_percentage = in_favor as f64 / threshold_base as f64 * 100.0; against_percentage = against as f64 / threshold_base as f64 * 100.0; + in_favor_percentage = in_favor as f64 / threshold_base as f64 * 100.0; } // Collect the allowed voters who have not voted yet @@ -232,29 +330,28 @@ pub(crate) async fn calculate<'a>( // Check if the vote passed let passed = threshold_reached(in_favor, vote.cfg.pass_threshold, threshold_base); + // Prepare the vote results (not voted is counted before pending voters are moved) Ok(VoteResults { - passed, - in_favor_percentage, - pass_threshold: vote.cfg.pass_threshold, - in_favor, + abstain, against, against_percentage, - abstain, - not_voted: pending_voters.len() as i64, + allowed_voters: allowed_voters_count, binding, + in_favor, + in_favor_percentage, non_binding, - allowed_voters: allowed_voters_count, - votes, + not_voted: pending_voters.len() as i64, + pass_threshold: vote.cfg.pass_threshold, + passed, pending_voters, + votes, }) } -/// Check if the votes in favor reach the pass threshold over the base provided, -/// comparing without dividing so that exact matches are not lost to rounding. +/// Checks if the votes in favor reach the pass threshold over the base provided. The +/// threshold is not multiplied by the base, as the rounding of a decimal threshold could +/// leave the product above the exact value and miss a vote that reaches it exactly. #[allow(clippy::cast_precision_loss)] fn threshold_reached(in_favor: i64, pass_threshold: f64, base: i64) -> bool { - base > 0 && in_favor as f64 * 100.0 >= pass_threshold * base as f64 + base > 0 && in_favor as f64 * 100.0 / base as f64 >= pass_threshold } - -#[cfg(test)] -mod tests; diff --git a/src/results/tests.rs b/src/results/tests.rs index d09d241..45fbe6b 100644 --- a/src/results/tests.rs +++ b/src/results/tests.rs @@ -1,23 +1,28 @@ use std::{sync::Arc, time::Duration}; use anyhow::format_err; -use futures::future::{self}; +use askama::Template; +use futures::future; use mockall::predicate::eq; use proptest::{collection, prelude::*, sample}; use serde_json::json; use tokio_postgres::types::{FromSql, Type}; -use crate::cfg_repo::AllowedVoters; -use crate::github::{MockGH, Reaction, User, UserName}; -use crate::testutil::*; +use crate::{ + cfg_repo::{AllowedVoters, Announcements}, + github::{MockGH, Reaction, User, UserName}, + testutil::*, + tmpl, +}; use super::*; /// Reactions used in randomly generated votes, with the option they map to. const PROPTEST_REACTIONS: [(&str, Option); 4] = [ - (REACTION_IN_FAVOR, Some(VoteOption::InFavor)), - (REACTION_AGAINST, Some(VoteOption::Against)), (REACTION_ABSTAIN, Some(VoteOption::Abstain)), + (REACTION_AGAINST, Some(VoteOption::Against)), + (REACTION_IN_FAVOR, Some(VoteOption::InFavor)), + // Unsupported reaction, which must be ignored ("heart", None), ]; @@ -42,8 +47,10 @@ const PROPTEST_USERS: usize = 6; /// Pass threshold used to require more votes in favor than against. const STRICT_MAJORITY_THRESHOLD: f64 = 50.01; -/// Additional deterministic timestamps used to check each vote keeps its own. +/// Second deterministic timestamp, used to check each vote keeps its own. const TIMESTAMP2: &str = "2022-11-30T11:00:00Z"; + +/// Third deterministic timestamp, used to check each vote keeps its own. const TIMESTAMP3: &str = "2022-11-30T12:00:00Z"; #[tokio::test] @@ -55,12 +62,21 @@ async fn calculate_error_getting_allowed_voters() { .times(1) .returning(|_, _, _, _| Box::pin(future::ready(Ok(vec![])))); gh.expect_get_allowed_voters() + .withf(|inst_id, cfg, owner, repo, org| { + *inst_id == INST_ID + && *cfg == setup_test_vote().cfg + && owner == ORG + && repo == REPO + && *org == setup_test_vote().organization.as_ref() + }) .times(1) .returning(|_, _, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); - // Run and check the error is propagated + // Calculate the results let vote = setup_test_vote(); let err = calculate(Arc::new(gh), ORG, REPO, &vote).await.unwrap_err(); + + // Check the error is propagated assert_eq!(err.to_string(), ERROR); } @@ -74,17 +90,95 @@ async fn calculate_error_getting_reactions() { .returning(|_, _, _, _| Box::pin(future::ready(Err(format_err!(ERROR))))); gh.expect_get_allowed_voters().never(); - // Run and check the error is propagated + // Calculate the results let vote = setup_test_vote(); let err = calculate(Arc::new(gh), ORG, REPO, &vote).await.unwrap_err(); + + // Check the error is propagated assert_eq!(err.to_string(), ERROR); } +#[tokio::test] +async fn calculate_ignores_reactions_after_the_end() { + // Setup cases (seconds after the end of each reaction, expected in favor, against, + // binding, not voted). All the reactions are from the same user: the first one is + // in favor, and the second one, when there is one, is against + let cases: [(&[i64], i64, i64, i64, i64); 7] = [ + (&[-1], 1, 0, 1, 0), + (&[0], 1, 0, 1, 0), + (&[1], 0, 0, 0, 1), + (&[3600], 0, 0, 0, 1), + (&[-1, 1], 1, 0, 1, 0), + (&[1, -1], 0, 1, 1, 0), + (&[-1, 0], 0, 0, 0, 1), + ]; + + for (offsets, in_favor, against, binding, not_voted) in cases { + // Setup the vote and the reactions + let vote = setup_test_vote(); + let contents = [REACTION_IN_FAVOR, REACTION_AGAINST]; + let reactions = offsets + .iter() + .zip(contents) + .map(|(offset, content)| Reaction { + created_at: format_timestamp(vote.ends_at + time::Duration::seconds(*offset)), + ..reaction(USER1, content) + }) + .collect(); + + // Calculate the results + let results = calculate_vote_results(&vote, reactions, vec![USER1.to_string()]).await.unwrap(); + + // Check only the votes cast until the end of the vote count + assert_eq!(results.against, against, "offsets: {offsets:?}"); + assert_eq!(results.binding, binding, "offsets: {offsets:?}"); + assert_eq!(results.in_favor, in_favor, "offsets: {offsets:?}"); + assert_eq!(results.not_voted, not_voted, "offsets: {offsets:?}"); + assert_eq!(results.votes.len() as i64, binding, "offsets: {offsets:?}"); + } +} + +#[tokio::test] +async fn calculate_invalid_reaction_timestamp_returns_error() { + // Setup a vote with a reaction whose timestamp is not valid + let reaction = Reaction { + created_at: "not a timestamp".to_string(), + ..reaction(USER1, REACTION_IN_FAVOR) + }; + + // Calculate the results + let vote = setup_test_vote(); + let result = calculate_vote_results(&vote, vec![reaction], vec![USER1.to_string()]).await; + + // Check the error is returned + assert_eq!(result.unwrap_err().to_string(), "invalid reaction timestamp"); +} + +#[tokio::test] +async fn calculate_open_vote_counts_reactions_until_now() { + // Setup a vote still open with a reaction created just now + let vote = Vote { + ends_at: OffsetDateTime::now_utc() + time::Duration::days(1), + ..setup_test_vote() + }; + let reaction = Reaction { + created_at: format_timestamp(OffsetDateTime::now_utc()), + ..reaction(USER1, REACTION_IN_FAVOR) + }; + + // Calculate the results + let results = calculate_vote_results(&vote, vec![reaction], vec![USER1.to_string()]).await.unwrap(); + + // Check the vote counts + assert_eq!(results.in_favor, 1); +} + proptest! { #[test] fn calculate_results_match_the_votes_cast( allowed_users in sample::subsequence((1..=PROPTEST_USERS).collect::>(), 0..=PROPTEST_USERS), allowed_users_uppercase in collection::vec(any::(), PROPTEST_USERS), + logins_uppercase in collection::vec(any::(), PROPTEST_USERS), users_reactions in collection::vec((1..=PROPTEST_USERS, 0..PROPTEST_REACTIONS.len()), 0..20), pass_threshold in 1..=100_u32, pass_threshold_base in 0..PROPTEST_THRESHOLD_BASES.len(), @@ -98,13 +192,20 @@ proptest! { }) .collect(); - // Setup reactions and vote profile + // Setup reactions, using logins spelled with their own random casing + let login = |i: usize| { + let user = format!("user{i}"); + if logins_uppercase[i - 1] { user.to_uppercase() } else { user } + }; let reactions: Vec = users_reactions .iter() - .map(|(i, r)| reaction(&format!("user{i}"), PROPTEST_REACTIONS[*r].0)) + .map(|(i, r)| reaction(&login(*i), PROPTEST_REACTIONS[*r].0)) .collect(); + + // Setup vote profile let cfg = CfgProfile { pass_threshold: f64::from(pass_threshold), + pass_threshold_base: PROPTEST_THRESHOLD_BASES[pass_threshold_base].clone(), ..setup_test_vote().cfg }; @@ -117,14 +218,14 @@ proptest! { let mut users_options: BTreeMap> = BTreeMap::new(); for (i, r) in &users_reactions { if let Some(option) = &PROPTEST_REACTIONS[*r].1 { - users_options.entry(format!("user{i}")).or_default().push(option.clone()); + users_options.entry(login(*i)).or_default().push(option.clone()); } } let expected_votes: BTreeMap = users_options .into_iter() .filter(|(_, options)| options.len() == 1) .map(|(user, options)| { - let binding = allowed_voters.iter().any(|voter| voter.to_lowercase() == user); + let binding = allowed_voters.iter().any(|voter| voter.eq_ignore_ascii_case(&user)); (user, (options[0].clone(), binding)) }) .collect(); @@ -141,7 +242,7 @@ proptest! { let expected_binding = count_binding(None); let expected_pending: Vec = allowed_voters .iter() - .filter(|voter| !expected_votes.contains_key(&voter.to_lowercase())) + .filter(|voter| !expected_votes.keys().any(|user| user.eq_ignore_ascii_case(voter))) .cloned() .collect(); let allowed = allowed_voters.len() as i64; @@ -169,17 +270,17 @@ proptest! { prop_assert_eq!(&votes, &expected_votes); // Check the results summary - prop_assert_eq!(results.passed, expected_passed); - prop_assert!((results.in_favor_percentage - expected_percentage(expected_in_favor)).abs() < 1e-9); - prop_assert!((results.against_percentage - expected_percentage(expected_against)).abs() < 1e-9); - prop_assert_eq!(results.in_favor, expected_in_favor); - prop_assert_eq!(results.against, expected_against); prop_assert_eq!(results.abstain, count_binding(Some(VoteOption::Abstain))); + prop_assert_eq!(results.against, expected_against); + prop_assert!((results.against_percentage - expected_percentage(expected_against)).abs() < 1e-9); + prop_assert_eq!(results.allowed_voters, allowed); prop_assert_eq!(results.binding, expected_binding); + prop_assert_eq!(results.in_favor, expected_in_favor); + prop_assert!((results.in_favor_percentage - expected_percentage(expected_in_favor)).abs() < 1e-9); prop_assert_eq!(results.non_binding, expected_votes.len() as i64 - expected_binding); - prop_assert_eq!(results.allowed_voters, allowed); - prop_assert_eq!(&results.pending_voters, &expected_pending); prop_assert_eq!(results.not_voted, expected_pending.len() as i64); + prop_assert_eq!(results.passed, expected_passed); + prop_assert_eq!(&results.pending_voters, &expected_pending); // Check the results invariants prop_assert_eq!(results.in_favor + results.against + results.abstain, results.binding); @@ -195,6 +296,55 @@ proptest! { } } +#[tokio::test] +async fn calculate_strict_majority_recipe() { + // Setup cases (in favor, against, abstain, pass threshold, expected passed) + let cases = [ + (2, 0, 10, STRICT_MAJORITY_THRESHOLD, true), + (2, 2, 0, STRICT_MAJORITY_THRESHOLD, false), + (3, 2, 5, STRICT_MAJORITY_THRESHOLD, true), + (0, 1, 0, STRICT_MAJORITY_THRESHOLD, false), + (0, 0, 3, STRICT_MAJORITY_THRESHOLD, false), + // A tie passes with 50, so it cannot be used to require a strict majority + (2, 2, 0, 50.0, true), + ]; + + for (in_favor, against, abstain, pass_threshold, expected_passed) in cases { + // Calculate the results, leaving one allowed voter pending + let votes = votes_cast(in_favor, against, abstain); + let allowed_voters = users(in_favor + against + abstain + 1); + let cfg = votes_cast_cfg(pass_threshold, Some(true)); + let results = calculate_results(cfg, reactions(&votes), allowed_voters).await; + + // Check the vote passes only when expected + assert_eq!( + results.passed, expected_passed, + "votes: ({in_favor}, {against}, {abstain}), pass threshold: {pass_threshold}" + ); + } +} + +#[tokio::test] +async fn calculate_votes_cast_omitted_exclude_abstentions_equals_false() { + for (in_favor, against, abstain) in [(2, 1, 1), (1, 1, 0), (0, 0, 2)] { + // Calculate the results with the flag omitted and set to false + let votes = votes_cast(in_favor, against, abstain); + let mut results = vec![]; + for exclude_abstentions in [None, Some(false)] { + let cfg = votes_cast_cfg(50.0, exclude_abstentions); + results.push(calculate_results(cfg, reactions(&votes), users(5)).await); + } + + // Check both results are identical + assert_eq!( + results[0], results[1], + "votes: ({in_favor}, {against}, {abstain})" + ); + } +} + +/// Generates a test per case provided, checking the results calculated from the +/// configuration, reactions and allowed voters given. macro_rules! test_calculate { ($( $func:ident: @@ -208,8 +358,10 @@ macro_rules! test_calculate { $( #[tokio::test] async fn $func() { - // Calculate vote results and check we get what we expect + // Calculate vote results let results = calculate_results($cfg, $reactions, $allowed_voters).await; + + // Check the calculated results match the expected ones assert_eq!(results, $expected_results); } )* @@ -223,28 +375,28 @@ test_calculate!( reactions: reactions(&votes_cast(1, 0, 0)), allowed_voters: vec![USER1.to_uppercase(), USER2.to_uppercase()], expected_results: VoteResults { - passed: true, - in_favor_percentage: 50.0, - pass_threshold: 50.0, - in_favor: 1, + abstain: 0, against: 0, against_percentage: 0.0, - abstain: 0, - not_voted: 1, + allowed_voters: 2, binding: 1, + in_favor: 1, + in_favor_percentage: 50.0, non_binding: 0, + not_voted: 1, + pass_threshold: 50.0, + passed: true, + pending_voters: vec![USER2.to_uppercase()], votes: BTreeMap::from([ ( USER1.to_string(), UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), binding: true, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + vote_option: VoteOption::InFavor, }, ) ]), - allowed_voters: 2, - pending_voters: vec![USER2.to_uppercase()], } }, @@ -346,6 +498,56 @@ test_calculate!( ) }, + calculate_reaction_logins_are_matched_case_insensitively: + { + cfg: threshold_cfg(50.0), + reactions: vec![reaction("USER1", REACTION_IN_FAVOR)], + allowed_voters: users(2), + expected_results: VoteResults { + abstain: 0, + against: 0, + against_percentage: 0.0, + allowed_voters: 2, + binding: 1, + in_favor: 1, + in_favor_percentage: 50.0, + non_binding: 0, + not_voted: 1, + pass_threshold: 50.0, + passed: true, + pending_voters: vec![USER2.to_string()], + votes: BTreeMap::from([ + ( + "USER1".to_string(), + UserVote { + binding: true, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + vote_option: VoteOption::InFavor, + }, + ) + ]), + } + }, + + calculate_reaction_logins_with_mixed_case_match_allowed_voters_with_mixed_case: + { + cfg: threshold_cfg(100.0), + reactions: vec![ + reaction("uSeR1", REACTION_IN_FAVOR), + reaction("USer2", REACTION_IN_FAVOR), + ], + allowed_voters: vec!["UsEr1".to_string(), "useR2".to_string()], + expected_results: expected_results( + 100.0, + &[ + ("USer2".to_string(), VoteOption::InFavor), + ("uSeR1".to_string(), VoteOption::InFavor), + ], + &["UsEr1".to_string(), "useR2".to_string()], + (true, 100.0, 0.0) + ) + }, + calculate_unsupported_reactions_are_ignored: { cfg: threshold_cfg(50.0), @@ -403,52 +605,52 @@ test_calculate!( reactions: reactions(&[votes_cast(1, 1, 1), vec![(USER5.to_string(), VoteOption::InFavor)]].concat()), allowed_voters: users(4), expected_results: VoteResults { - passed: false, - in_favor_percentage: 25.0, - pass_threshold: 50.0, - in_favor: 1, + abstain: 1, against: 1, against_percentage: 25.0, - abstain: 1, - not_voted: 1, + allowed_voters: 4, binding: 3, + in_favor: 1, + in_favor_percentage: 25.0, non_binding: 1, + not_voted: 1, + pass_threshold: 50.0, + passed: false, + pending_voters: vec![USER4.to_string()], votes: BTreeMap::from([ ( USER1.to_string(), UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), binding: true, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + vote_option: VoteOption::InFavor, }, ), ( USER2.to_string(), UserVote { - vote_option: VoteOption::Against, - timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), binding: true, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + vote_option: VoteOption::Against, }, ), ( USER3.to_string(), UserVote { - vote_option: VoteOption::Abstain, - timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), binding: true, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + vote_option: VoteOption::Abstain, }, ), ( USER5.to_string(), UserVote { - vote_option: VoteOption::InFavor, - timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), binding: false, + timestamp: OffsetDateTime::parse(TIMESTAMP, &Rfc3339).unwrap(), + vote_option: VoteOption::InFavor, }, ), ]), - allowed_voters: 4, - pending_voters: vec![USER4.to_string()], } }, @@ -559,49 +761,20 @@ test_calculate!( }, ); -#[tokio::test] -async fn calculate_strict_majority_recipe() { - // Setup cases (in favor, against, abstain, pass threshold, expected passed) - let cases = [ - (2, 0, 10, STRICT_MAJORITY_THRESHOLD, true), - (2, 2, 0, STRICT_MAJORITY_THRESHOLD, false), - (3, 2, 5, STRICT_MAJORITY_THRESHOLD, true), - (0, 1, 0, STRICT_MAJORITY_THRESHOLD, false), - (0, 0, 3, STRICT_MAJORITY_THRESHOLD, false), - // A tie passes with 50, so it cannot be used to require a strict majority - (2, 2, 0, 50.0, true), - ]; - - for (in_favor, against, abstain, pass_threshold, expected_passed) in cases { - // Calculate the results, leaving one allowed voter pending - let votes = votes_cast(in_favor, against, abstain); - let allowed_voters = users(in_favor + against + abstain + 1); - let cfg = votes_cast_cfg(pass_threshold, Some(true)); - let results = calculate_results(cfg, reactions(&votes), allowed_voters).await; - - // Check the vote passes only when expected - assert_eq!( - results.passed, expected_passed, - "votes: ({in_favor}, {against}, {abstain}), pass threshold: {pass_threshold}" +#[test] +fn threshold_reached_decimal_threshold_met_exactly() { + // Setup cases (in favor, pass threshold, base) + let cases = [(161, 64.4, 250), (33, 8.8, 375), (7, 1.12, 625)]; + + for (in_favor, pass_threshold, base) in cases { + // Check the exact match is reached, unlike one vote less + assert!( + threshold_reached(in_favor, pass_threshold, base), + "case: ({in_favor}, {pass_threshold}, {base})" ); - } -} - -#[tokio::test] -async fn calculate_votes_cast_omitted_exclude_abstentions_equals_false() { - for (in_favor, against, abstain) in [(2, 1, 1), (1, 1, 0), (0, 0, 2)] { - // Calculate the results with the flag omitted and set to false - let votes = votes_cast(in_favor, against, abstain); - let mut results = vec![]; - for exclude_abstentions in [None, Some(false)] { - let cfg = votes_cast_cfg(50.0, exclude_abstentions); - results.push(calculate_results(cfg, reactions(&votes), users(5)).await); - } - - // Check both results are identical - assert_eq!( - results[0], results[1], - "votes: ({in_favor}, {against}, {abstain})" + assert!( + !threshold_reached(in_favor - 1, pass_threshold, base), + "case: ({in_favor}, {pass_threshold}, {base})" ); } } @@ -671,6 +844,7 @@ fn vote_cfg_decode_stored_jsonb() { CfgProfile { duration: Duration::from_mins(5), pass_threshold: 50.0, + allowed_voters: Some(AllowedVoters { users: Some(vec![USER1.to_string()]), ..Default::default() @@ -684,35 +858,195 @@ fn vote_cfg_decode_stored_jsonb() { } } +#[test] +fn vote_cfg_decode_stored_shapes() { + // Setup the fields stored by other versions and the profile they should produce + let cases = [ + // Explicit nulls, written before absent fields were skipped when serializing + (json!({"allowed_voters": null}), CfgProfile::default()), + (json!({"announcements": null}), CfgProfile::default()), + (json!({"close_on_passing": null}), CfgProfile::default()), + (json!({"close_on_passing_min_wait": null}), CfgProfile::default()), + (json!({"pass_threshold_base": null}), CfgProfile::default()), + (json!({"periodic_status_check": null}), CfgProfile::default()), + ( + json!({ + "allowed_voters": {"teams": null, "users": [USER1], "exclude_team_maintainers": null} + }), + CfgProfile { + allowed_voters: Some(AllowedVoters { + users: Some(vec![USER1.to_string()]), + ..Default::default() + }), + ..Default::default() + }, + ), + // Sections without their optional fields + ( + json!({"allowed_voters": {}}), + CfgProfile { + allowed_voters: Some(AllowedVoters::default()), + ..Default::default() + }, + ), + ( + json!({"announcements": {}}), + CfgProfile { + announcements: Some(Announcements::default()), + ..Default::default() + }, + ), + // Unknown fields, as written by a newer version before rolling back + (json!({"future_field": {}}), CfgProfile::default()), + ]; + + for (stored_fields, expected) in cases { + // Setup JSONB value as stored in the vote cfg column (version byte + JSON) + let mut stored = json!({"duration": "5m", "pass_threshold": 50.0}); + stored.as_object_mut().unwrap().extend(stored_fields.as_object().unwrap().clone()); + let mut raw = vec![1_u8]; + raw.extend(serde_json::to_vec(&stored).unwrap()); + + // Check it decodes into the expected profile, as done when reading votes + let Json(cfg) = as FromSql>::from_sql(&Type::JSONB, &raw).unwrap(); + let expected = CfgProfile { + duration: Duration::from_mins(5), + pass_threshold: 50.0, + ..expected + }; + assert_eq!(cfg, expected, "stored: {stored_fields}"); + } +} + #[test] fn vote_option_display() { // Check display strings - assert_eq!(VoteOption::InFavor.to_string(), "In favor"); - assert_eq!(VoteOption::Against.to_string(), "Against"); assert_eq!(VoteOption::Abstain.to_string(), "Abstain"); + assert_eq!(VoteOption::Against.to_string(), "Against"); + assert_eq!(VoteOption::InFavor.to_string(), "In favor"); } #[test] fn vote_option_from_reaction() { // Check supported and unsupported reactions assert_eq!( - VoteOption::from_reaction(REACTION_IN_FAVOR).unwrap(), - VoteOption::InFavor + VoteOption::from_reaction(REACTION_ABSTAIN).unwrap(), + VoteOption::Abstain ); assert_eq!( VoteOption::from_reaction(REACTION_AGAINST).unwrap(), VoteOption::Against ); assert_eq!( - VoteOption::from_reaction(REACTION_ABSTAIN).unwrap(), - VoteOption::Abstain + VoteOption::from_reaction(REACTION_IN_FAVOR).unwrap(), + VoteOption::InFavor ); - assert!(VoteOption::from_reaction("unsupported").is_err()); + assert_eq!( + VoteOption::from_reaction("unsupported").unwrap_err().to_string(), + "reaction not supported" + ); +} + +#[test] +fn vote_results_decode_rows_from_previous_releases() { + // Setup rows as written by previous releases, which must never be updated: a failure + // here means votes already stored cannot be read anymore + let cases: [(&[u8], VoteResults); 2] = [ + // Fields backfilled by migration 004: a single precision percentage, and no + // pending voters even though some did not vote + ( + br#"{"passed":false,"in_favor_percentage":16.666666666666664,"pass_threshold":50.0, + "in_favor":1,"against":4,"against_percentage":66.66666865348816,"abstain":0, + "not_voted":1,"binding":5,"non_binding":0,"allowed_voters":6,"votes":{}, + "pending_voters":[]}"#, + VoteResults { + abstain: 0, + against: 4, + against_percentage: 66.666_668_653_488_16, + allowed_voters: 6, + binding: 5, + in_favor: 1, + in_favor_percentage: 16.666_666_666_666_664, + non_binding: 0, + not_voted: 1, + pass_threshold: 50.0, + passed: false, + pending_voters: vec![], + votes: BTreeMap::new(), + }, + ), + // Percentage backfilled by migration 004 as an integer, as nobody voted against + ( + br#"{"passed":true,"in_favor_percentage":100.0,"pass_threshold":50.0,"in_favor":1, + "against":0,"against_percentage":0,"abstain":0,"not_voted":0,"binding":1, + "non_binding":0,"allowed_voters":1,"votes":{},"pending_voters":[]}"#, + VoteResults { + abstain: 0, + against: 0, + against_percentage: 0.0, + allowed_voters: 1, + binding: 1, + in_favor: 1, + in_favor_percentage: 100.0, + non_binding: 0, + not_voted: 0, + pass_threshold: 50.0, + passed: true, + pending_voters: vec![], + votes: BTreeMap::new(), + }, + ), + ]; + + for (row, expected) in cases { + // Check each row decodes as done when reading votes + let raw = [b"\x01", row].concat(); + let stored = ::from_sql_nullable(&Type::JSONB, Some(&raw)).unwrap(); + let results = stored.and_then(|Json(results)| results).unwrap(); + assert_eq!(results, expected); + + // Render the vote details in the audit page + let vote = setup_test_vote(); + let output = tmpl::AuditVoteDetails { + vote: &vote, + + results: Some(&results), + } + .render() + .unwrap(); + + // Check they are displayed + let in_favor = format!( + r#"{:.0}%"#, + expected.in_favor_percentage + ); + assert!(output.contains(&in_favor), "{in_favor}"); + } +} + +#[test] +fn vote_results_decode_stored_column() { + // Setup the stored forms of the results column and their expected values + let results = setup_test_vote_results(); + let mut object = vec![1]; + object.extend(serde_json::to_vec(&results).unwrap()); + let cases: [(Option<&[u8]>, Option); 3] = [ + (None, None), + (Some(b"\x01null"), None), + (Some(&object), Some(results)), + ]; + + // Check each one decodes as done when reading votes + for (raw, expected) in cases { + let stored = ::from_sql_nullable(&Type::JSONB, raw).unwrap(); + assert_eq!(stored.and_then(|Json(results)| results), expected); + } } #[test] fn vote_results_deserialize_stored_json() { - // Setup JSON as stored in the vote results column + // Setup JSON as stored in the vote results column, keeping the field order it was + // written with let stored = json!({ "passed": false, "in_favor_percentage": 25.0, @@ -740,52 +1074,52 @@ fn vote_results_deserialize_stored_json() { assert_eq!( results, VoteResults { - passed: false, - in_favor_percentage: 25.0, - pass_threshold: 50.0, - in_favor: 1, + abstain: 1, against: 1, against_percentage: 25.0, - abstain: 1, - not_voted: 1, + allowed_voters: 4, binding: 3, + in_favor: 1, + in_favor_percentage: 25.0, non_binding: 1, - allowed_voters: 4, + not_voted: 1, + pass_threshold: 50.0, + passed: false, + pending_voters: vec![USER4.to_string()], votes: BTreeMap::from([ ( USER1.to_string(), UserVote { - vote_option: VoteOption::InFavor, - timestamp, binding: true, + timestamp, + vote_option: VoteOption::InFavor, }, ), ( USER2.to_string(), UserVote { - vote_option: VoteOption::Against, - timestamp, binding: true, + timestamp, + vote_option: VoteOption::Against, }, ), ( USER3.to_string(), UserVote { - vote_option: VoteOption::Abstain, - timestamp, binding: true, + timestamp, + vote_option: VoteOption::Abstain, }, ), ( USER5.to_string(), UserVote { - vote_option: VoteOption::InFavor, - timestamp, binding: false, + timestamp, + vote_option: VoteOption::InFavor, }, ), ]), - pending_voters: vec![USER4.to_string()], } ); } @@ -806,9 +1140,9 @@ fn vote_results_passes_with_all_allowed_voters() { for (in_favor, allowed_voters, pass_threshold, expected) in cases { // Setup results let results = VoteResults { - pass_threshold, - in_favor, allowed_voters, + in_favor, + pass_threshold, ..setup_test_vote_results() }; @@ -827,32 +1161,32 @@ fn vote_results_serialize() { assert_eq!( serde_json::to_value(setup_test_vote_results()).unwrap(), json!({ - "passed": true, - "in_favor_percentage": 100.0, - "pass_threshold": 50.0, - "in_favor": 1, + "abstain": 0, "against": 0, "against_percentage": 0.0, - "abstain": 0, - "not_voted": 0, + "allowed_voters": 1, "binding": 1, + "in_favor": 1, + "in_favor_percentage": 100.0, "non_binding": 0, - "allowed_voters": 1, + "not_voted": 0, + "pass_threshold": 50.0, + "passed": true, + "pending_voters": [], "votes": { "user1": { - "vote_option": "InFavor", + "binding": true, "timestamp": [2022, 334, 10, 0, 0, 0, 0, 0, 0], - "binding": true + "vote_option": "InFavor" } - }, - "pending_voters": [] + } }) ); } // Helpers. -/// Calculate the results of a vote using the configuration, reactions and +/// Calculates the results of a vote using the configuration, reactions and /// allowed voters provided. async fn calculate_results( cfg: CfgProfile, @@ -885,7 +1219,37 @@ async fn calculate_results( calculate(Arc::new(gh), OWNER, REPO, &vote).await.unwrap() } -/// Build the expected results of a vote from the votes cast and allowed voters +/// Calculates the results of the vote provided using the reactions and allowed voters +/// given. +async fn calculate_vote_results( + vote: &Vote, + reactions: Vec, + allowed_voters: Vec, +) -> Result { + // Setup GitHub expectations + let mut gh = MockGH::new(); + gh.expect_get_comment_reactions() + .with(eq(INST_ID), eq(OWNER), eq(REPO), eq(vote.vote_comment_id)) + .times(1) + .return_once(move |_, _, _, _| Box::pin(future::ready(Ok(reactions)))); + let expected_cfg = vote.cfg.clone(); + let expected_org = vote.organization.clone(); + gh.expect_get_allowed_voters() + .withf(move |inst_id, cfg, owner, repo, org| { + *inst_id == INST_ID + && *cfg == expected_cfg + && owner == OWNER + && repo == REPO + && *org == expected_org.as_ref() + }) + .times(1) + .return_once(move |_, _, _, _, _| Box::pin(future::ready(Ok(allowed_voters)))); + + // Calculate vote results + calculate(Arc::new(gh), OWNER, REPO, vote).await +} + +/// Builds the expected results of a vote from the votes cast and allowed voters /// provided, using the outcome given (passed, in favor and against percentages). fn expected_results( pass_threshold: f64, @@ -902,9 +1266,9 @@ fn expected_results( .iter() .map(|(user, vote_option)| { let user_vote = UserVote { - vote_option: vote_option.clone(), - timestamp, binding: is_allowed_voter(user), + timestamp, + vote_option: vote_option.clone(), }; (user.clone(), user_vote) }) @@ -924,50 +1288,56 @@ fn expected_results( .cloned() .collect(); + // Prepare the results VoteResults { - passed, - in_favor_percentage, - pass_threshold, - in_favor: count_binding(Some(VoteOption::InFavor)), + abstain: count_binding(Some(VoteOption::Abstain)), against: count_binding(Some(VoteOption::Against)), against_percentage, - abstain: count_binding(Some(VoteOption::Abstain)), - not_voted: pending_voters.len() as i64, + allowed_voters: allowed_voters.len() as i64, binding, + in_favor: count_binding(Some(VoteOption::InFavor)), + in_favor_percentage, non_binding: votes.len() as i64 - binding, - allowed_voters: allowed_voters.len() as i64, - votes, + not_voted: pending_voters.len() as i64, + pass_threshold, + passed, pending_voters, + votes, } } -/// Setup a reaction from the user provided with the content given. +/// Formats the timestamp provided as GitHub does in a reaction. +fn format_timestamp(timestamp: OffsetDateTime) -> String { + timestamp.format(&Rfc3339).unwrap() +} + +/// Sets up a reaction from the user provided with the content given. fn reaction(user: &str, content: &str) -> Reaction { Reaction { + content: content.to_string(), + created_at: TIMESTAMP.to_string(), user: User { login: user.to_string(), }, - content: content.to_string(), - created_at: TIMESTAMP.to_string(), } } -/// Setup reactions for the votes provided. +/// Sets up reactions for the votes provided. fn reactions(votes: &[(UserName, VoteOption)]) -> Vec { votes .iter() .map(|(user, vote_option)| { let content = match vote_option { - VoteOption::InFavor => REACTION_IN_FAVOR, - VoteOption::Against => REACTION_AGAINST, VoteOption::Abstain => REACTION_ABSTAIN, + VoteOption::Against => REACTION_AGAINST, + VoteOption::InFavor => REACTION_IN_FAVOR, }; reaction(user, content) }) .collect() } -/// Setup a profile using the pass threshold provided and the default pass +/// Sets up a profile using the pass threshold provided and the default pass /// threshold base. fn threshold_cfg(pass_threshold: f64) -> CfgProfile { CfgProfile { @@ -977,12 +1347,12 @@ fn threshold_cfg(pass_threshold: f64) -> CfgProfile { } } -/// Setup a list with the first users (`user1`, `user2`, ...). +/// Sets up a list with the first users (`user1`, `user2`, ...). fn users(count: usize) -> Vec { (1..=count).map(|i| format!("user{i}")).collect() } -/// Setup the votes cast by the first users: in favor first, then against and +/// Sets up the votes cast by the first users: in favor first, then against and /// then abstain (i.e. `(2, 1, 0)` means `user1` and `user2` in favor and `user3` /// against). fn votes_cast(in_favor: usize, against: usize, abstain: usize) -> Vec<(UserName, VoteOption)> { @@ -998,7 +1368,7 @@ fn votes_cast(in_favor: usize, against: usize, abstain: usize) -> Vec<(UserName, .collect() } -/// Setup a profile using the votes cast as the pass threshold base. +/// Sets up a profile using the votes cast as the pass threshold base. fn votes_cast_cfg(pass_threshold: f64, exclude_abstentions: Option) -> CfgProfile { CfgProfile { pass_threshold_base: Some(PassThresholdBase::VotesCast { exclude_abstentions }), diff --git a/src/testdata/event-cmd-profile.json b/src/testdata/event-cmd-profile.json index df7bc7b..dc24b13 100644 --- a/src/testdata/event-cmd-profile.json +++ b/src/testdata/event-cmd-profile.json @@ -1,14 +1,14 @@ { "action": "created", "comment": { - "id": 1234, + "id": 3003, "body": "/vote-profile1" }, "installation": { - "id": 1234 + "id": 1001 }, "issue": { - "id": 1234, + "id": 2002, "number": 1, "title": "Test title" }, diff --git a/src/testdata/event-cmd.json b/src/testdata/event-cmd.json index 4bc2b0a..b264bfd 100644 --- a/src/testdata/event-cmd.json +++ b/src/testdata/event-cmd.json @@ -1,14 +1,14 @@ { "action": "created", "comment": { - "id": 1234, + "id": 3003, "body": "/vote" }, "installation": { - "id": 1234 + "id": 1001 }, "issue": { - "id": 1234, + "id": 2002, "number": 1, "title": "Test title" }, diff --git a/src/testdata/event-no-cmd.json b/src/testdata/event-no-cmd.json index 24de1e7..c21c95b 100644 --- a/src/testdata/event-no-cmd.json +++ b/src/testdata/event-no-cmd.json @@ -1,14 +1,14 @@ { "action": "created", "comment": { - "id": 1234, + "id": 3003, "body": "Hi!" }, "installation": { - "id": 1234 + "id": 1001 }, "issue": { - "id": 1234, + "id": 2002, "number": 1, "title": "Test title" }, diff --git a/src/testdata/event-pr-no-cmd.json b/src/testdata/event-pr-no-cmd.json index a4fe6e5..767b013 100644 --- a/src/testdata/event-pr-no-cmd.json +++ b/src/testdata/event-pr-no-cmd.json @@ -1,10 +1,10 @@ { "action": "opened", "installation": { - "id": 1234 + "id": 1001 }, "pull_request": { - "id": 1234, + "id": 2002, "number": 1, "title": "Test title", "body": "Hi!", diff --git a/src/testdata/templates/audit-vote-details-closed-without-results.golden b/src/testdata/templates/audit-vote-details-closed-without-results.golden new file mode 100644 index 0000000..ebee52f --- /dev/null +++ b/src/testdata/templates/audit-vote-details-closed-without-results.golden @@ -0,0 +1,46 @@ + +
+

Vote details

+
+
+
+ + Issue + +
+ +
+
+
Created by
+
@user
+
+
+
Created
+
2024-03-01 10:00:00.0 +00:00:00
+
+
+
Closed
+
+ + 2024-03-02 10:00:00.0 +00:00:00 +
+
+
+
+ +
+

Results

+
+

Results are not available: the vote comment was deleted before the vote closed.

+
+
diff --git a/src/testdata/templates/audit.golden b/src/testdata/templates/audit.golden index f6b6255..e510a1c 100644 --- a/src/testdata/templates/audit.golden +++ b/src/testdata/templates/audit.golden @@ -1279,7 +1279,7 @@ - + @@ -1175,7 +1175,7 @@

{{ repository_full_name }}

{% else %} Failed {% endif %} - {% else %} + {% else if !vote.closed %} Pending {% endif %} @@ -1259,6 +1259,7 @@

Vote

{% else %} - +