Skip to content

Commit 80c1360

Browse files
committed
initial commit
0 parents  commit 80c1360

10 files changed

+765
-0
lines changed

Diff for: Dockerfile

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
FROM golang:1.17 AS builder
2+
COPY . /var/app
3+
WORKDIR /var/app
4+
RUN CGO_ENABLED=0 go build -o app .
5+
6+
FROM alpine:3.14
7+
RUN apk update && apk add ca-certificates
8+
COPY --from=builder /var/app/app /var/app/app
9+
CMD ["/var/app/app"]

Diff for: README.md

Whitespace-only changes.

Diff for: action.yaml

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
name: Manual Workflow Approval
2+
description: Pause a workflow and get user approval to continue
3+
inputs:
4+
approvers:
5+
description: Required approvers
6+
required: true
7+
secret:
8+
description: Secret
9+
required: true
10+
runs:
11+
using: docker
12+
image: docker://ghcr.io/trstringer/manual-approval:1.0.0

Diff for: approval.go

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/google/go-github/v43/github"
9+
)
10+
11+
type approvalEnvironment struct {
12+
client *github.Client
13+
repoFullName string
14+
repo string
15+
repoOwner string
16+
runID int
17+
approvers []string
18+
approvalIssue *github.Issue
19+
approvalIssueNumber int
20+
}
21+
22+
func newApprovalEnvironment(client *github.Client, repoFullName, repoOwner string, runID int, approvers []string) (*approvalEnvironment, error) {
23+
repoOwnerAndName := strings.Split(repoFullName, "/")
24+
if len(repoOwnerAndName) != 2 {
25+
return nil, fmt.Errorf("repo owner and name in unexpected format: %s", repoFullName)
26+
}
27+
repo := repoOwnerAndName[1]
28+
29+
return &approvalEnvironment{
30+
client: client,
31+
repoFullName: repoFullName,
32+
repo: repo,
33+
repoOwner: repoOwner,
34+
runID: runID,
35+
approvers: approvers,
36+
}, nil
37+
}
38+
39+
func (a approvalEnvironment) runURL() string {
40+
return fmt.Sprintf("https://github.com/%s/actions/runs/%d", a.repoFullName, a.runID)
41+
}
42+
43+
func (a *approvalEnvironment) createApprovalIssue(ctx context.Context) error {
44+
issueTitle := fmt.Sprintf("Manual approval required for workflow run %d", a.runID)
45+
issueBody := fmt.Sprintf(`Workflow is pending manual review.
46+
URL: %s
47+
48+
Required approvers: %s
49+
50+
Respond '%s' to continue workflow or '%s' to cancel.
51+
`, a.runURL(), a.approvers, approvalStatusApproved, approvalStatusDenied)
52+
var err error
53+
a.approvalIssue, _, err = a.client.Issues.Create(ctx, a.repoOwner, a.repo, &github.IssueRequest{
54+
Title: &issueTitle,
55+
Body: &issueBody,
56+
Assignees: &a.approvers,
57+
})
58+
a.approvalIssueNumber = a.approvalIssue.GetNumber()
59+
return err
60+
}
61+
62+
func approvalFromComments(comments []*github.IssueComment, approvers []string) approvalStatus {
63+
remainingApprovers := make([]string, len(approvers))
64+
copy(remainingApprovers, approvers)
65+
66+
for _, comment := range comments {
67+
commentUser := comment.User.GetLogin()
68+
approverIdx := approversIndex(remainingApprovers, commentUser)
69+
if approverIdx < 0 {
70+
continue
71+
}
72+
73+
commentBody := comment.GetBody()
74+
if commentBody == string(approvalStatusApproved) {
75+
if len(remainingApprovers) == 1 {
76+
return approvalStatusApproved
77+
}
78+
remainingApprovers[approverIdx] = remainingApprovers[len(remainingApprovers)-1]
79+
remainingApprovers = remainingApprovers[:len(remainingApprovers)-1]
80+
continue
81+
} else if commentBody == string(approvalStatusDenied) {
82+
return approvalStatusDenied
83+
}
84+
}
85+
86+
return approvalStatusPending
87+
}
88+
89+
func approversIndex(approvers []string, name string) int {
90+
for idx, approver := range approvers {
91+
if approver == name {
92+
return idx
93+
}
94+
}
95+
return -1
96+
}

Diff for: approval_test.go

+125
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
6+
"github.com/google/go-github/v43/github"
7+
)
8+
9+
func TestApprovalFromComments(t *testing.T) {
10+
login1 := "login1"
11+
login2 := "login2"
12+
bodyApproved := "Approved"
13+
bodyDenied := "Denied"
14+
bodyPending := "not approval or denial"
15+
16+
testCases := []struct {
17+
name string
18+
comments []*github.IssueComment
19+
approvers []string
20+
expectedStatus approvalStatus
21+
}{
22+
{
23+
name: "single_approver_single_comment_approved",
24+
comments: []*github.IssueComment{
25+
{
26+
User: &github.User{Login: &login1},
27+
Body: &bodyApproved,
28+
},
29+
},
30+
approvers: []string{login1},
31+
expectedStatus: approvalStatusApproved,
32+
},
33+
{
34+
name: "single_approver_single_comment_denied",
35+
comments: []*github.IssueComment{
36+
{
37+
User: &github.User{Login: &login1},
38+
Body: &bodyDenied,
39+
},
40+
},
41+
approvers: []string{login1},
42+
expectedStatus: approvalStatusDenied,
43+
},
44+
{
45+
name: "single_approver_single_comment_pending",
46+
comments: []*github.IssueComment{
47+
{
48+
User: &github.User{Login: &login1},
49+
Body: &bodyPending,
50+
},
51+
},
52+
approvers: []string{login1},
53+
expectedStatus: approvalStatusPending,
54+
},
55+
{
56+
name: "single_approver_multi_comment_approved",
57+
comments: []*github.IssueComment{
58+
{
59+
User: &github.User{Login: &login1},
60+
Body: &bodyPending,
61+
},
62+
{
63+
User: &github.User{Login: &login1},
64+
Body: &bodyApproved,
65+
},
66+
},
67+
approvers: []string{login1},
68+
expectedStatus: approvalStatusApproved,
69+
},
70+
{
71+
name: "multi_approver_approved",
72+
comments: []*github.IssueComment{
73+
{
74+
User: &github.User{Login: &login1},
75+
Body: &bodyApproved,
76+
},
77+
{
78+
User: &github.User{Login: &login2},
79+
Body: &bodyApproved,
80+
},
81+
},
82+
approvers: []string{login1, login2},
83+
expectedStatus: approvalStatusApproved,
84+
},
85+
{
86+
name: "multi_approver_mixed",
87+
comments: []*github.IssueComment{
88+
{
89+
User: &github.User{Login: &login1},
90+
Body: &bodyPending,
91+
},
92+
{
93+
User: &github.User{Login: &login2},
94+
Body: &bodyApproved,
95+
},
96+
},
97+
approvers: []string{login1, login2},
98+
expectedStatus: approvalStatusPending,
99+
},
100+
{
101+
name: "multi_approver_denied",
102+
comments: []*github.IssueComment{
103+
{
104+
User: &github.User{Login: &login1},
105+
Body: &bodyDenied,
106+
},
107+
{
108+
User: &github.User{Login: &login2},
109+
Body: &bodyApproved,
110+
},
111+
},
112+
approvers: []string{login1, login2},
113+
expectedStatus: approvalStatusDenied,
114+
},
115+
}
116+
117+
for _, testCase := range testCases {
118+
t.Run(testCase.name, func(t *testing.T) {
119+
actual := approvalFromComments(testCase.comments, testCase.approvers)
120+
if actual != testCase.expectedStatus {
121+
t.Fatalf("actual %s, expected %s", actual, testCase.expectedStatus)
122+
}
123+
})
124+
}
125+
}

Diff for: approvalstatus.go

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package main
2+
3+
type approvalStatus string
4+
5+
const (
6+
approvalStatusPending approvalStatus = "Pending"
7+
approvalStatusApproved approvalStatus = "Approved"
8+
approvalStatusDenied approvalStatus = "Denied"
9+
)

Diff for: constants.go

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package main
2+
3+
import "time"
4+
5+
const (
6+
pollingInterval time.Duration = 10 * time.Second
7+
8+
envVarRepoFullName string = "GITHUB_REPOSITORY"
9+
envVarRunID string = "GITHUB_RUN_ID"
10+
envVarRepoOwner string = "GITHUB_REPOSITORY_OWNER"
11+
envVarToken string = "INPUT_SECRET"
12+
envVarApprovers string = "INPUT_APPROVERS"
13+
)

Diff for: go.mod

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
module github.com/trstringer/manual-approval
2+
3+
go 1.17
4+
5+
require (
6+
github.com/google/go-github/v43 v43.0.0
7+
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a
8+
)
9+
10+
require (
11+
github.com/golang/protobuf v1.4.2 // indirect
12+
github.com/google/go-querystring v1.1.0 // indirect
13+
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect
14+
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
15+
google.golang.org/appengine v1.6.7 // indirect
16+
google.golang.org/protobuf v1.25.0 // indirect
17+
)

0 commit comments

Comments
 (0)