Compare commits
1 commit
main
...
release-1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
578bd909fa |
17 changed files with 673 additions and 1299 deletions
24
.github/workflows/ci.yaml
vendored
Normal file
24
.github/workflows/ci.yaml
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
name: CI
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: Build
|
||||||
|
run: make build
|
||||||
|
env:
|
||||||
|
VERSION: latest
|
||||||
|
- name: Test
|
||||||
|
run: make test
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
.env
|
|
||||||
.idea/
|
|
||||||
go.sum
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
# Contribution Guide
|
|
||||||
|
|
||||||
Thank you for your interest in contributing to this project! We appreciate your efforts to make this project better. Please follow the guidelines below to ensure a smooth contribution process.
|
|
||||||
|
|
||||||
## Guidelines
|
|
||||||
|
|
||||||
### Code Contributions
|
|
||||||
- Follow the existing code style and conventions.
|
|
||||||
- Write meaningful PR titles.
|
|
||||||
- Ensure your changes do not break existing functionality.
|
|
||||||
- Run tests before submitting a pull request (PR).
|
|
||||||
- Build and run your changes in your own environment to ensure they work as expected.
|
|
||||||
- Add or update documentation if necessary.
|
|
||||||
|
|
||||||
### Submitting a Pull Request
|
|
||||||
1. Ensure your branch is up to date with the main branch:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git fetch upstream
|
|
||||||
git checkout main
|
|
||||||
git merge upstream/main
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Push your changes to your fork:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git push origin feature-branch-name
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Open a PR on StackitGit, describing your changes clearly.
|
|
||||||
4. If your PR is related to an issue, use the [StackitGit key words](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/using-keywords-in-issues-and-pull-requests#linking-a-pull-request-to-an-issue).
|
|
||||||
This will ensure that the related issue will automatically close when your change is merged.
|
|
||||||
5. Address any feedback and update your PR as necessary.
|
|
||||||
|
|
||||||
### Issue Reporting
|
|
||||||
- **Check for existing issues before opening a new one.**
|
|
||||||
- Provide clear, concise descriptions with steps to reproduce.
|
|
||||||
- Include logs, screenshots, or example code if relevant.
|
|
||||||
- Suggest possible solutions if applicable.
|
|
||||||
|
|
||||||
## License
|
|
||||||
By contributing, you agree that your contributions will be licensed under the project's [LICENSE](LICENSE).
|
|
||||||
|
|
||||||
Thank you for contributing!
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
FROM golang:1.25 AS builder
|
FROM golang:1.17 AS builder
|
||||||
COPY . /var/app
|
COPY . /var/app
|
||||||
WORKDIR /var/app
|
WORKDIR /var/app
|
||||||
RUN go mod tidy
|
|
||||||
RUN CGO_ENABLED=0 go build -o app .
|
RUN CGO_ENABLED=0 go build -o app .
|
||||||
|
|
||||||
FROM alpine:3.23
|
FROM alpine:3.14
|
||||||
LABEL org.opencontainers.image.source=https://github.com/trstringer/manual-approval
|
LABEL org.opencontainers.image.source https://github.com/trstringer/manual-approval
|
||||||
RUN apk update && apk add ca-certificates
|
RUN apk update && apk add ca-certificates
|
||||||
COPY --from=builder /var/app/app /var/app/app
|
COPY --from=builder /var/app/app /var/app/app
|
||||||
CMD ["/var/app/app"]
|
CMD ["/var/app/app"]
|
||||||
|
|
|
||||||
24
Makefile
24
Makefile
|
|
@ -1,33 +1,21 @@
|
||||||
IMAGE_REPO=ghcr.io/trstringer/manual-approval
|
IMAGE_REPO=ghcr.io/trstringer/manual-approval
|
||||||
TARGET_PLATFORM=linux/amd64,linux/arm64,linux/arm/v8
|
|
||||||
|
|
||||||
.PHONY: tidy
|
|
||||||
tidy:
|
|
||||||
go mod tidy
|
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build:
|
build:
|
||||||
@if [ -z "$(VERSION)" ]; then \
|
@if [ -z "$$VERSION" ]; then \
|
||||||
echo "VERSION is required"; \
|
echo "VERSION is required"; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
docker build -t $(IMAGE_REPO):$(VERSION) .
|
docker build -t $(IMAGE_REPO):$$VERSION .
|
||||||
|
|
||||||
.PHONY: build_push
|
.PHONY: push
|
||||||
build_push:
|
push:
|
||||||
@if [ -z "$(VERSION)" ]; then \
|
@if [ -z "$$VERSION" ]; then \
|
||||||
echo "VERSION is required"; \
|
echo "VERSION is required"; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
docker buildx create --use --name mybuilder
|
docker push $(IMAGE_REPO):$$VERSION
|
||||||
docker buildx build --push --platform $(TARGET_PLATFORM) -t $(IMAGE_REPO):$(VERSION) .
|
|
||||||
docker buildx rm mybuilder
|
|
||||||
|
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test:
|
test:
|
||||||
go test -v .
|
go test -v .
|
||||||
|
|
||||||
.PHONY: lint
|
|
||||||
lint:
|
|
||||||
docker run --rm -v $$(pwd):/app -w /app golangci/golangci-lint:v2.1.6 golangci-lint run -v
|
|
||||||
|
|
|
||||||
141
README.md
141
README.md
|
|
@ -1,13 +1,12 @@
|
||||||
|
|
||||||
# Manual Workflow Approval
|
# Manual Workflow Approval
|
||||||
|
|
||||||
Pause a StackitGit Actions workflow and require manual approval from one or more approvers before continuing.
|
[](https://github.com/trstringer/manual-approval/actions/workflows/ci.yaml)
|
||||||
|
|
||||||
This is a very common feature for a deployment or release pipeline.
|
Pause a GitHub Actions workflow and require manual approval from one or more approvers before continuing.
|
||||||
|
|
||||||
*Note: This approval duration is subject to the broader 4 hours timeout for a workflow, as well as usage costs. So keep that in mind when figuring out how quickly an approver must respond. See [limitations](#limitations) for more information.*
|
This is a very common feature for a deployment or release pipeline, and while [this functionality is available from GitHub](https://docs.github.com/en/actions/managing-workflow-runs/reviewing-deployments), it requires the use of environments and if you want to use this for private repositories then you need GitHub Enterprise. This action provides manual approval without the use of environments, and is freely available to use on private repositories.
|
||||||
|
|
||||||
*Note: A cheaper but less automatic solution using split jobs and workflow dispatcher can be seen in [here](workflow_dispatcher.md)
|
*Note: This approval duration is subject to the broader 72 hours timeout for a workflow. So keep that in mind when figuring out how quickly an approver must respond.*
|
||||||
|
|
||||||
The way this action works is the following:
|
The way this action works is the following:
|
||||||
|
|
||||||
|
|
@ -21,140 +20,18 @@ The way this action works is the following:
|
||||||
|
|
||||||
These are case insensitive with optional punctuation either a period or an exclamation mark.
|
These are case insensitive with optional punctuation either a period or an exclamation mark.
|
||||||
|
|
||||||
In all cases, `manual-approval` will close the initial StackitGit issue.
|
In all cases, `manual-approval` will close the initial GitHub issue.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
steps:
|
steps:
|
||||||
- uses: https://stackit.git.onstackit.cloud/actions/manual-approval@v1
|
- uses: trstringer/manual-approval@v1
|
||||||
with:
|
with:
|
||||||
secret: ${{ github.TOKEN }}
|
secret: ${{ github.TOKEN }}
|
||||||
approvers: user1,user2,org-team1
|
approvers: user1,user2
|
||||||
minimum-approvals: 1
|
minimum-approvals: 1
|
||||||
issue-title: "Deploying v1.3.5 to prod from staging"
|
|
||||||
issue-body: "Please approve or deny the deployment of version v1.3.5."
|
|
||||||
issue-body-file-path: relative/file_path/wrt/repo/root
|
|
||||||
exclude-workflow-initiator-as-approver: false
|
|
||||||
fail-on-denial: true
|
|
||||||
additional-approved-words: ''
|
|
||||||
additional-denied-words: ''
|
|
||||||
polling-interval-seconds: 10
|
|
||||||
```
|
```
|
||||||
|
|
||||||
* `approvers` is a comma-delimited list of all required approvers. An approver can either be a user or an org team. (*Note: Required approvers must have the ability to be set as approvers in the repository. If you add an approver that doesn't have this permission then you would receive an HTTP/402 Validation Failed error when running this action*)
|
- `approvers` is a comma-delimited list of all required approvers.
|
||||||
* `minimum-approvals` is an integer that sets the minimum number of approvals required to progress the workflow. Defaults to ALL approvers.
|
- `minimum-approvals` is an integer that sets the minimum number of approvals required to progress the workflow. Defaults to ALL approvers.
|
||||||
* `issue-title` is a string that will be used as the title of the approval-issue.
|
|
||||||
* `issue-body` is a string that will be added as comments on the approval-issue.
|
|
||||||
* `issue-body-file-path` is a string that is the file path, this file's content will be added as comments on the approval-issue. If both issue-body and issue-body-file-path are given, then the file contents are considered for issue comments.
|
|
||||||
* `exclude-workflow-initiator-as-approver` is a boolean that indicates if the workflow initiator (determined by the `GITHUB_ACTOR` environment variable) should be filtered from the final list of approvers. This is optional and defaults to `false`. Set this to `true` to prevent users in the `approvers` list from being able to self-approve workflows.
|
|
||||||
* `fail-on-denial` is a boolean that indicates if the workflow should fail if any approver denies the approval. This is optional and defaults to `true`. Set this to `false` to allow the workflow to continue if any approver denies the approval.
|
|
||||||
* `additional-approved-words` is a comma separated list of strings to expand the dictionary of words that indicate approval. This is optional and defaults to an empty string.
|
|
||||||
* `additional-denied-words` is a comma separated list of strings to expand the dictionary of words that indicate denial. This is optional and defaults to an empty string.
|
|
||||||
* `polling-interval-seconds` is an integer that sets the number of seconds to wait between polling the StackitGit API for approval status. This is optional and defaults to `10` seconds. Increase this value if you want to reduce API calls, or decrease it for faster response times.
|
|
||||||
|
|
||||||
> [!Note]
|
|
||||||
> 1. If You are using issue-body-file-path then please make sure the file is reachable; for example, if the file is in your repo, then please checkout to your repo in the same job as the approval issue.
|
|
||||||
> 2. When using issue-body, the content string is passed as an arguent which is limited by StackitGit at 10kb. For content >= 10kb, use files for passing the issue body.
|
|
||||||
|
|
||||||
> [!CAUTION]
|
|
||||||
> When using a file please make sure that the file size remains under 125 KB (a safe limit, to stay under the threshold). If the file size is huge, then the file content will be broken into multiple chunks, each representing an issue comment. With this many API requests the API rate limit is exceeded, and the actions will be temporarily blocked, resulting in an error message like: `403 You have exceeded a secondary rate limit and have been temporarily blocked from content creation. Please retry your request again later.`
|
|
||||||
>
|
|
||||||
> 5 MB is a crude estimate, as secondary rate limits apply to a user, so your user (usually the bot using an app token for authentication) will not be able to do anything for some time. Primary limits might still reset quickly, but secondary limits will need some cool-off time.
|
|
||||||
|
|
||||||
|
|
||||||
The file method works unless the file itself is so big that after breaking it into chunks of 65k characters, it exceeds the API limit.
|
|
||||||
|
|
||||||
### Outputs
|
|
||||||
|
|
||||||
* `approval-status` is a string that indicates the final status of the approval. This will be either `approved` or `denied`.
|
|
||||||
|
|
||||||
### Creating Issues in a different repository
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
steps:
|
|
||||||
- uses: https://stackit.git.onstackit.cloud/actions/manual-approval@v1
|
|
||||||
with:
|
|
||||||
secret: ${{ github.TOKEN }}
|
|
||||||
approvers: user1,user2,org-team1
|
|
||||||
minimum-approvals: 1
|
|
||||||
issue-title: "Deploying v1.3.5 to prod from staging"
|
|
||||||
issue-body: "Please approve or deny the deployment of version v1.3.5."
|
|
||||||
exclude-workflow-initiator-as-approver: false
|
|
||||||
additional-approved-words: ''
|
|
||||||
additional-denied-words: ''
|
|
||||||
target-repository: repository-name
|
|
||||||
target-repository-owner: owner-id
|
|
||||||
```
|
|
||||||
- If either of `target-repository` or `target-repository-owner` is missing or is an empty string, then the issue will be created in the same repository where this step is used.
|
|
||||||
|
|
||||||
### Using Custom Words
|
|
||||||
|
|
||||||
StackitGit has a rich library of emojis, and these all work in additional approved words or denied words. Some values StackitGit will store in their text version - i.e. `:shipit:`. Other emojis, StackitGit will store in their Unicode emoji form, like âś….
|
|
||||||
For a seamless experience, it is recommended that you add the custom words to a StackitGit comment, and then copy it back out of the comment into your actions configuration YAML.
|
|
||||||
|
|
||||||
## Org team approver
|
|
||||||
|
|
||||||
If you want to have `approvers` set to an org team, then you need to take a different approach. The default [StackitGit Actions automatic token](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token) does not have the necessary permissions to list out team members. If you would like to use this, then you need to generate a token from a StackitGit App with the correct set of permissions. Apart from this, the StackitGit app will also need the Issue: Read & Write role.
|
|
||||||
|
|
||||||
Create an Organization StackitGit App with **read-only access to organization members**. Once the app is created, add a repo secret with the app ID. In the StackitGit App settings, generate a private key and add that as a secret in the repo as well. You can get the app token by using the [`actions/create-github-app-token`](https://github.com/actions/create-github-app-token) StackitGit Action:
|
|
||||||
|
|
||||||
*Note: The StackitGit App tokens expire after 1 hour, which implies the duration for the approval cannot exceed 60 minutes, or the job will fail due to bad credentials. See [docs](https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app).*
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
jobs:
|
|
||||||
myjob:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Generate token
|
|
||||||
id: generate_token
|
|
||||||
uses: actions/create-github-app-token@v2
|
|
||||||
with:
|
|
||||||
app-id: ${{ secrets.APP_ID }}
|
|
||||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
|
||||||
- name: Wait for approval
|
|
||||||
uses: https://stackit.git.onstackit.cloud/actions/manual-approval@v1
|
|
||||||
with:
|
|
||||||
secret: ${{ steps.generate_token.outputs.token }}
|
|
||||||
approvers: myteam
|
|
||||||
minimum-approvals: 1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Timeout
|
|
||||||
|
|
||||||
If you'd like to force a timeout of your workflow pause, you can specify `timeout-minutes` at either the [step](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepstimeout-minutes) level or the [job](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes) level.
|
|
||||||
|
|
||||||
> [!Note]
|
|
||||||
>
|
|
||||||
> The `timeout-minutes` option has been removed from the `manual-approval` inputs, as it did nothing and incorrectly assured users that they were in fact
|
|
||||||
> getting timeout behavior. Please use one of the below two approaches instead.
|
|
||||||
>
|
|
||||||
> If you are currently using `timeout-minutes` as a `manual-approval` input, you may see a warning, but this will not break your action.
|
|
||||||
|
|
||||||
For instance, if you want your manual approval step to timeout after an hour, you could do the following:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
jobs:
|
|
||||||
approval:
|
|
||||||
steps:
|
|
||||||
- uses: https://stackit.git.onstackit.cloud/actions/manual-approval@v1
|
|
||||||
timeout-minutes: 60
|
|
||||||
# ...
|
|
||||||
```
|
|
||||||
or
|
|
||||||
```yaml
|
|
||||||
jobs:
|
|
||||||
approval:
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- uses: https://stackit.git.onstackit.cloud/actions/manual-approval@v1
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
## Limitations
|
|
||||||
|
|
||||||
* While the workflow is paused, it will still continue to consume a concurrent job allocation out of the max concurrent job of 20.
|
|
||||||
* A paused job is still running compute/instance/virtual machine and will continue to incur costs.
|
|
||||||
* Expirations (also mentioned elsewhere in this document):
|
|
||||||
* A job (including a paused job) will be failed after 4 hours, and a workflow will be failed after 35 days.
|
|
||||||
* StackitGit App tokens expire after 1 hour, which implies the duration for the approval cannot exceed 60 minutes, or the job will fail due to bad credentials.
|
|
||||||
|
|
|
||||||
50
Session.vim
50
Session.vim
|
|
@ -1,50 +0,0 @@
|
||||||
let SessionLoad = 1
|
|
||||||
let s:so_save = &g:so | let s:siso_save = &g:siso | setg so=0 siso=0 | setl so=-1 siso=-1
|
|
||||||
let v:this_session=expand("<sfile>:p")
|
|
||||||
silent only
|
|
||||||
silent tabonly
|
|
||||||
cd ~/dev/manual-approval
|
|
||||||
if expand('%') == '' && !&modified && line('$') <= 1 && getline(1) == ''
|
|
||||||
let s:wipebuf = bufnr('%')
|
|
||||||
endif
|
|
||||||
let s:shortmess_save = &shortmess
|
|
||||||
if &shortmess =~ 'A'
|
|
||||||
set shortmess=aoOA
|
|
||||||
else
|
|
||||||
set shortmess=aoO
|
|
||||||
endif
|
|
||||||
badd +0 action.yaml
|
|
||||||
argglobal
|
|
||||||
%argdel
|
|
||||||
edit action.yaml
|
|
||||||
argglobal
|
|
||||||
balt action.yaml
|
|
||||||
setlocal fdm=expr
|
|
||||||
setlocal fde=nvim_treesitter#foldexpr()
|
|
||||||
setlocal fmr={{{,}}}
|
|
||||||
setlocal fdi=#
|
|
||||||
setlocal fdl=99
|
|
||||||
setlocal fml=1
|
|
||||||
setlocal fdn=20
|
|
||||||
setlocal fen
|
|
||||||
let s:l = 36 - ((35 * winheight(0) + 20) / 41)
|
|
||||||
if s:l < 1 | let s:l = 1 | endif
|
|
||||||
keepjumps exe s:l
|
|
||||||
normal! zt
|
|
||||||
keepjumps 36
|
|
||||||
normal! 058|
|
|
||||||
tabnext 1
|
|
||||||
if exists('s:wipebuf') && len(win_findbuf(s:wipebuf)) == 0 && getbufvar(s:wipebuf, '&buftype') isnot# 'terminal'
|
|
||||||
silent exe 'bwipe ' . s:wipebuf
|
|
||||||
endif
|
|
||||||
unlet! s:wipebuf
|
|
||||||
set winheight=1 winwidth=20
|
|
||||||
let &shortmess = s:shortmess_save
|
|
||||||
let s:sx = expand("<sfile>:p:r")."x.vim"
|
|
||||||
if filereadable(s:sx)
|
|
||||||
exe "source " . fnameescape(s:sx)
|
|
||||||
endif
|
|
||||||
let &g:so = s:so_save | let &g:siso = s:siso_save
|
|
||||||
doautoall SessionLoadPost
|
|
||||||
unlet SessionLoad
|
|
||||||
" vim: set ft=vim :
|
|
||||||
49
action.yaml
49
action.yaml
|
|
@ -1,8 +1,5 @@
|
||||||
name: Manual Workflow Approval
|
name: Manual Workflow Approval
|
||||||
description: Pause a workflow and get user approval to continue
|
description: Pause a workflow and get user approval to continue
|
||||||
branding:
|
|
||||||
icon: pause
|
|
||||||
color: yellow
|
|
||||||
inputs:
|
inputs:
|
||||||
approvers:
|
approvers:
|
||||||
description: Required approvers
|
description: Required approvers
|
||||||
|
|
@ -13,50 +10,6 @@ inputs:
|
||||||
minimum-approvals:
|
minimum-approvals:
|
||||||
description: Minimum number of approvals to progress workflow
|
description: Minimum number of approvals to progress workflow
|
||||||
required: false
|
required: false
|
||||||
issue-title:
|
|
||||||
description: The custom subtitle for the issue
|
|
||||||
required: false
|
|
||||||
issue-body:
|
|
||||||
description: The custom body for the issue
|
|
||||||
required: false
|
|
||||||
issue-body-file-path:
|
|
||||||
description: The file path to a custom body for the issue
|
|
||||||
required: false
|
|
||||||
exclude-workflow-initiator-as-approver:
|
|
||||||
description: Whether or not to filter out the user who initiated the workflow as
|
|
||||||
an approver if they are in the approvers list
|
|
||||||
required: false
|
|
||||||
default: "false"
|
|
||||||
additional-approved-words:
|
|
||||||
description: Comma separated list of words that can be used to approve beyond
|
|
||||||
the defaults.
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
additional-denied-words:
|
|
||||||
description: Comma separated list of words that can be used to deny beyond the defaults.
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
target-repository-owner:
|
|
||||||
description: Owner of the repository in which the issue will be created.
|
|
||||||
default: ""
|
|
||||||
target-repository:
|
|
||||||
description: Name of the repository in which the issue will be created.
|
|
||||||
default: ""
|
|
||||||
fail-on-denial:
|
|
||||||
description: Whether or not to fail the workflow if the approval is denied
|
|
||||||
required: false
|
|
||||||
default: "true"
|
|
||||||
polling-interval-seconds:
|
|
||||||
description: Number of seconds to wait between polling StackitGit API for approval status
|
|
||||||
required: false
|
|
||||||
default: "10"
|
|
||||||
outputs:
|
|
||||||
issue-number:
|
|
||||||
description: The number of the issue created
|
|
||||||
issue-url:
|
|
||||||
description: The URL of the issue created
|
|
||||||
approval-status:
|
|
||||||
description: The status of the approval ("approved" or "denied")
|
|
||||||
runs:
|
runs:
|
||||||
using: docker
|
using: docker
|
||||||
image: Dockerfile
|
image: docker://ghcr.io/trstringer/manual-approval:1.2.0
|
||||||
|
|
|
||||||
218
approval.go
218
approval.go
|
|
@ -3,31 +3,25 @@ package main
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
|
"github.com/google/go-github/v43/github"
|
||||||
)
|
)
|
||||||
|
|
||||||
type approvalEnvironment struct {
|
type approvalEnvironment struct {
|
||||||
forgejoClient *forgejo.Client
|
client *github.Client
|
||||||
repoFullName string
|
repoFullName string
|
||||||
repo string
|
repo string
|
||||||
repoOwner string
|
repoOwner string
|
||||||
runID int
|
runID int
|
||||||
approvalIssue *forgejo.Issue
|
approvers []string
|
||||||
approvalIssueNumber int
|
|
||||||
issueTitle string
|
|
||||||
issueBody string
|
|
||||||
issueApprovers []string
|
|
||||||
minimumApprovals int
|
minimumApprovals int
|
||||||
targetRepoOwner string
|
approvalIssue *github.Issue
|
||||||
targetRepoName string
|
approvalIssueNumber int
|
||||||
failOnDenial bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newApprovalEnvironment(forgejoClient *forgejo.Client, repoFullName, repoOwner string, runID int, approvers []string, minimumApprovals int, issueTitle, issueBody string, targetRepoOwner string, targetRepoName string, failOnDenial bool) (*approvalEnvironment, error) {
|
func newApprovalEnvironment(client *github.Client, repoFullName, repoOwner string, runID int, approvers []string, minimumApprovals int) (*approvalEnvironment, error) {
|
||||||
repoOwnerAndName := strings.Split(repoFullName, "/")
|
repoOwnerAndName := strings.Split(repoFullName, "/")
|
||||||
if len(repoOwnerAndName) != 2 {
|
if len(repoOwnerAndName) != 2 {
|
||||||
return nil, fmt.Errorf("repo owner and name in unexpected format: %s", repoFullName)
|
return nil, fmt.Errorf("repo owner and name in unexpected format: %s", repoFullName)
|
||||||
|
|
@ -35,175 +29,87 @@ func newApprovalEnvironment(forgejoClient *forgejo.Client, repoFullName, repoOwn
|
||||||
repo := repoOwnerAndName[1]
|
repo := repoOwnerAndName[1]
|
||||||
|
|
||||||
return &approvalEnvironment{
|
return &approvalEnvironment{
|
||||||
forgejoClient: forgejoClient,
|
client: client,
|
||||||
repoFullName: repoFullName,
|
repoFullName: repoFullName,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
repoOwner: repoOwner,
|
repoOwner: repoOwner,
|
||||||
runID: runID,
|
runID: runID,
|
||||||
issueApprovers: approvers,
|
approvers: approvers,
|
||||||
minimumApprovals: minimumApprovals,
|
minimumApprovals: minimumApprovals,
|
||||||
issueTitle: issueTitle,
|
|
||||||
issueBody: issueBody,
|
|
||||||
targetRepoOwner: targetRepoOwner,
|
|
||||||
targetRepoName: targetRepoName,
|
|
||||||
failOnDenial: failOnDenial,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a approvalEnvironment) runURL() string {
|
func (a approvalEnvironment) runURL() string {
|
||||||
serverUrl := os.Getenv("GITHUB_SERVER_URL")
|
return fmt.Sprintf("https://github.com/%s/actions/runs/%d", a.repoFullName, a.runID)
|
||||||
if serverUrl == "" {
|
|
||||||
serverUrl = "https://github.com"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s/%s/actions/runs/%d", strings.TrimRight(serverUrl, "/"), a.repoFullName, a.runID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *approvalEnvironment) createApprovalIssue(_ context.Context) error {
|
func (a *approvalEnvironment) createApprovalIssue(ctx context.Context) error {
|
||||||
issueTitle := fmt.Sprintf("Manual approval required for workflow run %d", a.runID)
|
issueTitle := fmt.Sprintf("Manual approval required for workflow run %d", a.runID)
|
||||||
|
issueBody := fmt.Sprintf(`Workflow is pending manual review.
|
||||||
|
URL: %s
|
||||||
|
|
||||||
if a.issueTitle != "" {
|
Required approvers: %s
|
||||||
issueTitle = a.issueTitle
|
|
||||||
}
|
|
||||||
|
|
||||||
approversBody := ""
|
Respond %s to continue workflow or %s to cancel.`,
|
||||||
for _, approver := range a.issueApprovers {
|
|
||||||
approversBody = fmt.Sprintf("%s> * @%s\n", approversBody, approver)
|
|
||||||
}
|
|
||||||
|
|
||||||
issueBody := fmt.Sprintf(`> Workflow is pending manual review.
|
|
||||||
> URL: %s
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> Required approvers:
|
|
||||||
%s
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> Respond %s to continue workflow or %s to cancel.`,
|
|
||||||
a.runURL(),
|
a.runURL(),
|
||||||
approversBody,
|
a.approvers,
|
||||||
formatAcceptedWords(approvedWords),
|
formatAcceptedWords(approvedWords),
|
||||||
formatAcceptedWords(deniedWords),
|
formatAcceptedWords(deniedWords),
|
||||||
)
|
)
|
||||||
|
|
||||||
if a.issueBody != "" {
|
|
||||||
issueBody = fmt.Sprintf("%s\n\n%s", a.issueBody, issueBody)
|
|
||||||
} else {
|
|
||||||
issueBody = fmt.Sprintf(">[!NOTE]\n%s", issueBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
fmt.Printf(
|
a.approvalIssue, _, err = a.client.Issues.Create(ctx, a.repoOwner, a.repo, &github.IssueRequest{
|
||||||
"Creating issue in repo %s/%s with the following content:\nTitle: %s\nApprovers: %s\nBody:\n%s\n",
|
Title: &issueTitle,
|
||||||
a.targetRepoOwner,
|
Body: &issueBody,
|
||||||
a.targetRepoName,
|
Assignees: &a.approvers,
|
||||||
issueTitle,
|
|
||||||
a.issueApprovers,
|
|
||||||
issueBody,
|
|
||||||
)
|
|
||||||
|
|
||||||
created, _, err := a.forgejoClient.CreateIssue(a.targetRepoOwner, a.targetRepoName, forgejo.CreateIssueOption{
|
|
||||||
Title: issueTitle,
|
|
||||||
Body: issueBody,
|
|
||||||
Assignees: a.issueApprovers,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
a.approvalIssueNumber = a.approvalIssue.GetNumber()
|
||||||
return err
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
a.approvalIssue = created
|
|
||||||
a.approvalIssueNumber = int(created.Index)
|
|
||||||
|
|
||||||
fmt.Printf("Issue created: %s\n", a.approvalIssue.HTMLURL)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *approvalEnvironment) SetActionOutputs(outputs map[string]string) (bool, error) {
|
func approvalFromComments(comments []*github.IssueComment, approvers []string, minimumApprovals int) (approvalStatus, error) {
|
||||||
outputFile := os.Getenv("GITHUB_OUTPUT")
|
remainingApprovers := make([]string, len(approvers))
|
||||||
if outputFile == "" {
|
copy(remainingApprovers, approvers)
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := os.OpenFile(outputFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
_ = f.Close() // Error explicitly ignored as there is nothing to handle if file close fails.
|
|
||||||
}()
|
|
||||||
|
|
||||||
var pairs []string
|
|
||||||
|
|
||||||
for key, value := range outputs {
|
|
||||||
pairs = append(pairs, fmt.Sprintf("%s=%s", key, value))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a newline before writing the new outputs if the file is not empty. This prevents
|
|
||||||
// two outputs from being written on the same line.
|
|
||||||
fileInfo, err := f.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if fileInfo.Size() > 0 {
|
|
||||||
if _, err := f.WriteString("\n"); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := f.WriteString(strings.Join(pairs, "\n")); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func approvalFromComments(comments []*forgejo.Comment, approvers []string, minimumApprovals int) (approvalStatus, error) {
|
|
||||||
approvedUsers := []string{}
|
|
||||||
deniedUsers := []string{}
|
|
||||||
// If minimum approvals is not set, default to all approvers
|
|
||||||
if minimumApprovals == 0 {
|
if minimumApprovals == 0 {
|
||||||
minimumApprovals = len(approvers)
|
minimumApprovals = len(approvers)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, comment := range comments {
|
for _, comment := range comments {
|
||||||
commenter := comment.Poster.UserName
|
commentUser := comment.User.GetLogin()
|
||||||
if approversIndex(approvers, commenter) == -1 {
|
approverIdx := approversIndex(remainingApprovers, commentUser)
|
||||||
|
if approverIdx < 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
isApproval, err := isApproved(comment.Body)
|
commentBody := comment.GetBody()
|
||||||
|
isApprovalComment, err := isApproved(commentBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return approvalStatusPending, err
|
||||||
}
|
}
|
||||||
isDenial, err := isDenied(comment.Body)
|
if isApprovalComment {
|
||||||
|
if len(remainingApprovers) == len(approvers)-minimumApprovals+1 {
|
||||||
|
return approvalStatusApproved, nil
|
||||||
|
}
|
||||||
|
remainingApprovers[approverIdx] = remainingApprovers[len(remainingApprovers)-1]
|
||||||
|
remainingApprovers = remainingApprovers[:len(remainingApprovers)-1]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
isDenialComment, err := isDenied(commentBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return approvalStatusPending, err
|
||||||
}
|
}
|
||||||
|
if isDenialComment {
|
||||||
if isApproval {
|
return approvalStatusDenied, nil
|
||||||
approvedUsers = append(approvedUsers, commenter)
|
|
||||||
} else if isDenial {
|
|
||||||
deniedUsers = append(deniedUsers, commenter)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
approvedUsers = deduplicateUsers(approvedUsers)
|
|
||||||
deniedUsers = deduplicateUsers(deniedUsers)
|
|
||||||
|
|
||||||
if len(deniedUsers) > 0 {
|
|
||||||
return approvalStatusDenied, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(approvedUsers) >= minimumApprovals {
|
|
||||||
return approvalStatusApproved, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return approvalStatusPending, nil
|
return approvalStatusPending, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func approversIndex(approvers []string, name string) int {
|
func approversIndex(approvers []string, name string) int {
|
||||||
for idx, approver := range approvers {
|
for idx, approver := range approvers {
|
||||||
if strings.EqualFold(approver, name) {
|
if approver == name {
|
||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -212,14 +118,10 @@ func approversIndex(approvers []string, name string) int {
|
||||||
|
|
||||||
func isApproved(commentBody string) (bool, error) {
|
func isApproved(commentBody string) (bool, error) {
|
||||||
for _, approvedWord := range approvedWords {
|
for _, approvedWord := range approvedWords {
|
||||||
re, err := regexp.Compile(fmt.Sprintf("(?i)^%s[.!]*\n*\\s*$", approvedWord))
|
matched, err := regexp.MatchString(fmt.Sprintf("(?i)^%s[.!]?$", approvedWord), commentBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error parsing. %v", err)
|
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
matched := re.MatchString(commentBody)
|
|
||||||
|
|
||||||
if matched {
|
if matched {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
@ -230,12 +132,10 @@ func isApproved(commentBody string) (bool, error) {
|
||||||
|
|
||||||
func isDenied(commentBody string) (bool, error) {
|
func isDenied(commentBody string) (bool, error) {
|
||||||
for _, deniedWord := range deniedWords {
|
for _, deniedWord := range deniedWords {
|
||||||
re, err := regexp.Compile(fmt.Sprintf("(?i)^%s[.!]*\n*\\s*$", deniedWord))
|
matched, err := regexp.MatchString(fmt.Sprintf("(?i)^%s[.!]?$", deniedWord), commentBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error parsing. %v", err)
|
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
matched := re.MatchString(commentBody)
|
|
||||||
if matched {
|
if matched {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
@ -253,29 +153,3 @@ func formatAcceptedWords(words []string) string {
|
||||||
|
|
||||||
return strings.Join(quotedWords, ", ")
|
return strings.Join(quotedWords, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitLongLine(line string, maxL int) ([]string, bool) {
|
|
||||||
if len(line) <= maxL {
|
|
||||||
return []string{line}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
words := strings.Fields(line)
|
|
||||||
var result []string
|
|
||||||
var currentLine string
|
|
||||||
|
|
||||||
for _, word := range words {
|
|
||||||
if len(currentLine)+len(word)+1 > maxL {
|
|
||||||
result = append(result, currentLine)
|
|
||||||
currentLine = word
|
|
||||||
} else {
|
|
||||||
if currentLine != "" {
|
|
||||||
currentLine += " "
|
|
||||||
}
|
|
||||||
currentLine += word
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if currentLine != "" {
|
|
||||||
result = append(result, currentLine)
|
|
||||||
}
|
|
||||||
return result, true
|
|
||||||
}
|
|
||||||
|
|
|
||||||
407
approval_test.go
407
approval_test.go
|
|
@ -1,12 +1,9 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
|
"github.com/google/go-github/v43/github"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestApprovalFromComments(t *testing.T) {
|
func TestApprovalFromComments(t *testing.T) {
|
||||||
|
|
@ -17,21 +14,19 @@ func TestApprovalFromComments(t *testing.T) {
|
||||||
bodyDenied := "Denied"
|
bodyDenied := "Denied"
|
||||||
bodyPending := "not approval or denial"
|
bodyPending := "not approval or denial"
|
||||||
|
|
||||||
login1u := strings.ToUpper(login1)
|
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
comments []*forgejo.Comment
|
comments []*github.IssueComment
|
||||||
approvers []string
|
approvers []string
|
||||||
minimumApprovals int
|
minimumApprovals int
|
||||||
expectedStatus approvalStatus
|
expectedStatus approvalStatus
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "single_approver_single_comment_approved",
|
name: "single_approver_single_comment_approved",
|
||||||
comments: []*forgejo.Comment{
|
comments: []*github.IssueComment{
|
||||||
{
|
{
|
||||||
Poster: &forgejo.User{UserName: login1},
|
User: &github.User{Login: &login1},
|
||||||
Body: bodyApproved,
|
Body: &bodyApproved,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
approvers: []string{login1},
|
approvers: []string{login1},
|
||||||
|
|
@ -39,10 +34,10 @@ func TestApprovalFromComments(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "single_approver_single_comment_denied",
|
name: "single_approver_single_comment_denied",
|
||||||
comments: []*forgejo.Comment{
|
comments: []*github.IssueComment{
|
||||||
{
|
{
|
||||||
Poster: &forgejo.User{UserName: login1},
|
User: &github.User{Login: &login1},
|
||||||
Body: bodyDenied,
|
Body: &bodyDenied,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
approvers: []string{login1},
|
approvers: []string{login1},
|
||||||
|
|
@ -50,10 +45,10 @@ func TestApprovalFromComments(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "single_approver_single_comment_pending",
|
name: "single_approver_single_comment_pending",
|
||||||
comments: []*forgejo.Comment{
|
comments: []*github.IssueComment{
|
||||||
{
|
{
|
||||||
Poster: &forgejo.User{UserName: login1},
|
User: &github.User{Login: &login1},
|
||||||
Body: bodyPending,
|
Body: &bodyPending,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
approvers: []string{login1},
|
approvers: []string{login1},
|
||||||
|
|
@ -61,14 +56,14 @@ func TestApprovalFromComments(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "single_approver_multi_comment_approved",
|
name: "single_approver_multi_comment_approved",
|
||||||
comments: []*forgejo.Comment{
|
comments: []*github.IssueComment{
|
||||||
{
|
{
|
||||||
Poster: &forgejo.User{UserName: login1},
|
User: &github.User{Login: &login1},
|
||||||
Body: bodyPending,
|
Body: &bodyPending,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Poster: &forgejo.User{UserName: login1},
|
User: &github.User{Login: &login1},
|
||||||
Body: bodyApproved,
|
Body: &bodyApproved,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
approvers: []string{login1},
|
approvers: []string{login1},
|
||||||
|
|
@ -76,14 +71,14 @@ func TestApprovalFromComments(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multi_approver_approved",
|
name: "multi_approver_approved",
|
||||||
comments: []*forgejo.Comment{
|
comments: []*github.IssueComment{
|
||||||
{
|
{
|
||||||
Poster: &forgejo.User{UserName: login1},
|
User: &github.User{Login: &login1},
|
||||||
Body: bodyApproved,
|
Body: &bodyApproved,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Poster: &forgejo.User{UserName: login2},
|
User: &github.User{Login: &login2},
|
||||||
Body: bodyApproved,
|
Body: &bodyApproved,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
approvers: []string{login1, login2},
|
approvers: []string{login1, login2},
|
||||||
|
|
@ -91,14 +86,14 @@ func TestApprovalFromComments(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multi_approver_mixed",
|
name: "multi_approver_mixed",
|
||||||
comments: []*forgejo.Comment{
|
comments: []*github.IssueComment{
|
||||||
{
|
{
|
||||||
Poster: &forgejo.User{UserName: login1},
|
User: &github.User{Login: &login1},
|
||||||
Body: bodyPending,
|
Body: &bodyPending,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Poster: &forgejo.User{UserName: login2},
|
User: &github.User{Login: &login2},
|
||||||
Body: bodyApproved,
|
Body: &bodyApproved,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
approvers: []string{login1, login2},
|
approvers: []string{login1, login2},
|
||||||
|
|
@ -106,14 +101,14 @@ func TestApprovalFromComments(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multi_approver_denied",
|
name: "multi_approver_denied",
|
||||||
comments: []*forgejo.Comment{
|
comments: []*github.IssueComment{
|
||||||
{
|
{
|
||||||
Poster: &forgejo.User{UserName: login1},
|
User: &github.User{Login: &login1},
|
||||||
Body: bodyDenied,
|
Body: &bodyDenied,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Poster: &forgejo.User{UserName: login2},
|
User: &github.User{Login: &login2},
|
||||||
Body: bodyApproved,
|
Body: &bodyApproved,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
approvers: []string{login1, login2},
|
approvers: []string{login1, login2},
|
||||||
|
|
@ -121,14 +116,14 @@ func TestApprovalFromComments(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multi_approver_minimum_one_approval",
|
name: "multi_approver_minimum_one_approval",
|
||||||
comments: []*forgejo.Comment{
|
comments: []*github.IssueComment{
|
||||||
{
|
{
|
||||||
Poster: &forgejo.User{UserName: login1},
|
User: &github.User{Login: &login1},
|
||||||
Body: bodyPending,
|
Body: &bodyPending,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Poster: &forgejo.User{UserName: login2},
|
User: &github.User{Login: &login2},
|
||||||
Body: bodyApproved,
|
Body: &bodyApproved,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
approvers: []string{login1, login2},
|
approvers: []string{login1, login2},
|
||||||
|
|
@ -137,14 +132,14 @@ func TestApprovalFromComments(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multi_approver_minimum_two_approvals",
|
name: "multi_approver_minimum_two_approvals",
|
||||||
comments: []*forgejo.Comment{
|
comments: []*github.IssueComment{
|
||||||
{
|
{
|
||||||
Poster: &forgejo.User{UserName: login1},
|
User: &github.User{Login: &login1},
|
||||||
Body: bodyApproved,
|
Body: &bodyApproved,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Poster: &forgejo.User{UserName: login2},
|
User: &github.User{Login: &login2},
|
||||||
Body: bodyApproved,
|
Body: &bodyApproved,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
approvers: []string{login1, login2, login3},
|
approvers: []string{login1, login2, login3},
|
||||||
|
|
@ -153,27 +148,16 @@ func TestApprovalFromComments(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multi_approver_approvals_less_than_minimum",
|
name: "multi_approver_approvals_less_than_minimum",
|
||||||
comments: []*forgejo.Comment{
|
comments: []*github.IssueComment{
|
||||||
{
|
{
|
||||||
Poster: &forgejo.User{UserName: login1},
|
User: &github.User{Login: &login1},
|
||||||
Body: bodyApproved,
|
Body: &bodyApproved,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
approvers: []string{login1, login2, login3},
|
approvers: []string{login1, login2, login3},
|
||||||
expectedStatus: approvalStatusPending,
|
expectedStatus: approvalStatusPending,
|
||||||
minimumApprovals: 2,
|
minimumApprovals: 2,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "single_approver_single_comment_approved_case_insensitive",
|
|
||||||
comments: []*forgejo.Comment{
|
|
||||||
{
|
|
||||||
Poster: &forgejo.User{UserName: login1u},
|
|
||||||
Body: bodyApproved,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
approvers: []string{login1},
|
|
||||||
expectedStatus: approvalStatusApproved,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, testCase := range testCases {
|
for _, testCase := range testCases {
|
||||||
|
|
@ -192,130 +176,64 @@ func TestApprovalFromComments(t *testing.T) {
|
||||||
|
|
||||||
func TestApprovedCommentBody(t *testing.T) {
|
func TestApprovedCommentBody(t *testing.T) {
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
commentBody string
|
commentBody string
|
||||||
isSuccess bool
|
isSuccess bool
|
||||||
customApprovalWord string
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "approved_lowercase_no_punctuation",
|
name: "approved_lowercase_no_punctuation",
|
||||||
commentBody: "approved",
|
commentBody: "approved",
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
customApprovalWord: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "approve_lowercase_no_punctuation",
|
name: "approve_lowercase_no_punctuation",
|
||||||
commentBody: "approve",
|
commentBody: "approve",
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
customApprovalWord: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "lgtm_lowercase_no_punctuation",
|
name: "lgtm_lowercase_no_punctuation",
|
||||||
commentBody: "lgtm",
|
commentBody: "lgtm",
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
customApprovalWord: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "yes_lowercase_no_punctuation",
|
name: "yes_lowercase_no_punctuation",
|
||||||
commentBody: "yes",
|
commentBody: "yes",
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
customApprovalWord: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "approve_uppercase_no_punctuation",
|
name: "approve_uppercase_no_punctuation",
|
||||||
commentBody: "APPROVE",
|
commentBody: "APPROVE",
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
customApprovalWord: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "approved_titlecase_period",
|
name: "approved_titlecase_period",
|
||||||
commentBody: "Approved.",
|
commentBody: "Approved.",
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
customApprovalWord: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "approved_titlecase_exclamation",
|
name: "approved_titlecase_exclamation",
|
||||||
commentBody: "Approved!",
|
commentBody: "Approved!",
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
customApprovalWord: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "approved_titlecase_multi_exclamation",
|
name: "approved_titlecase_question",
|
||||||
commentBody: "Approved!!",
|
commentBody: "Approved?",
|
||||||
isSuccess: true,
|
isSuccess: false,
|
||||||
customApprovalWord: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "approved_titlecase_question",
|
name: "sentence_with_keyword",
|
||||||
commentBody: "Approved?",
|
commentBody: "should i approve this",
|
||||||
isSuccess: false,
|
isSuccess: false,
|
||||||
customApprovalWord: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "sentence_with_keyword",
|
name: "sentence_without_keyword",
|
||||||
commentBody: "should i approve this",
|
commentBody: "this is just some random comment",
|
||||||
isSuccess: false,
|
isSuccess: false,
|
||||||
customApprovalWord: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "sentence_without_keyword",
|
|
||||||
commentBody: "this is just some random comment",
|
|
||||||
isSuccess: false,
|
|
||||||
customApprovalWord: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "approved_with_newline",
|
|
||||||
commentBody: "approved\n",
|
|
||||||
isSuccess: true,
|
|
||||||
customApprovalWord: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "approved_with_exclamation_newline",
|
|
||||||
commentBody: "approved!\n",
|
|
||||||
isSuccess: true,
|
|
||||||
customApprovalWord: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "approved_with_multi_exclamation_multi_newline",
|
|
||||||
commentBody: "approved!!!\n\n\n",
|
|
||||||
isSuccess: true,
|
|
||||||
customApprovalWord: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "approved_with_custom_approval_word",
|
|
||||||
commentBody: "shipit",
|
|
||||||
isSuccess: true,
|
|
||||||
customApprovalWord: "shipit",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "approved_with_github_emoji_syntax",
|
|
||||||
commentBody: ":shipit:",
|
|
||||||
isSuccess: true,
|
|
||||||
customApprovalWord: ":shipit:",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "approved_with_custom_hashtag",
|
|
||||||
commentBody: "#shipit",
|
|
||||||
isSuccess: true,
|
|
||||||
customApprovalWord: "#shipit",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "approved_with_actual_emoji_âś…",
|
|
||||||
commentBody: "âś… ",
|
|
||||||
isSuccess: true,
|
|
||||||
customApprovalWord: "âś…",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, testCase := range testCases {
|
for _, testCase := range testCases {
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
// before each
|
|
||||||
word := testCase.customApprovalWord
|
|
||||||
if len(word) > 0 {
|
|
||||||
approvedWords = append(approvedWords, word)
|
|
||||||
}
|
|
||||||
|
|
||||||
// test
|
|
||||||
actual, err := isApproved(testCase.commentBody)
|
actual, err := isApproved(testCase.commentBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("error getting approval: %v", err)
|
t.Fatalf("error getting approval: %v", err)
|
||||||
|
|
@ -323,111 +241,65 @@ func TestApprovedCommentBody(t *testing.T) {
|
||||||
if actual != testCase.isSuccess {
|
if actual != testCase.isSuccess {
|
||||||
t.Fatalf("expected %v but got %v", testCase.isSuccess, actual)
|
t.Fatalf("expected %v but got %v", testCase.isSuccess, actual)
|
||||||
}
|
}
|
||||||
|
|
||||||
// after each
|
|
||||||
if len(word) > 0 {
|
|
||||||
approvedWords = approvedWords[:len(approvedWords)-1]
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDeniedCommentBody(t *testing.T) {
|
func TestDeniedCommentBody(t *testing.T) {
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
commentBody string
|
commentBody string
|
||||||
isSuccess bool
|
isSuccess bool
|
||||||
customDenialWord string
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "denied_lowercase_no_punctuation",
|
name: "denied_lowercase_no_punctuation",
|
||||||
commentBody: "denied",
|
commentBody: "denied",
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
customDenialWord: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "deny_lowercase_no_punctuation",
|
name: "deny_lowercase_no_punctuation",
|
||||||
commentBody: "deny",
|
commentBody: "deny",
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
customDenialWord: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no_lowercase_no_punctuation",
|
name: "no_lowercase_no_punctuation",
|
||||||
commentBody: "no",
|
commentBody: "no",
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
customDenialWord: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "deny_uppercase_no_punctuation",
|
name: "deny_uppercase_no_punctuation",
|
||||||
commentBody: "DENY",
|
commentBody: "DENY",
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
customDenialWord: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "denied_titlecase_period",
|
name: "denied_titlecase_period",
|
||||||
commentBody: "Denied.",
|
commentBody: "Denied.",
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
customDenialWord: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "denied_titlecase_exclamation",
|
name: "denied_titlecase_exclamation",
|
||||||
commentBody: "Denied!",
|
commentBody: "Denied!",
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
customDenialWord: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "deny_titlecase_question",
|
name: "deny_titlecase_question",
|
||||||
commentBody: "Deny?",
|
commentBody: "Deny?",
|
||||||
isSuccess: false,
|
isSuccess: false,
|
||||||
customDenialWord: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "sentence_with_keyword",
|
name: "sentence_with_keyword",
|
||||||
commentBody: "should i deny this",
|
commentBody: "should i deny this",
|
||||||
isSuccess: false,
|
isSuccess: false,
|
||||||
customDenialWord: "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "sentence_without_keyword",
|
name: "sentence_without_keyword",
|
||||||
commentBody: "this is just some random comment",
|
commentBody: "this is just some random comment",
|
||||||
isSuccess: false,
|
isSuccess: false,
|
||||||
customDenialWord: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denied_with_newline",
|
|
||||||
commentBody: "denied\n",
|
|
||||||
isSuccess: true,
|
|
||||||
customDenialWord: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denied_with_custom_word",
|
|
||||||
commentBody: "naw",
|
|
||||||
isSuccess: true,
|
|
||||||
customDenialWord: "naw",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denied_with_github_emoji",
|
|
||||||
commentBody: ":no_entry_sign: ",
|
|
||||||
isSuccess: true,
|
|
||||||
customDenialWord: ":no_entry_sign:",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denied_with_hashtag",
|
|
||||||
commentBody: "#noway",
|
|
||||||
isSuccess: true,
|
|
||||||
customDenialWord: "#noway",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, testCase := range testCases {
|
for _, testCase := range testCases {
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
// before each
|
|
||||||
word := testCase.customDenialWord
|
|
||||||
if len(word) > 0 {
|
|
||||||
deniedWords = append(deniedWords, word)
|
|
||||||
}
|
|
||||||
|
|
||||||
// test
|
|
||||||
actual, err := isDenied(testCase.commentBody)
|
actual, err := isDenied(testCase.commentBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("error getting approval: %v", err)
|
t.Fatalf("error getting approval: %v", err)
|
||||||
|
|
@ -435,71 +307,6 @@ func TestDeniedCommentBody(t *testing.T) {
|
||||||
if actual != testCase.isSuccess {
|
if actual != testCase.isSuccess {
|
||||||
t.Fatalf("expected %v but got %v", testCase.isSuccess, actual)
|
t.Fatalf("expected %v but got %v", testCase.isSuccess, actual)
|
||||||
}
|
}
|
||||||
|
|
||||||
// after each
|
|
||||||
if len(word) > 0 {
|
|
||||||
deniedWords = deniedWords[:len(deniedWords)-1]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSaveOutput(t *testing.T) {
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
approvalIssueNumber int
|
|
||||||
env_github_output string
|
|
||||||
isSuccess bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "save_output_with_env",
|
|
||||||
approvalIssueNumber: 123,
|
|
||||||
env_github_output: "./output.txt",
|
|
||||||
isSuccess: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fail_save_output_without_env",
|
|
||||||
approvalIssueNumber: 123,
|
|
||||||
env_github_output: "",
|
|
||||||
isSuccess: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, testCase := range testCases {
|
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
|
||||||
t.Setenv("GITHUB_OUTPUT", testCase.env_github_output)
|
|
||||||
a := approvalEnvironment{
|
|
||||||
forgejoClient: nil,
|
|
||||||
repoFullName: "",
|
|
||||||
repo: "",
|
|
||||||
repoOwner: "",
|
|
||||||
runID: -1,
|
|
||||||
approvalIssueNumber: testCase.approvalIssueNumber,
|
|
||||||
issueTitle: "",
|
|
||||||
issueBody: "",
|
|
||||||
issueApprovers: nil,
|
|
||||||
minimumApprovals: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.Remove(testCase.env_github_output); err != nil && !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("failed to remove file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
actual, err := a.SetActionOutputs(nil)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error creating output file: %v: %v", testCase.env_github_output, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if actual != testCase.isSuccess {
|
|
||||||
t.Fatalf("expected %v but got %v", testCase.isSuccess, actual)
|
|
||||||
}
|
|
||||||
|
|
||||||
if actual == true {
|
|
||||||
if _, err := os.Stat(testCase.env_github_output); errors.Is(err, os.ErrNotExist) {
|
|
||||||
t.Fatalf("expected create output file %v but it was not", testCase.env_github_output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
109
approvers.go
109
approvers.go
|
|
@ -1,109 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
func retrieveApprovers(client *forgejo.Client, repoOwner string) ([]string, error) {
|
|
||||||
workflowInitiator := os.Getenv(envVarWorkflowInitiator)
|
|
||||||
shouldExcludeWorkflowInitiatorRaw := os.Getenv(envVarExcludeWorkflowInitiatorAsApprover)
|
|
||||||
shouldExcludeWorkflowInitiator, parseBoolErr := strconv.ParseBool(shouldExcludeWorkflowInitiatorRaw)
|
|
||||||
if parseBoolErr != nil {
|
|
||||||
return nil, fmt.Errorf("error parsing exclude-workflow-initiator-as-approver flag: %w", parseBoolErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
approvers := []string{}
|
|
||||||
requiredApproversRaw := os.Getenv(envVarApprovers)
|
|
||||||
requiredApprovers := strings.Split(requiredApproversRaw, ",")
|
|
||||||
|
|
||||||
for i := range requiredApprovers {
|
|
||||||
requiredApprovers[i] = strings.TrimSpace(requiredApprovers[i])
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, approverUser := range requiredApprovers {
|
|
||||||
expandedUsers := expandGroupFromUser(client, repoOwner, approverUser, workflowInitiator, shouldExcludeWorkflowInitiator)
|
|
||||||
if len(expandedUsers) > 0 {
|
|
||||||
approvers = append(approvers, expandedUsers...)
|
|
||||||
} else if strings.EqualFold(workflowInitiator, approverUser) && shouldExcludeWorkflowInitiator {
|
|
||||||
fmt.Printf("Not adding user '%s' as an approver as they are the workflow initiator\n", approverUser)
|
|
||||||
} else {
|
|
||||||
approvers = append(approvers, approverUser)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
approvers = deduplicateUsers(approvers)
|
|
||||||
|
|
||||||
minimumApprovalsRaw := os.Getenv(envVarMinimumApprovals)
|
|
||||||
minimumApprovals := len(approvers)
|
|
||||||
var err error
|
|
||||||
if minimumApprovalsRaw != "" {
|
|
||||||
minimumApprovals, err = strconv.Atoi(minimumApprovalsRaw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error parsing minimum number of approvals: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if minimumApprovals > len(approvers) {
|
|
||||||
return nil, fmt.Errorf("error: minimum required approvals (%d) is greater than the total number of approvers (%d)", minimumApprovals, len(approvers))
|
|
||||||
}
|
|
||||||
|
|
||||||
return approvers, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func expandGroupFromUser(client *forgejo.Client, org string, userOrTeam string, workflowInitiator string, shouldExcludeWorkflowInitiator bool) []string {
|
|
||||||
fmt.Printf("Attempting to expand user %s/%s as a group (may not succeed)\n", org, userOrTeam)
|
|
||||||
|
|
||||||
// Try to find a team with this name in the org
|
|
||||||
teams, _, err := client.SearchOrgTeams(org, &forgejo.SearchTeamsOptions{Query: userOrTeam})
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error searching teams: %v\n", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var team *forgejo.Team
|
|
||||||
for _, t := range teams {
|
|
||||||
if strings.EqualFold(t.Name, userOrTeam) {
|
|
||||||
team = t
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if team == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
users, _, err := client.ListTeamMembers(team.ID, forgejo.ListTeamMembersOptions{})
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error listing team members: %v\n", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
userNames := make([]string, 0, len(users))
|
|
||||||
for _, user := range users {
|
|
||||||
userName := user.UserName
|
|
||||||
if strings.EqualFold(userName, workflowInitiator) && shouldExcludeWorkflowInitiator {
|
|
||||||
fmt.Printf("Not adding user '%s' from group '%s' as an approver as they are the workflow initiator\n", userName, userOrTeam)
|
|
||||||
} else {
|
|
||||||
userNames = append(userNames, userName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return userNames
|
|
||||||
}
|
|
||||||
|
|
||||||
func deduplicateUsers(users []string) []string {
|
|
||||||
uniqValuesByKey := make(map[string]bool)
|
|
||||||
uniqUsers := []string{}
|
|
||||||
for _, user := range users {
|
|
||||||
if _, ok := uniqValuesByKey[user]; !ok {
|
|
||||||
uniqValuesByKey[user] = true
|
|
||||||
uniqUsers = append(uniqUsers, user)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return uniqUsers
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDeduplicateUsers(t *testing.T) {
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
input []string
|
|
||||||
expected []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "with_duplicate_user",
|
|
||||||
input: []string{"first", "second", "first"},
|
|
||||||
expected: []string{"first", "second"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "without_duplicate_user",
|
|
||||||
input: []string{"first", "second"},
|
|
||||||
expected: []string{"first", "second"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, testCase := range testCases {
|
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
|
||||||
actual := deduplicateUsers(testCase.input)
|
|
||||||
if !reflect.DeepEqual(testCase.expected, actual) {
|
|
||||||
t.Fatalf(
|
|
||||||
"unequal depulicated: expected %v actual %v",
|
|
||||||
testCase.expected,
|
|
||||||
actual,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
52
constants.go
52
constants.go
|
|
@ -1,51 +1,19 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import "time"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultPollingInterval time.Duration = 10 * time.Second
|
pollingInterval time.Duration = 10 * time.Second
|
||||||
|
|
||||||
envVarRepoFullName string = "GITHUB_REPOSITORY"
|
envVarRepoFullName string = "GITHUB_REPOSITORY"
|
||||||
envVarRunID string = "GITHUB_RUN_ID"
|
envVarRunID string = "GITHUB_RUN_ID"
|
||||||
envVarRepoOwner string = "GITHUB_REPOSITORY_OWNER"
|
envVarRepoOwner string = "GITHUB_REPOSITORY_OWNER"
|
||||||
envVarWorkflowInitiator string = "GITHUB_ACTOR"
|
envVarToken string = "INPUT_SECRET"
|
||||||
envVarToken string = "INPUT_SECRET"
|
envVarApprovers string = "INPUT_APPROVERS"
|
||||||
envVarApprovers string = "INPUT_APPROVERS"
|
envVarMinimumApprovals string = "INPUT_MINIMUM-APPROVALS"
|
||||||
envVarMinimumApprovals string = "INPUT_MINIMUM-APPROVALS"
|
|
||||||
envVarIssueTitle string = "INPUT_ISSUE-TITLE"
|
|
||||||
envVarIssueBody string = "INPUT_ISSUE-BODY"
|
|
||||||
envVarIssueBodyFilePath string = "INPUT_ISSUE-BODY-FILE-PATH"
|
|
||||||
envVarExcludeWorkflowInitiatorAsApprover string = "INPUT_EXCLUDE-WORKFLOW-INITIATOR-AS-APPROVER"
|
|
||||||
envVarAdditionalApprovedWords string = "INPUT_ADDITIONAL-APPROVED-WORDS"
|
|
||||||
envVarAdditionalDeniedWords string = "INPUT_ADDITIONAL-DENIED-WORDS"
|
|
||||||
envVarFailOnDenial string = "INPUT_FAIL-ON-DENIAL"
|
|
||||||
envVarTargetRepoOwner string = "INPUT_TARGET-REPOSITORY-OWNER"
|
|
||||||
envVarTargetRepo string = "INPUT_TARGET-REPOSITORY"
|
|
||||||
envVarPollingIntervalSeconds string = "INPUT_POLLING-INTERVAL-SECONDS"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
additionalApprovedWords = readAdditionalWords(envVarAdditionalApprovedWords)
|
approvedWords = []string{"approved", "approve", "lgtm", "yes"}
|
||||||
additionalDeniedWords = readAdditionalWords(envVarAdditionalDeniedWords)
|
deniedWords = []string{"denied", "deny", "no"}
|
||||||
|
|
||||||
approvedWords = append([]string{"approved", "approve", "lgtm", "yes"}, additionalApprovedWords...)
|
|
||||||
deniedWords = append([]string{"denied", "deny", "no"}, additionalDeniedWords...)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func readAdditionalWords(envVar string) []string {
|
|
||||||
rawValue := strings.TrimSpace(os.Getenv(envVar))
|
|
||||||
if len(rawValue) == 0 {
|
|
||||||
// Nothing else to do here.
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
slicedWords := strings.Split(rawValue, ",")
|
|
||||||
for i := range slicedWords {
|
|
||||||
// no leading or trailing spaces in user provided words.
|
|
||||||
slicedWords[i] = strings.TrimSpace(slicedWords[i])
|
|
||||||
}
|
|
||||||
return slicedWords
|
|
||||||
}
|
|
||||||
|
|
|
||||||
42
go.mod
42
go.mod
|
|
@ -1,35 +1,17 @@
|
||||||
module github.com/trstringer/manual-approval
|
module github.com/trstringer/manual-approval
|
||||||
|
|
||||||
go 1.25
|
go 1.17
|
||||||
|
|
||||||
require codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3 v3.0.0
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/42wim/httpsig v1.2.3 // indirect
|
github.com/google/go-github/v43 v43.0.0
|
||||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a
|
||||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
)
|
||||||
github.com/go-openapi/errors v0.22.6 // indirect
|
|
||||||
github.com/go-openapi/strfmt v0.25.0 // indirect
|
require (
|
||||||
github.com/go-openapi/swag v0.25.4 // indirect
|
github.com/golang/protobuf v1.4.2 // indirect
|
||||||
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect
|
||||||
github.com/go-openapi/swag/fileutils v0.25.4 // indirect
|
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
google.golang.org/appengine v1.6.7 // indirect
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
google.golang.org/protobuf v1.25.0 // indirect
|
||||||
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
|
||||||
github.com/go-openapi/swag/mangling v0.25.4 // indirect
|
|
||||||
github.com/go-openapi/swag/netutils v0.25.4 // indirect
|
|
||||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
|
||||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
|
||||||
github.com/oklog/ulid v1.3.1 // indirect
|
|
||||||
go.mongodb.org/mongo-driver v1.17.9 // indirect
|
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
|
||||||
golang.org/x/net v0.47.0 // indirect
|
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
|
||||||
golang.org/x/text v0.31.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
383
go.sum
Normal file
383
go.sum
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||||
|
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||||
|
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||||
|
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||||
|
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||||
|
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
||||||
|
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
||||||
|
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
||||||
|
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
||||||
|
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
||||||
|
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
||||||
|
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
||||||
|
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
||||||
|
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||||
|
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||||
|
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||||
|
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||||
|
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||||
|
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||||
|
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||||
|
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||||
|
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||||
|
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||||
|
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||||
|
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
||||||
|
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||||
|
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||||
|
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||||
|
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||||
|
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||||
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
|
github.com/bradleyfalzon/ghinstallation/v2 v2.0.4/go.mod h1:B40qPqJxWE0jDZgOR1JmaMy+4AY1eBP+IByOvqyAKp0=
|
||||||
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
|
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
|
||||||
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
|
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||||
|
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
|
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
|
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
|
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
|
github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
|
||||||
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
|
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
|
||||||
|
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
|
||||||
|
github.com/google/go-github/v41 v41.0.0/go.mod h1:XgmCA5H323A9rtgExdTcnDkcqp6S30AVACCBDOonIxg=
|
||||||
|
github.com/google/go-github/v43 v43.0.0 h1:y+GL7LIsAIF2NZlJ46ZoC/D1W1ivZasT0lnWHMYPZ+U=
|
||||||
|
github.com/google/go-github/v43 v43.0.0/go.mod h1:ZkTvvmCXBvsfPpTHXnH/d2hP9Y0cTbvN9kr5xqyXOIc=
|
||||||
|
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||||
|
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||||
|
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||||
|
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
|
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
|
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
|
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||||
|
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||||
|
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||||
|
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
|
||||||
|
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
|
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||||
|
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||||
|
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||||
|
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||||
|
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||||
|
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
|
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||||
|
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||||
|
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||||
|
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||||
|
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk=
|
||||||
|
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a h1:qfl7ob3DIEs3Ml9oLuPwY2N04gymzAW04WsUQHIClgM=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||||
|
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||||
|
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||||
|
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||||
|
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||||
|
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||||
|
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||||
|
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||||
|
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||||
|
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
|
||||||
|
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
||||||
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||||
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||||
|
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||||
|
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
|
||||||
|
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
|
||||||
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
|
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
|
||||||
|
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
|
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||||
|
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||||
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
|
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
|
||||||
|
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||||
|
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||||
|
google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c=
|
||||||
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||||
|
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||||
|
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||||
|
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||||
|
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||||
|
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||||
324
main.go
324
main.go
|
|
@ -4,174 +4,24 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
|
"github.com/google/go-github/v43/github"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// patchIssueState closes or otherwise updates an issue's state without decoding
|
func newGithubClient(ctx context.Context) *github.Client {
|
||||||
// the response body into a github.Issue. go-github's Issues.Edit decodes the
|
|
||||||
// response into github.Issue, which fails against StackitGit because its issue
|
|
||||||
// response embeds "repository.owner" as a plain string rather than a User object.
|
|
||||||
func patchIssueState(_ context.Context, client *forgejo.Client, owner, repo string, number int, state string) error {
|
|
||||||
issueState := forgejo.StateType(state)
|
|
||||||
_, _, err := client.EditIssue(owner, repo, int64(number), forgejo.EditIssueOption{
|
|
||||||
State: &issueState,
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleInterrupt(ctx context.Context, client *forgejo.Client, apprv *approvalEnvironment) {
|
|
||||||
newState := "closed"
|
|
||||||
closeComment := "Workflow cancelled, closing issue."
|
|
||||||
|
|
||||||
fmt.Println(closeComment)
|
|
||||||
_, _, err := client.CreateIssueComment(apprv.targetRepoOwner, apprv.targetRepoName, int64(apprv.approvalIssueNumber), forgejo.CreateIssueCommentOption{
|
|
||||||
Body: closeComment,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("error commenting on issue: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err = patchIssueState(ctx, client, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, newState); err != nil {
|
|
||||||
fmt.Printf("error closing issue: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, client *forgejo.Client, pollingInterval time.Duration) chan int {
|
|
||||||
channel := make(chan int)
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
comments, _, err := client.ListIssueComments(apprv.targetRepoOwner, apprv.targetRepoName, int64(apprv.approvalIssueNumber), forgejo.ListIssueCommentOptions{})
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("error getting comments: %v\n", err)
|
|
||||||
channel <- 1
|
|
||||||
close(channel)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
approved, err := approvalFromComments(comments, apprv.issueApprovers, apprv.minimumApprovals)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("error getting approval from comments: %v\n", err)
|
|
||||||
channel <- 1
|
|
||||||
close(channel)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Printf("Workflow status: %s\n", approved)
|
|
||||||
switch approved {
|
|
||||||
case approvalStatusApproved:
|
|
||||||
newState := "closed"
|
|
||||||
closeComment := fmt.Sprintf("The required number of approvals (%d) has been met; continuing workflow and closing this issue.", apprv.minimumApprovals)
|
|
||||||
_, _, err := client.CreateIssueComment(apprv.targetRepoOwner, apprv.targetRepoName, int64(apprv.approvalIssueNumber), forgejo.CreateIssueCommentOption{
|
|
||||||
Body: closeComment,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("error commenting on issue: %v\n", err)
|
|
||||||
channel <- 1
|
|
||||||
close(channel)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err = patchIssueState(ctx, client, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, newState); err != nil {
|
|
||||||
fmt.Printf("error closing issue: %v\n", err)
|
|
||||||
channel <- 1
|
|
||||||
close(channel)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
channel <- 0
|
|
||||||
fmt.Println("Workflow manual approval completed")
|
|
||||||
close(channel)
|
|
||||||
return
|
|
||||||
case approvalStatusDenied:
|
|
||||||
newState := "closed"
|
|
||||||
closeComment := "Request denied. Closing issue "
|
|
||||||
if !apprv.failOnDenial {
|
|
||||||
closeComment += "but continuing"
|
|
||||||
} else {
|
|
||||||
closeComment += "and failing"
|
|
||||||
}
|
|
||||||
closeComment += " workflow."
|
|
||||||
|
|
||||||
_, _, err := client.CreateIssueComment(apprv.targetRepoOwner, apprv.targetRepoName, int64(apprv.approvalIssueNumber), forgejo.CreateIssueCommentOption{
|
|
||||||
Body: closeComment,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("error commenting on issue: %v\n", err)
|
|
||||||
channel <- 1
|
|
||||||
close(channel)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err = patchIssueState(ctx, client, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, newState); err != nil {
|
|
||||||
fmt.Printf("error closing issue: %v\n", err)
|
|
||||||
channel <- 1
|
|
||||||
close(channel)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
channel <- 1
|
|
||||||
close(channel)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(pollingInterval)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return channel
|
|
||||||
}
|
|
||||||
|
|
||||||
func newForgejoClient() (*forgejo.Client, error) {
|
|
||||||
token := os.Getenv(envVarToken)
|
token := os.Getenv(envVarToken)
|
||||||
serverUrl := os.Getenv("GITHUB_SERVER_URL")
|
ts := oauth2.StaticTokenSource(
|
||||||
if serverUrl == "" {
|
&oauth2.Token{AccessToken: token},
|
||||||
return nil, fmt.Errorf("GITHUB_SERVER_URL must be set for StackitGit client")
|
)
|
||||||
}
|
tc := oauth2.NewClient(ctx, ts)
|
||||||
|
return github.NewClient(tc)
|
||||||
client, err := forgejo.NewClient(serverUrl, forgejo.SetToken(token))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create StackitGit client: %w", err)
|
|
||||||
}
|
|
||||||
return client, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateInput() error {
|
|
||||||
missingEnvVars := []string{}
|
|
||||||
if os.Getenv(envVarRepoFullName) == "" {
|
|
||||||
missingEnvVars = append(missingEnvVars, envVarRepoFullName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.Getenv(envVarRunID) == "" {
|
|
||||||
missingEnvVars = append(missingEnvVars, envVarRunID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.Getenv(envVarRepoOwner) == "" {
|
|
||||||
missingEnvVars = append(missingEnvVars, envVarRepoOwner)
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.Getenv(envVarToken) == "" {
|
|
||||||
missingEnvVars = append(missingEnvVars, envVarToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.Getenv(envVarApprovers) == "" {
|
|
||||||
missingEnvVars = append(missingEnvVars, envVarApprovers)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(missingEnvVars) > 0 {
|
|
||||||
return fmt.Errorf("missing env vars: %v", missingEnvVars)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := validateInput(); err != nil {
|
|
||||||
fmt.Printf("%v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
targetRepoName := os.Getenv(envVarTargetRepo)
|
|
||||||
targetRepoOwner := os.Getenv(envVarTargetRepoOwner)
|
|
||||||
|
|
||||||
repoFullName := os.Getenv(envVarRepoFullName)
|
repoFullName := os.Getenv(envVarRepoFullName)
|
||||||
runID, err := strconv.Atoi(os.Getenv(envVarRunID))
|
runID, err := strconv.Atoi(os.Getenv(envVarRunID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -180,73 +30,29 @@ func main() {
|
||||||
}
|
}
|
||||||
repoOwner := os.Getenv(envVarRepoOwner)
|
repoOwner := os.Getenv(envVarRepoOwner)
|
||||||
|
|
||||||
if targetRepoName == "" || targetRepoOwner == "" {
|
|
||||||
parts := strings.SplitN(repoFullName, "/", 2)
|
|
||||||
targetRepoOwner = parts[0]
|
|
||||||
targetRepoName = parts[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
forgejoClient, err := newForgejoClient()
|
client := newGithubClient(ctx)
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("error connecting to StackitGit server: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
approvers, err := retrieveApprovers(forgejoClient, repoOwner)
|
requiredApproversRaw := os.Getenv(envVarApprovers)
|
||||||
if err != nil {
|
fmt.Printf("Required approvers: %s\n", requiredApproversRaw)
|
||||||
fmt.Printf("error retrieving approvers: %v\n", err)
|
approvers := strings.Split(requiredApproversRaw, ",")
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
failOnDenial := true
|
|
||||||
failOnDenialRaw := os.Getenv(envVarFailOnDenial)
|
|
||||||
if failOnDenialRaw != "" {
|
|
||||||
failOnDenial, err = strconv.ParseBool(failOnDenialRaw)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("error parsing fail on denial: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pollingInterval := defaultPollingInterval
|
|
||||||
pollingIntervalSecondsRaw := os.Getenv(envVarPollingIntervalSeconds)
|
|
||||||
if pollingIntervalSecondsRaw != "" {
|
|
||||||
pollingIntervalSeconds, err := strconv.Atoi(pollingIntervalSecondsRaw)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("error parsing polling interval: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if pollingIntervalSeconds <= 0 {
|
|
||||||
fmt.Printf("error: polling interval must be greater than 0\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
pollingInterval = time.Duration(pollingIntervalSeconds) * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
issueTitle := os.Getenv(envVarIssueTitle)
|
|
||||||
var issueBody string
|
|
||||||
if os.Getenv(envVarIssueBodyFilePath) != "" {
|
|
||||||
fileContents, err := os.ReadFile(os.Getenv(envVarIssueBodyFilePath))
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("error reading issue body file: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
issueBody = string(fileContents)
|
|
||||||
} else {
|
|
||||||
issueBody = os.Getenv(envVarIssueBody)
|
|
||||||
}
|
|
||||||
minimumApprovalsRaw := os.Getenv(envVarMinimumApprovals)
|
minimumApprovalsRaw := os.Getenv(envVarMinimumApprovals)
|
||||||
minimumApprovals := 0
|
minimumApprovals := len(approvers)
|
||||||
if minimumApprovalsRaw != "" {
|
if minimumApprovalsRaw != "" {
|
||||||
minimumApprovals, err = strconv.Atoi(minimumApprovalsRaw)
|
minimumApprovals, err = strconv.Atoi(minimumApprovalsRaw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("error parsing minimum approvals: %v\n", err)
|
fmt.Printf("error parsing minimum number of approvals: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
apprv, err := newApprovalEnvironment(forgejoClient, repoFullName, repoOwner, runID, approvers, minimumApprovals, issueTitle, issueBody, targetRepoOwner, targetRepoName, failOnDenial)
|
if minimumApprovals > len(approvers) {
|
||||||
|
fmt.Printf("error: minimum required approvals (%v) is greater than the total number of approvers (%v)\n", minimumApprovals, len(approvers))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
apprv, err := newApprovalEnvironment(client, repoFullName, repoOwner, runID, approvers, minimumApprovals)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("error creating approval environment: %v\n", err)
|
fmt.Printf("error creating approval environment: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
@ -254,47 +60,61 @@ func main() {
|
||||||
|
|
||||||
err = apprv.createApprovalIssue(ctx)
|
err = apprv.createApprovalIssue(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("error creating issue: %v\n", err)
|
fmt.Printf("error creating issue: %v", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
outputs := map[string]string{
|
commentLoop:
|
||||||
"issue-number": fmt.Sprintf("%d", apprv.approvalIssueNumber),
|
for {
|
||||||
"issue-url": apprv.approvalIssue.HTMLURL,
|
comments, _, err := client.Issues.ListComments(ctx, apprv.repoOwner, apprv.repo, apprv.approvalIssueNumber, &github.IssueListCommentsOptions{})
|
||||||
}
|
if err != nil {
|
||||||
_, err = apprv.SetActionOutputs(outputs)
|
fmt.Printf("error getting comments: %v\n", err)
|
||||||
if err != nil {
|
os.Exit(1)
|
||||||
fmt.Printf("error saving output: %v\n", err)
|
}
|
||||||
os.Exit(1)
|
|
||||||
|
approved, err := approvalFromComments(comments, approvers, minimumApprovals)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error getting approval from comments: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Workflow status: %s\n", approved)
|
||||||
|
switch approved {
|
||||||
|
case approvalStatusApproved:
|
||||||
|
newState := "closed"
|
||||||
|
closeComment := "All approvers have approved, continuing workflow and closing this issue."
|
||||||
|
_, _, err := client.Issues.CreateComment(ctx, apprv.repoOwner, apprv.repo, apprv.approvalIssueNumber, &github.IssueComment{
|
||||||
|
Body: &closeComment,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error commenting on issue: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
_, _, err = client.Issues.Edit(ctx, apprv.repoOwner, apprv.repo, apprv.approvalIssueNumber, &github.IssueRequest{State: &newState})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error closing issue: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
break commentLoop
|
||||||
|
case approvalStatusDenied:
|
||||||
|
newState := "closed"
|
||||||
|
closeComment := "Request denied. Closing issue and failing workflow."
|
||||||
|
_, _, err := client.Issues.CreateComment(ctx, apprv.repoOwner, apprv.repo, apprv.approvalIssueNumber, &github.IssueComment{
|
||||||
|
Body: &closeComment,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error commenting on issue: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
_, _, err = client.Issues.Edit(ctx, apprv.repoOwner, apprv.repo, apprv.approvalIssueNumber, &github.IssueRequest{State: &newState})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error closing issue: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(pollingInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
killSignalChannel := make(chan os.Signal, 1)
|
fmt.Println("Workflow manual approval completed")
|
||||||
signal.Notify(killSignalChannel, os.Interrupt)
|
|
||||||
|
|
||||||
commentLoopChannel := newCommentLoopChannel(ctx, apprv, forgejoClient, pollingInterval)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case exitCode := <-commentLoopChannel:
|
|
||||||
approvalStatus := ""
|
|
||||||
|
|
||||||
if !failOnDenial && exitCode == 1 {
|
|
||||||
approvalStatus = "denied"
|
|
||||||
exitCode = 0
|
|
||||||
} else if exitCode == 1 {
|
|
||||||
approvalStatus = "denied"
|
|
||||||
} else {
|
|
||||||
approvalStatus = "approved"
|
|
||||||
}
|
|
||||||
outputs := map[string]string{
|
|
||||||
"approval-status": approvalStatus,
|
|
||||||
}
|
|
||||||
if _, err := apprv.SetActionOutputs(outputs); err != nil {
|
|
||||||
fmt.Printf("error setting action output: %v\n", err)
|
|
||||||
exitCode = 1
|
|
||||||
}
|
|
||||||
os.Exit(exitCode)
|
|
||||||
case <-killSignalChannel:
|
|
||||||
handleInterrupt(ctx, forgejoClient, apprv)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
# StackitGit Actions: Manual Approvals Guide
|
|
||||||
|
|
||||||
While StackitGit Actions does not currently feature native environment protection rules that pause a running workflow for a UI approval, the most effective and resource-efficient workaround is to split your CI/CD pipeline into separate workflows using the `workflow_dispatch` event.
|
|
||||||
|
|
||||||
> **Resource Efficiency (Cost Savings):**
|
|
||||||
> Unlike workarounds that use third-party actions to poll for comments on an Issue, splitting workflows is **significantly cheaper and more efficient**. Pause-and-poll methods keep the original workflow active, *holding the runner hostage* and consuming compute minutes for hours or days while waiting for an approval. By splitting workflows, the runner is immediately freed after the build phase. A new runner is only provisioned when the deployment is explicitly approved.
|
|
||||||
|
|
||||||
## Step 1: The Automated Build & Test Workflow
|
|
||||||
|
|
||||||
Create your primary workflow that runs automatically on every push or pull request. This workflow handles everything up to the point of deployment.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: 1. Build and Test
|
|
||||||
on: [push]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: stackit-ubuntu-22
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Run tests
|
|
||||||
run: echo "Testing the code..."
|
|
||||||
- name: Build artifact
|
|
||||||
run: echo "Building artifact..."
|
|
||||||
# Upload artifacts here for the deploy workflow to download
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 2: The Manual Deployment Workflow
|
|
||||||
|
|
||||||
Create a second workflow triggered *only* by `workflow_dispatch`. This generates a "Run Workflow" button in the StackitGit UI, serving as your manual approval gate.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: 2. Manual Production Deploy
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: 'Version or Branch to deploy'
|
|
||||||
required: true
|
|
||||||
default: 'main'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: stackit-ubuntu-22
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.inputs.version }}
|
|
||||||
- name: Deploy to Production
|
|
||||||
run: echo "Deploying version ${{ github.event.inputs.version }}..."
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow Execution
|
|
||||||
|
|
||||||
1. A developer pushes code, triggering the **Build and Test** workflow automatically.
|
|
||||||
2. The team reviews the workflow results and test logs. The runner completes its job, reports success, and shuts down.
|
|
||||||
3. When the release is approved, an authorized team member navigates to the StackitGit Actions tab, selects the **Manual Production Deploy** workflow, and clicks "Run Workflow".
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue