Add ability to specify a team as an approver (#32)

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.
This commit is contained in:
Thomas Stringer 2022-07-14 22:11:38 -04:00 committed by GitHub
parent d8f25cb80e
commit 78b4c148f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
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: