From 78b4c148f1533b9bb92f7267ade63ff723b9b706 Mon Sep 17 00:00:00 2001 From: Thomas Stringer Date: Thu, 14 Jul 2022 22:11:38 -0400 Subject: [PATCH] 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. --- .gitignore | 1 + README.md | 4 +-- approval.go | 12 ++++---- approvers.go | 71 +++++++++++++++++++++++++++++++++++++++++++++++ approvers_test.go | 38 +++++++++++++++++++++++++ main.go | 68 ++++++++++++++++++++++++++++++++------------- 6 files changed, 166 insertions(+), 28 deletions(-) create mode 100644 .gitignore create mode 100644 approvers.go create mode 100644 approvers_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/README.md b/README.md index a14b293..1c116b6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/approval.go b/approval.go index db2dbae..7f24547 100644 --- a/approval.go +++ b/approval.go @@ -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 diff --git a/approvers.go b/approvers.go new file mode 100644 index 0000000..22bfc32 --- /dev/null +++ b/approvers.go @@ -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 +} diff --git a/approvers_test.go b/approvers_test.go new file mode 100644 index 0000000..f84f4e7 --- /dev/null +++ b/approvers_test.go @@ -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, + ) + } + }) + } +} diff --git a/main.go b/main.go index b8d4994..bb65edc 100644 --- a/main.go +++ b/main.go @@ -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: