Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Thomas Stringer
879aad8338 Add ability to specify a team as an approver
Prior to this change, the approvers could only be explicit users. With
this change, you can now specify an org team and this will be expanded
out with a user list for approvers.

Closes #14.
2022-07-14 21:57:27 -04:00
6 changed files with 166 additions and 28 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.env

View file

@ -29,12 +29,12 @@ steps:
- uses: trstringer/manual-approval@v1
with:
secret: ${{ github.TOKEN }}
approvers: user1,user2
approvers: user1,user2,org-team1
minimum-approvals: 1
issue-title: "Deploying v1.3.5 to prod from staging"
```
- `approvers` is a comma-delimited list of all required approvers. (*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. 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*)
- `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 appened to the title of the issue.

View file

@ -15,11 +15,11 @@ type approvalEnvironment struct {
repo string
repoOwner string
runID int
approvers []string
minimumApprovals int
approvalIssue *github.Issue
approvalIssueNumber int
issueTitle string
issueApprovers []string
minimumApprovals int
}
func newApprovalEnvironment(client *github.Client, repoFullName, repoOwner string, runID int, approvers []string, minimumApprovals int, issueTitle string) (*approvalEnvironment, error) {
@ -35,7 +35,7 @@ func newApprovalEnvironment(client *github.Client, repoFullName, repoOwner strin
repo: repo,
repoOwner: repoOwner,
runID: runID,
approvers: approvers,
issueApprovers: approvers,
minimumApprovals: minimumApprovals,
issueTitle: issueTitle,
}, nil
@ -59,7 +59,7 @@ Required approvers: %s
Respond %s to continue workflow or %s to cancel.`,
a.runURL(),
a.approvers,
a.issueApprovers,
formatAcceptedWords(approvedWords),
formatAcceptedWords(deniedWords),
)
@ -69,13 +69,13 @@ Respond %s to continue workflow or %s to cancel.`,
a.repoOwner,
a.repo,
issueTitle,
a.approvers,
a.issueApprovers,
issueBody,
)
a.approvalIssue, _, err = a.client.Issues.Create(ctx, a.repoOwner, a.repo, &github.IssueRequest{
Title: &issueTitle,
Body: &issueBody,
Assignees: &a.approvers,
Assignees: &a.issueApprovers,
})
a.approvalIssueNumber = a.approvalIssue.GetNumber()
return err

71
approvers.go Normal file
View file

@ -0,0 +1,71 @@
package main
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"github.com/google/go-github/v43/github"
)
func retrieveApprovers(client *github.Client, repoOwner string) ([]string, error) {
approvers := []string{}
requiredApproversRaw := os.Getenv(envVarApprovers)
requiredApprovers := strings.Split(requiredApproversRaw, ",")
for _, approverUser := range requiredApprovers {
expandedUsers := expandGroupFromUser(client, repoOwner, approverUser)
if expandedUsers != nil {
approvers = append(approvers, expandedUsers...)
} 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 *github.Client, org, userOrTeam string) []string {
users, _, err := client.Teams.ListTeamMembersBySlug(context.Background(), org, userOrTeam, &github.TeamListTeamMembersOptions{})
if err != nil || len(users) == 0 {
return nil
}
userNames := make([]string, 0, len(users))
for _, user := range users {
userNames = append(userNames, user.GetLogin())
}
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
}

38
approvers_test.go Normal file
View file

@ -0,0 +1,38 @@
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,
)
}
})
}
}

68
main.go
View file

@ -6,7 +6,6 @@ import (
"os"
"os/signal"
"strconv"
"strings"
"time"
"github.com/google/go-github/v43/github"
@ -31,7 +30,7 @@ func handleInterrupt(ctx context.Context, client *github.Client, apprv *approval
}
}
func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, client *github.Client, approvers []string, minimumApprovals int) chan int {
func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, client *github.Client) chan int {
channel := make(chan int)
go func() {
for {
@ -42,7 +41,7 @@ func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, clie
close(channel)
}
approved, err := approvalFromComments(comments, approvers, minimumApprovals)
approved, err := approvalFromComments(comments, apprv.issueApprovers, apprv.minimumApprovals)
if err != nil {
fmt.Printf("error getting approval from comments: %v\n", err)
channel <- 1
@ -106,7 +105,40 @@ func newGithubClient(ctx context.Context) *github.Client {
return github.NewClient(tc)
}
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() {
if err := validateInput(); err != nil {
fmt.Printf("%v\n", err)
os.Exit(1)
}
repoFullName := os.Getenv(envVarRepoFullName)
runID, err := strconv.Atoi(os.Getenv(envVarRunID))
if err != nil {
@ -118,26 +150,22 @@ func main() {
ctx := context.Background()
client := newGithubClient(ctx)
requiredApproversRaw := os.Getenv(envVarApprovers)
fmt.Printf("Required approvers: %s\n", requiredApproversRaw)
approvers := strings.Split(requiredApproversRaw, ",")
minimumApprovalsRaw := os.Getenv(envVarMinimumApprovals)
minimumApprovals := len(approvers)
if minimumApprovalsRaw != "" {
minimumApprovals, err = strconv.Atoi(minimumApprovalsRaw)
if err != nil {
fmt.Printf("error parsing minimum number of approvals: %v\n", err)
os.Exit(1)
}
}
if minimumApprovals > len(approvers) {
fmt.Printf("error: minimum required approvals (%v) is greater than the total number of approvers (%v)\n", minimumApprovals, len(approvers))
approvers, err := retrieveApprovers(client, repoOwner)
if err != nil {
fmt.Printf("error retrieving approvers: %v\n", err)
os.Exit(1)
}
issueTitle := os.Getenv(envVarIssueTitle)
minimumApprovalsRaw := os.Getenv(envVarMinimumApprovals)
minimumApprovals := 0
if minimumApprovalsRaw != "" {
minimumApprovals, err = strconv.Atoi(minimumApprovalsRaw)
if err != nil {
fmt.Printf("error parsing minimum approvals: %v\n", err)
os.Exit(1)
}
}
apprv, err := newApprovalEnvironment(client, repoFullName, repoOwner, runID, approvers, minimumApprovals, issueTitle)
if err != nil {
fmt.Printf("error creating approval environment: %v\n", err)
@ -153,7 +181,7 @@ func main() {
killSignalChannel := make(chan os.Signal, 1)
signal.Notify(killSignalChannel, os.Interrupt)
commentLoopChannel := newCommentLoopChannel(ctx, apprv, client, approvers, minimumApprovals)
commentLoopChannel := newCommentLoopChannel(ctx, apprv, client)
select {
case exitCode := <-commentLoopChannel: