Merge pull request '- Fixed Approver Expansion: Repaired the broken expandGroupFromUser function in approvers.go. It now correctly utilizes the Forgejo SDK to search for teams within an organization and expand them into individual users, while respecting the option to exc…' (#1) from stackit into main
Reviewed-on: #1
This commit is contained in:
commit
5a92a14f06
14 changed files with 277 additions and 489 deletions
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -1,29 +0,0 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
- [ ] I have searched through the current issues and did not find any that were related.
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
22
.github/ISSUE_TEMPLATE/feature_request.md
vendored
22
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
|
@ -1,22 +0,0 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
- [ ] I have searched through the current feature requests and did not find any that were related.
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
7
.github/dependabot.yml
vendored
7
.github/dependabot.yml
vendored
|
|
@ -1,7 +0,0 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10 # default: 5
|
||||
34
.github/workflows/ci.yaml
vendored
34
.github/workflows/ci.yaml
vendored
|
|
@ -1,34 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: CI
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Refresh module hashsums
|
||||
run: make tidy
|
||||
- name: Build
|
||||
run: make build
|
||||
env:
|
||||
VERSION: latest
|
||||
|
||||
- name: Test
|
||||
run: make test
|
||||
- name: Lint
|
||||
run: make lint
|
||||
39
.github/workflows/codeql.yml
vendored
39
.github/workflows/codeql.yml
vendored
|
|
@ -1,39 +0,0 @@
|
|||
name: "Code Analysis"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
CodeQL-Build:
|
||||
if: github.repository == 'trstringer/manual-approval'
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Golang
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: go
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
FROM golang:1.24 AS builder
|
||||
FROM golang:1.25 AS builder
|
||||
COPY . /var/app
|
||||
WORKDIR /var/app
|
||||
RUN go mod tidy
|
||||
|
|
|
|||
121
README.md
121
README.md
|
|
@ -1,13 +1,13 @@
|
|||
|
||||
# Manual Workflow Approval
|
||||
|
||||
[](https://github.com/trstringer/manual-approval/actions/workflows/ci.yaml)
|
||||
Pause a StackitGit Actions workflow and require manual approval from one or more approvers before continuing.
|
||||
|
||||
Pause a GitHub Actions workflow and require manual approval from one or more approvers before continuing.
|
||||
This is a very common feature for a deployment or release pipeline.
|
||||
|
||||
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: 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.*
|
||||
|
||||
*Note: This approval duration is subject to the broader 35 day 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.*
|
||||
*Note: A cheaper but less automatic solution using split jobs and workflow dispatcher can be seen in [here](workflow_dispatcher.md)
|
||||
|
||||
The way this action works is the following:
|
||||
|
||||
|
|
@ -21,28 +21,13 @@ The way this action works is the following:
|
|||
|
||||
These are case insensitive with optional punctuation either a period or an exclamation mark.
|
||||
|
||||
In all cases, `manual-approval` will close the initial GitHub issue.
|
||||
|
||||
🖥️ Supported Runners, The action is compatible with the following runner types:
|
||||
- Linux/amd64 — 64-bit Intel/AMD (x86_64)
|
||||
- Linux/arm64 — 64-bit ARM (Apple M1)
|
||||
- Linux/arm/v8 — 64-bit ARM
|
||||
|
||||
🚫 Unsupported
|
||||
- Windows/amd64 — 64-bit Windows systems are currently not supported.
|
||||
- Non-Linux runners of any architecture.
|
||||
|
||||
# v1.9.x → v1.10.0
|
||||
🚨 Update: Approval Issue Content Handling 🚨
|
||||
Starting from v1.10.0, the behaviour for issue contents has changed:
|
||||
- The issue-body and issue-body-file-path are now added as comments on the issue instead of being set as the issue’s main description/body.
|
||||
- The issue title is now exactly what is provided as input, instead of being appended to or wrapped in a predefined string.
|
||||
In all cases, `manual-approval` will close the initial StackitGit issue.
|
||||
|
||||
## Usage
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- uses: trstringer/manual-approval@v1
|
||||
- uses: https://stackit.git.onstackit.cloud/actions/manual-approval@v1
|
||||
with:
|
||||
secret: ${{ github.TOKEN }}
|
||||
approvers: user1,user2,org-team1
|
||||
|
|
@ -66,7 +51,7 @@ steps:
|
|||
* `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 GitHub 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.
|
||||
* `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.
|
||||
|
|
@ -88,7 +73,7 @@ The file method works unless the file itself is so big that after breaking it in
|
|||
|
||||
```yaml
|
||||
steps:
|
||||
- uses: trstringer/manual-approval@v1
|
||||
- uses: https://stackit.git.onstackit.cloud/actions/manual-approval@v1
|
||||
with:
|
||||
secret: ${{ github.TOKEN }}
|
||||
approvers: user1,user2,org-team1
|
||||
|
|
@ -105,16 +90,16 @@ steps:
|
|||
|
||||
### Using Custom Words
|
||||
|
||||
GitHub has a rich library of emojis, and these all work in additional approved words or denied words. Some values GitHub will store in their text version - i.e. `:shipit:`. Other emojis, GitHub will store in their Unicode emoji form, like ✅.
|
||||
For a seamless experience, it is recommended that you add the custom words to a GitHub comment, and then copy it back out of the comment into your actions configuration YAML.
|
||||
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 [GitHub 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 GitHub App with the correct set of permissions. Apart from this, the GH app will also need the Issue: Read & Write role.
|
||||
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 GH app will also need the Issue: Read & Write role.
|
||||
|
||||
Create an Organization GitHub App with **read-only access to organization members**. Once the app is created, add a repo secret with the app ID. In the GitHub 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) GitHub Action:
|
||||
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 GitHub 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).*
|
||||
*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:
|
||||
|
|
@ -128,7 +113,7 @@ jobs:
|
|||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
- name: Wait for approval
|
||||
uses: trstringer/manual-approval@v1
|
||||
uses: https://stackit.git.onstackit.cloud/actions/manual-approval@v1
|
||||
with:
|
||||
secret: ${{ steps.generate_token.outputs.token }}
|
||||
approvers: myteam
|
||||
|
|
@ -152,9 +137,9 @@ For instance, if you want your manual approval step to timeout after an hour, yo
|
|||
jobs:
|
||||
approval:
|
||||
steps:
|
||||
- uses: trstringer/manual-approval@v1
|
||||
- uses: https://stackit.git.onstackit.cloud/actions/manual-approval@v1
|
||||
timeout-minutes: 60
|
||||
...
|
||||
# ...
|
||||
```
|
||||
or
|
||||
```yaml
|
||||
|
|
@ -162,80 +147,14 @@ jobs:
|
|||
approval:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: trstringer/manual-approval@v1
|
||||
- uses: https://stackit.git.onstackit.cloud/actions/manual-approval@v1
|
||||
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
For the action to create a new issue in your project, please ensure that the action has write permissions on issues. You may have to add the following to your workflow:
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
issues: write
|
||||
```
|
||||
|
||||
For more information on permissions, please look at the [GitHub documentation](https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs).
|
||||
|
||||
## Limitations
|
||||
|
||||
* While the workflow is paused, it will still continue to consume a concurrent job allocation out of the [max concurrent jobs](https://docs.github.com/en/actions/learn-github-actions/usage-limits-billing-and-administration#usage-limits).
|
||||
* 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 6 hours, and a workflow will be failed after 35 days](https://docs.github.com/en/actions/learn-github-actions/usage-limits-billing-and-administration#usage-limits).
|
||||
* GitHub 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).
|
||||
* Adding a GH team instead of a user won't circumvent the issue of having a maximum of 10 assignees, GitHub doesn't allow assigning an issue to a team, only to a user.
|
||||
|
||||
## Development
|
||||
|
||||
### Running test code
|
||||
|
||||
To test out your code in an action, you need to build the image and push it to a different container registry repository. For instance, if I want to test some code, I won't build the image with the main image repository. Prior to this, comment out the label binding the image to a repo:
|
||||
|
||||
```dockerfile
|
||||
# LABEL org.opencontainers.image.source https://github.com/trstringer/manual-approval
|
||||
```
|
||||
|
||||
Build the image:
|
||||
|
||||
```shell
|
||||
VERSION=1.7.1-rc.1 make IMAGE_REPO=ghcr.io/trstringer/manual-approval-test build
|
||||
```
|
||||
|
||||
*Note: The image version can be whatever you want, as this image wouldn't be pushed to production. It is only for testing.*
|
||||
|
||||
Push the image to your container registry:
|
||||
|
||||
```shell
|
||||
VERSION=1.7.1-rc.1 make IMAGE_REPO=ghcr.io/trstringer/manual-approval-test push
|
||||
```
|
||||
|
||||
To test out the image, you will need to modify `action.yaml` so that it points to your new image that you're testing:
|
||||
|
||||
```yaml
|
||||
image: docker://ghcr.io/trstringer/manual-approval-test:1.7.0-rc.1
|
||||
```
|
||||
|
||||
Then, to test out the image, run a workflow specifying your dev branch:
|
||||
|
||||
```yaml
|
||||
- name: Wait for approval
|
||||
uses: your-github-user/manual-approval@your-dev-branch
|
||||
with:
|
||||
secret: ${{ secrets.GITHUB_TOKEN }}
|
||||
approvers: trstringer
|
||||
```
|
||||
|
||||
For `uses`, this should point to your repo and dev branch.
|
||||
|
||||
*Note: To test out the action that uses an approver that is an org team, refer to the [org team approver](#org-team-approver) section for instructions.*
|
||||
|
||||
### Create a release
|
||||
|
||||
1. Build and push the new image: `$ VERSION=1.7.0 make build_push`.
|
||||
2. Create a release branch and modify `action.yaml` to point to the new image.
|
||||
3. Open and merge a PR to add these changes to the default branch.
|
||||
4. Make sure to fetch the new changes into your local repo: `$ git checkout main && git fetch origin && git merge origin main`.
|
||||
5. Delete the `v1` tag locally and remotely: `$ git tag -d v1 && git push --delete origin v1`.
|
||||
6. Create and push new tags: `$ git tag v1.7.0 && git tag v1 && git push origin --tags`.
|
||||
7. Create the GitHub project release.
|
||||
* 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.
|
||||
|
|
|
|||
22
action.yaml
22
action.yaml
|
|
@ -23,31 +23,33 @@ inputs:
|
|||
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
|
||||
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'
|
||||
default: "false"
|
||||
additional-approved-words:
|
||||
description: Comma separated list of words that can be used to approve beyond the defaults.
|
||||
description: Comma separated list of words that can be used to approve beyond
|
||||
the defaults.
|
||||
required: false
|
||||
default: ''
|
||||
default: ""
|
||||
additional-denied-words:
|
||||
description: Comma separated list of words that can be used to deny beyond the defaults.
|
||||
required: false
|
||||
default: ''
|
||||
default: ""
|
||||
target-repository-owner:
|
||||
description: Owner of the repository in which the issue will be created.
|
||||
default: ''
|
||||
default: ""
|
||||
target-repository:
|
||||
description: Name of the repository in which the issue will be created.
|
||||
default: ''
|
||||
default: ""
|
||||
fail-on-denial:
|
||||
description: Whether or not to fail the workflow if the approval is denied
|
||||
required: false
|
||||
default: 'true'
|
||||
default: "true"
|
||||
polling-interval-seconds:
|
||||
description: Number of seconds to wait between polling GitHub API for approval status
|
||||
required: false
|
||||
default: '10'
|
||||
default: "10"
|
||||
outputs:
|
||||
issue-number:
|
||||
description: The number of the issue created
|
||||
|
|
@ -57,4 +59,4 @@ outputs:
|
|||
description: The status of the approval ("approved" or "denied")
|
||||
runs:
|
||||
using: docker
|
||||
image: docker://ghcr.io/trstringer/manual-approval:1.12.0
|
||||
image: Dockerfile
|
||||
164
approval.go
164
approval.go
|
|
@ -7,16 +7,16 @@ import (
|
|||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/google/go-github/v43/github"
|
||||
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
|
||||
)
|
||||
|
||||
type approvalEnvironment struct {
|
||||
client *github.Client
|
||||
forgejoClient *forgejo.Client
|
||||
repoFullName string
|
||||
repo string
|
||||
repoOwner string
|
||||
runID int
|
||||
approvalIssue *github.Issue
|
||||
approvalIssue *forgejo.Issue
|
||||
approvalIssueNumber int
|
||||
issueTitle string
|
||||
issueBody string
|
||||
|
|
@ -27,7 +27,7 @@ type approvalEnvironment struct {
|
|||
failOnDenial bool
|
||||
}
|
||||
|
||||
func newApprovalEnvironment(client *github.Client, repoFullName, repoOwner string, runID int, approvers []string, minimumApprovals int, issueTitle, issueBody string, targetRepoOwner string, targetRepoName string, failOnDenial bool) (*approvalEnvironment, error) {
|
||||
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) {
|
||||
repoOwnerAndName := strings.Split(repoFullName, "/")
|
||||
if len(repoOwnerAndName) != 2 {
|
||||
return nil, fmt.Errorf("repo owner and name in unexpected format: %s", repoFullName)
|
||||
|
|
@ -35,7 +35,7 @@ func newApprovalEnvironment(client *github.Client, repoFullName, repoOwner strin
|
|||
repo := repoOwnerAndName[1]
|
||||
|
||||
return &approvalEnvironment{
|
||||
client: client,
|
||||
forgejoClient: forgejoClient,
|
||||
repoFullName: repoFullName,
|
||||
repo: repo,
|
||||
repoOwner: repoOwner,
|
||||
|
|
@ -58,7 +58,7 @@ func (a approvalEnvironment) runURL() string {
|
|||
return fmt.Sprintf("%s/%s/actions/runs/%d", strings.TrimRight(serverUrl, "/"), a.repoFullName, a.runID)
|
||||
}
|
||||
|
||||
func (a *approvalEnvironment) createApprovalIssue(ctx context.Context) error {
|
||||
func (a *approvalEnvironment) createApprovalIssue(_ context.Context) error {
|
||||
issueTitle := fmt.Sprintf("Manual approval required for workflow run %d", a.runID)
|
||||
|
||||
if a.issueTitle != "" {
|
||||
|
|
@ -85,7 +85,11 @@ func (a *approvalEnvironment) createApprovalIssue(ctx context.Context) error {
|
|||
formatAcceptedWords(deniedWords),
|
||||
)
|
||||
|
||||
issueBody = fmt.Sprintf(">[!NOTE]\n%s", issueBody)
|
||||
if a.issueBody != "" {
|
||||
issueBody = fmt.Sprintf("%s\n\n%s", a.issueBody, issueBody)
|
||||
} else {
|
||||
issueBody = fmt.Sprintf(">[!NOTE]\n%s", issueBody)
|
||||
}
|
||||
|
||||
var err error
|
||||
fmt.Printf(
|
||||
|
|
@ -96,46 +100,20 @@ func (a *approvalEnvironment) createApprovalIssue(ctx context.Context) error {
|
|||
a.issueApprovers,
|
||||
issueBody,
|
||||
)
|
||||
// Use NewRequest+Do with a minimal response struct rather than client.Issues.Create.
|
||||
// Forgejo's issue response includes "repository.owner" as a plain string, but
|
||||
// go-github's Repository.Owner is a *User struct, causing an unmarshal error.
|
||||
// Our minimal struct omits Repository entirely, so the field is ignored.
|
||||
type createIssueResponse struct {
|
||||
Number int `json:"number"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
req, err := a.client.NewRequest("POST",
|
||||
fmt.Sprintf("repos/%s/%s/issues", a.targetRepoOwner, a.targetRepoName),
|
||||
&github.IssueRequest{
|
||||
Title: &issueTitle,
|
||||
Body: &issueBody,
|
||||
Assignees: &a.issueApprovers,
|
||||
},
|
||||
)
|
||||
|
||||
created, _, err := a.forgejoClient.CreateIssue(a.targetRepoOwner, a.targetRepoName, forgejo.CreateIssueOption{
|
||||
Title: issueTitle,
|
||||
Body: issueBody,
|
||||
Assignees: a.issueApprovers,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var created createIssueResponse
|
||||
if _, err = a.client.Do(ctx, req, &created); err != nil {
|
||||
return err
|
||||
}
|
||||
a.approvalIssueNumber = created.Number
|
||||
a.approvalIssue = &github.Issue{
|
||||
Number: &created.Number,
|
||||
HTMLURL: &created.HTMLURL,
|
||||
}
|
||||
|
||||
bodyChunks := splitLongString(a.issueBody)
|
||||
for _, chunk := range bodyChunks {
|
||||
_, _, err = a.client.Issues.CreateComment(ctx, a.targetRepoOwner, a.targetRepoName, created.Number, &github.IssueComment{
|
||||
Body: &chunk,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add comment chunk to issue: %w", err)
|
||||
}
|
||||
}
|
||||
a.approvalIssue = created
|
||||
a.approvalIssueNumber = int(created.ID)
|
||||
|
||||
fmt.Printf("Issue created: %s\n", a.approvalIssue.GetHTMLURL())
|
||||
fmt.Printf("Issue created: %s\n", a.approvalIssue.HTMLURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -150,9 +128,9 @@ func (a *approvalEnvironment) SetActionOutputs(outputs map[string]string) (bool,
|
|||
return false, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = f.Close() // Error explicitly ignored as there is nothing to handle if file close fails.
|
||||
}()
|
||||
defer func() {
|
||||
_ = f.Close() // Error explicitly ignored as there is nothing to handle if file close fails.
|
||||
}()
|
||||
|
||||
var pairs []string
|
||||
|
||||
|
|
@ -179,44 +157,47 @@ func (a *approvalEnvironment) SetActionOutputs(outputs map[string]string) (bool,
|
|||
return true, nil
|
||||
}
|
||||
|
||||
func approvalFromComments(comments []*github.IssueComment, approvers []string, minimumApprovals int) (approvalStatus, error) {
|
||||
remainingApprovers := make([]string, len(approvers))
|
||||
copy(remainingApprovers, approvers)
|
||||
|
||||
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 {
|
||||
minimumApprovals = len(approvers)
|
||||
}
|
||||
|
||||
for _, comment := range comments {
|
||||
commentUser := comment.User.GetLogin()
|
||||
approverIdx := approversIndex(remainingApprovers, commentUser)
|
||||
if approverIdx < 0 {
|
||||
commenter := comment.Poster.UserName
|
||||
if approversIndex(approvers, commenter) == -1 {
|
||||
continue
|
||||
}
|
||||
|
||||
commentBody := comment.GetBody()
|
||||
isApprovalComment, err := isApproved(commentBody)
|
||||
isApproval, err := isApproved(comment.Body)
|
||||
if err != nil {
|
||||
return approvalStatusPending, err
|
||||
return "", err
|
||||
}
|
||||
if isApprovalComment {
|
||||
if len(remainingApprovers) == len(approvers)-minimumApprovals+1 {
|
||||
return approvalStatusApproved, nil
|
||||
}
|
||||
remainingApprovers[approverIdx] = remainingApprovers[len(remainingApprovers)-1]
|
||||
remainingApprovers = remainingApprovers[:len(remainingApprovers)-1]
|
||||
continue
|
||||
isDenial, err := isDenied(comment.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
isDenialComment, err := isDenied(commentBody)
|
||||
if err != nil {
|
||||
return approvalStatusPending, err
|
||||
}
|
||||
if isDenialComment {
|
||||
return approvalStatusDenied, nil
|
||||
if isApproval {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -298,50 +279,3 @@ func splitLongLine(line string, maxL int) ([]string, bool) {
|
|||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
func splitLongString(input string) []string {
|
||||
maxLength := 65536
|
||||
var result []string
|
||||
|
||||
lines := strings.Split(input, "\n")
|
||||
currentChunk := strings.Builder{}
|
||||
currentLength := 0
|
||||
|
||||
for i, line := range lines {
|
||||
lineLength := len(line)
|
||||
if i < len(lines)-1 {
|
||||
lineLength++
|
||||
}
|
||||
|
||||
if currentLength+lineLength > maxLength {
|
||||
if currentChunk.Len() > 0 {
|
||||
result = append(result, currentChunk.String())
|
||||
currentChunk.Reset()
|
||||
currentLength = 0
|
||||
}
|
||||
}
|
||||
|
||||
lineSplit, isLongLine := splitLongLine(line, maxLength)
|
||||
if isLongLine {
|
||||
if currentChunk.Len() > 0 {
|
||||
result = append(result, currentChunk.String())
|
||||
currentChunk.Reset()
|
||||
}
|
||||
result = append(result, lineSplit[:len(lineSplit)-1]...)
|
||||
currentChunk.WriteString(lineSplit[len(lineSplit)-1])
|
||||
currentLength = len(lineSplit[len(lineSplit)-1])
|
||||
} else {
|
||||
currentChunk.WriteString(line)
|
||||
currentLength += lineLength
|
||||
}
|
||||
|
||||
if i < len(lines)-1 {
|
||||
currentChunk.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if currentChunk.Len() > 0 {
|
||||
result = append(result, currentChunk.String())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
|
|||
102
approval_test.go
102
approval_test.go
|
|
@ -6,7 +6,7 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-github/v43/github"
|
||||
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
|
||||
)
|
||||
|
||||
func TestApprovalFromComments(t *testing.T) {
|
||||
|
|
@ -21,17 +21,17 @@ func TestApprovalFromComments(t *testing.T) {
|
|||
|
||||
testCases := []struct {
|
||||
name string
|
||||
comments []*github.IssueComment
|
||||
comments []*forgejo.Comment
|
||||
approvers []string
|
||||
minimumApprovals int
|
||||
expectedStatus approvalStatus
|
||||
}{
|
||||
{
|
||||
name: "single_approver_single_comment_approved",
|
||||
comments: []*github.IssueComment{
|
||||
comments: []*forgejo.Comment{
|
||||
{
|
||||
User: &github.User{Login: &login1},
|
||||
Body: &bodyApproved,
|
||||
Poster: &forgejo.User{UserName: login1},
|
||||
Body: bodyApproved,
|
||||
},
|
||||
},
|
||||
approvers: []string{login1},
|
||||
|
|
@ -39,10 +39,10 @@ func TestApprovalFromComments(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "single_approver_single_comment_denied",
|
||||
comments: []*github.IssueComment{
|
||||
comments: []*forgejo.Comment{
|
||||
{
|
||||
User: &github.User{Login: &login1},
|
||||
Body: &bodyDenied,
|
||||
Poster: &forgejo.User{UserName: login1},
|
||||
Body: bodyDenied,
|
||||
},
|
||||
},
|
||||
approvers: []string{login1},
|
||||
|
|
@ -50,10 +50,10 @@ func TestApprovalFromComments(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "single_approver_single_comment_pending",
|
||||
comments: []*github.IssueComment{
|
||||
comments: []*forgejo.Comment{
|
||||
{
|
||||
User: &github.User{Login: &login1},
|
||||
Body: &bodyPending,
|
||||
Poster: &forgejo.User{UserName: login1},
|
||||
Body: bodyPending,
|
||||
},
|
||||
},
|
||||
approvers: []string{login1},
|
||||
|
|
@ -61,14 +61,14 @@ func TestApprovalFromComments(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "single_approver_multi_comment_approved",
|
||||
comments: []*github.IssueComment{
|
||||
comments: []*forgejo.Comment{
|
||||
{
|
||||
User: &github.User{Login: &login1},
|
||||
Body: &bodyPending,
|
||||
Poster: &forgejo.User{UserName: login1},
|
||||
Body: bodyPending,
|
||||
},
|
||||
{
|
||||
User: &github.User{Login: &login1},
|
||||
Body: &bodyApproved,
|
||||
Poster: &forgejo.User{UserName: login1},
|
||||
Body: bodyApproved,
|
||||
},
|
||||
},
|
||||
approvers: []string{login1},
|
||||
|
|
@ -76,14 +76,14 @@ func TestApprovalFromComments(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "multi_approver_approved",
|
||||
comments: []*github.IssueComment{
|
||||
comments: []*forgejo.Comment{
|
||||
{
|
||||
User: &github.User{Login: &login1},
|
||||
Body: &bodyApproved,
|
||||
Poster: &forgejo.User{UserName: login1},
|
||||
Body: bodyApproved,
|
||||
},
|
||||
{
|
||||
User: &github.User{Login: &login2},
|
||||
Body: &bodyApproved,
|
||||
Poster: &forgejo.User{UserName: login2},
|
||||
Body: bodyApproved,
|
||||
},
|
||||
},
|
||||
approvers: []string{login1, login2},
|
||||
|
|
@ -91,14 +91,14 @@ func TestApprovalFromComments(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "multi_approver_mixed",
|
||||
comments: []*github.IssueComment{
|
||||
comments: []*forgejo.Comment{
|
||||
{
|
||||
User: &github.User{Login: &login1},
|
||||
Body: &bodyPending,
|
||||
Poster: &forgejo.User{UserName: login1},
|
||||
Body: bodyPending,
|
||||
},
|
||||
{
|
||||
User: &github.User{Login: &login2},
|
||||
Body: &bodyApproved,
|
||||
Poster: &forgejo.User{UserName: login2},
|
||||
Body: bodyApproved,
|
||||
},
|
||||
},
|
||||
approvers: []string{login1, login2},
|
||||
|
|
@ -106,14 +106,14 @@ func TestApprovalFromComments(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "multi_approver_denied",
|
||||
comments: []*github.IssueComment{
|
||||
comments: []*forgejo.Comment{
|
||||
{
|
||||
User: &github.User{Login: &login1},
|
||||
Body: &bodyDenied,
|
||||
Poster: &forgejo.User{UserName: login1},
|
||||
Body: bodyDenied,
|
||||
},
|
||||
{
|
||||
User: &github.User{Login: &login2},
|
||||
Body: &bodyApproved,
|
||||
Poster: &forgejo.User{UserName: login2},
|
||||
Body: bodyApproved,
|
||||
},
|
||||
},
|
||||
approvers: []string{login1, login2},
|
||||
|
|
@ -121,14 +121,14 @@ func TestApprovalFromComments(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "multi_approver_minimum_one_approval",
|
||||
comments: []*github.IssueComment{
|
||||
comments: []*forgejo.Comment{
|
||||
{
|
||||
User: &github.User{Login: &login1},
|
||||
Body: &bodyPending,
|
||||
Poster: &forgejo.User{UserName: login1},
|
||||
Body: bodyPending,
|
||||
},
|
||||
{
|
||||
User: &github.User{Login: &login2},
|
||||
Body: &bodyApproved,
|
||||
Poster: &forgejo.User{UserName: login2},
|
||||
Body: bodyApproved,
|
||||
},
|
||||
},
|
||||
approvers: []string{login1, login2},
|
||||
|
|
@ -137,14 +137,14 @@ func TestApprovalFromComments(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "multi_approver_minimum_two_approvals",
|
||||
comments: []*github.IssueComment{
|
||||
comments: []*forgejo.Comment{
|
||||
{
|
||||
User: &github.User{Login: &login1},
|
||||
Body: &bodyApproved,
|
||||
Poster: &forgejo.User{UserName: login1},
|
||||
Body: bodyApproved,
|
||||
},
|
||||
{
|
||||
User: &github.User{Login: &login2},
|
||||
Body: &bodyApproved,
|
||||
Poster: &forgejo.User{UserName: login2},
|
||||
Body: bodyApproved,
|
||||
},
|
||||
},
|
||||
approvers: []string{login1, login2, login3},
|
||||
|
|
@ -153,10 +153,10 @@ func TestApprovalFromComments(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "multi_approver_approvals_less_than_minimum",
|
||||
comments: []*github.IssueComment{
|
||||
comments: []*forgejo.Comment{
|
||||
{
|
||||
User: &github.User{Login: &login1},
|
||||
Body: &bodyApproved,
|
||||
Poster: &forgejo.User{UserName: login1},
|
||||
Body: bodyApproved,
|
||||
},
|
||||
},
|
||||
approvers: []string{login1, login2, login3},
|
||||
|
|
@ -165,10 +165,10 @@ func TestApprovalFromComments(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "single_approver_single_comment_approved_case_insensitive",
|
||||
comments: []*github.IssueComment{
|
||||
comments: []*forgejo.Comment{
|
||||
{
|
||||
User: &github.User{Login: &login1u},
|
||||
Body: &bodyApproved,
|
||||
Poster: &forgejo.User{UserName: login1u},
|
||||
Body: bodyApproved,
|
||||
},
|
||||
},
|
||||
approvers: []string{login1},
|
||||
|
|
@ -469,7 +469,7 @@ func TestSaveOutput(t *testing.T) {
|
|||
t.Run(testCase.name, func(t *testing.T) {
|
||||
t.Setenv("GITHUB_OUTPUT", testCase.env_github_output)
|
||||
a := approvalEnvironment{
|
||||
client: nil,
|
||||
forgejoClient: nil,
|
||||
repoFullName: "",
|
||||
repo: "",
|
||||
repoOwner: "",
|
||||
|
|
@ -481,9 +481,9 @@ func TestSaveOutput(t *testing.T) {
|
|||
minimumApprovals: 0,
|
||||
}
|
||||
|
||||
if err := os.Remove(testCase.env_github_output); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatalf("failed to remove file: %v", err)
|
||||
}
|
||||
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)
|
||||
|
||||
|
|
|
|||
39
approvers.go
39
approvers.go
|
|
@ -1,16 +1,15 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/go-github/v43/github"
|
||||
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
|
||||
)
|
||||
|
||||
func retrieveApprovers(client *github.Client, repoOwner string) ([]string, error) {
|
||||
func retrieveApprovers(client *forgejo.Client, repoOwner string) ([]string, error) {
|
||||
workflowInitiator := os.Getenv(envVarWorkflowInitiator)
|
||||
shouldExcludeWorkflowInitiatorRaw := os.Getenv(envVarExcludeWorkflowInitiatorAsApprover)
|
||||
shouldExcludeWorkflowInitiator, parseBoolErr := strconv.ParseBool(shouldExcludeWorkflowInitiatorRaw)
|
||||
|
|
@ -28,7 +27,7 @@ func retrieveApprovers(client *github.Client, repoOwner string) ([]string, error
|
|||
|
||||
for _, approverUser := range requiredApprovers {
|
||||
expandedUsers := expandGroupFromUser(client, repoOwner, approverUser, workflowInitiator, shouldExcludeWorkflowInitiator)
|
||||
if expandedUsers != nil {
|
||||
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)
|
||||
|
|
@ -56,23 +55,37 @@ func retrieveApprovers(client *github.Client, repoOwner string) ([]string, error
|
|||
return approvers, nil
|
||||
}
|
||||
|
||||
func expandGroupFromUser(client *github.Client, org, userOrTeam string, workflowInitiator string, shouldExcludeWorkflowInitiator bool) []string {
|
||||
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)
|
||||
|
||||
// GitHub replaces periods in the team name with hyphens. If a period is
|
||||
// passed to the request it would result in a 404. So we need to replace
|
||||
// and occurrences with a hyphen.
|
||||
formattedUserOrTeam := strings.ReplaceAll(userOrTeam, ".", "-")
|
||||
|
||||
users, _, err := client.Teams.ListTeamMembersBySlug(context.Background(), org, formattedUserOrTeam, &github.TeamListTeamMembersOptions{})
|
||||
// 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("%v\n", err)
|
||||
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.GetLogin()
|
||||
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 {
|
||||
|
|
|
|||
36
go.mod
36
go.mod
|
|
@ -1,13 +1,35 @@
|
|||
module github.com/trstringer/manual-approval
|
||||
|
||||
go 1.24.0
|
||||
go 1.25
|
||||
|
||||
require codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3 v3.0.0
|
||||
|
||||
require (
|
||||
github.com/google/go-github/v43 v43.0.0
|
||||
golang.org/x/oauth2 v0.33.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/42wim/httpsig v1.2.3 // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
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
|
||||
github.com/go-openapi/swag v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 // 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
|
||||
)
|
||||
|
|
|
|||
92
main.go
92
main.go
|
|
@ -3,40 +3,34 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-github/v43/github"
|
||||
"golang.org/x/oauth2"
|
||||
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3"
|
||||
)
|
||||
|
||||
// patchIssueState closes or otherwise updates an issue's state without decoding
|
||||
// the response body into a github.Issue. go-github's Issues.Edit decodes the
|
||||
// response into github.Issue, which fails against Forgejo because its issue
|
||||
// response embeds "repository.owner" as a plain string rather than a User object.
|
||||
func patchIssueState(ctx context.Context, client *github.Client, owner, repo string, number int, state string) error {
|
||||
req, err := client.NewRequest("PATCH",
|
||||
fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, number),
|
||||
&github.IssueRequest{State: &state},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = client.Do(ctx, req, nil)
|
||||
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 *github.Client, apprv *approvalEnvironment) {
|
||||
func handleInterrupt(ctx context.Context, client *forgejo.Client, apprv *approvalEnvironment) {
|
||||
newState := "closed"
|
||||
closeComment := "Workflow cancelled, closing issue."
|
||||
|
||||
fmt.Println(closeComment)
|
||||
_, _, err := client.Issues.CreateComment(ctx, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, &github.IssueComment{
|
||||
Body: &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)
|
||||
|
|
@ -48,11 +42,11 @@ func handleInterrupt(ctx context.Context, client *github.Client, apprv *approval
|
|||
}
|
||||
}
|
||||
|
||||
func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, client *github.Client, pollingInterval time.Duration) chan int {
|
||||
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.Issues.ListComments(ctx, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, &github.IssueListCommentsOptions{})
|
||||
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
|
||||
|
|
@ -72,8 +66,8 @@ func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, clie
|
|||
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.Issues.CreateComment(ctx, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, &github.IssueComment{
|
||||
Body: &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)
|
||||
|
|
@ -101,8 +95,8 @@ func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, clie
|
|||
}
|
||||
closeComment += " workflow."
|
||||
|
||||
_, _, err := client.Issues.CreateComment(ctx, apprv.targetRepoOwner, apprv.targetRepoName, apprv.approvalIssueNumber, &github.IssueComment{
|
||||
Body: &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)
|
||||
|
|
@ -127,39 +121,17 @@ func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, clie
|
|||
return channel
|
||||
}
|
||||
|
||||
func newGithubClient(ctx context.Context) (*github.Client, error) {
|
||||
func newForgejoClient() (*forgejo.Client, error) {
|
||||
token := os.Getenv(envVarToken)
|
||||
ts := oauth2.StaticTokenSource(
|
||||
&oauth2.Token{AccessToken: token},
|
||||
)
|
||||
tc := oauth2.NewClient(ctx, ts)
|
||||
|
||||
serverUrl, serverUrlPresent := os.LookupEnv("GITHUB_SERVER_URL")
|
||||
apiUrl, apiUrlPresent := os.LookupEnv("GITHUB_API_URL")
|
||||
|
||||
if !serverUrlPresent && !apiUrlPresent {
|
||||
return github.NewClient(tc), nil
|
||||
serverUrl := os.Getenv("GITHUB_SERVER_URL")
|
||||
if serverUrl == "" {
|
||||
return nil, fmt.Errorf("GITHUB_SERVER_URL must be set for Forgejo client")
|
||||
}
|
||||
|
||||
if !apiUrlPresent {
|
||||
// Only GITHUB_SERVER_URL is set; assume GitHub Enterprise with the
|
||||
// default /api/v3 path and let NewEnterpriseClient append it.
|
||||
return github.NewEnterpriseClient(serverUrl, serverUrl, tc)
|
||||
}
|
||||
|
||||
// GITHUB_API_URL is set. github.NewEnterpriseClient appends "/api/v3/" to
|
||||
// any URL whose path doesn't already end with it. This breaks Forgejo/Gitea
|
||||
// instances whose API lives at "/api/v1/". Instead, set BaseURL directly so
|
||||
// the URL is used as-is, which works for GitHub.com, GHES, and Forgejo alike.
|
||||
if !strings.HasSuffix(apiUrl, "/") {
|
||||
apiUrl += "/"
|
||||
}
|
||||
baseURL, err := url.Parse(apiUrl)
|
||||
client, err := forgejo.NewClient(serverUrl, forgejo.SetToken(token))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid GITHUB_API_URL %q: %w", apiUrl, err)
|
||||
return nil, fmt.Errorf("failed to create forgejo client: %w", err)
|
||||
}
|
||||
client := github.NewClient(tc)
|
||||
client.BaseURL = baseURL
|
||||
return client, nil
|
||||
}
|
||||
|
||||
|
|
@ -215,13 +187,13 @@ func main() {
|
|||
}
|
||||
|
||||
ctx := context.Background()
|
||||
client, err := newGithubClient(ctx)
|
||||
forgejoClient, err := newForgejoClient()
|
||||
if err != nil {
|
||||
fmt.Printf("error connecting to server: %v\n", err)
|
||||
fmt.Printf("error connecting to forgejo server: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
approvers, err := retrieveApprovers(client, repoOwner)
|
||||
approvers, err := retrieveApprovers(forgejoClient, repoOwner)
|
||||
if err != nil {
|
||||
fmt.Printf("error retrieving approvers: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -274,7 +246,7 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
apprv, err := newApprovalEnvironment(client, repoFullName, repoOwner, runID, approvers, minimumApprovals, issueTitle, issueBody, targetRepoOwner, targetRepoName, failOnDenial)
|
||||
apprv, err := newApprovalEnvironment(forgejoClient, repoFullName, repoOwner, runID, approvers, minimumApprovals, issueTitle, issueBody, targetRepoOwner, targetRepoName, failOnDenial)
|
||||
if err != nil {
|
||||
fmt.Printf("error creating approval environment: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
|
@ -286,9 +258,9 @@ func main() {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
outputs := map[string]string {
|
||||
outputs := map[string]string{
|
||||
"issue-number": fmt.Sprintf("%d", apprv.approvalIssueNumber),
|
||||
"issue-url": apprv.approvalIssue.GetHTMLURL(),
|
||||
"issue-url": apprv.approvalIssue.HTMLURL,
|
||||
}
|
||||
_, err = apprv.SetActionOutputs(outputs)
|
||||
if err != nil {
|
||||
|
|
@ -299,21 +271,21 @@ func main() {
|
|||
killSignalChannel := make(chan os.Signal, 1)
|
||||
signal.Notify(killSignalChannel, os.Interrupt)
|
||||
|
||||
commentLoopChannel := newCommentLoopChannel(ctx, apprv, client, pollingInterval)
|
||||
commentLoopChannel := newCommentLoopChannel(ctx, apprv, forgejoClient, pollingInterval)
|
||||
|
||||
select {
|
||||
case exitCode := <-commentLoopChannel:
|
||||
approvalStatus := ""
|
||||
|
||||
if (!failOnDenial && exitCode == 1) {
|
||||
if !failOnDenial && exitCode == 1 {
|
||||
approvalStatus = "denied"
|
||||
exitCode = 0
|
||||
} else if (exitCode == 1) {
|
||||
} else if exitCode == 1 {
|
||||
approvalStatus = "denied"
|
||||
} else {
|
||||
approvalStatus = "approved"
|
||||
}
|
||||
outputs := map[string]string {
|
||||
outputs := map[string]string{
|
||||
"approval-status": approvalStatus,
|
||||
}
|
||||
if _, err := apprv.SetActionOutputs(outputs); err != nil {
|
||||
|
|
@ -322,7 +294,7 @@ func main() {
|
|||
}
|
||||
os.Exit(exitCode)
|
||||
case <-killSignalChannel:
|
||||
handleInterrupt(ctx, client, apprv)
|
||||
handleInterrupt(ctx, forgejoClient, apprv)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
57
workflow_dispatcher.md
Normal file
57
workflow_dispatcher.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Forgejo 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 Forgejo 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 Forgejo Actions tab, selects the **Manual Production Deploy** workflow, and clicks "Run Workflow".
|
||||
Loading…
Add table
Add a link
Reference in a new issue