Skip to content

Commit 78b4c14

Browse files
authored
Add ability to specify a team as an approver (trstringer#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 trstringer#14.
1 parent d8f25cb commit 78b4c14

File tree

6 files changed

+162
-24
lines changed

6 files changed

+162
-24
lines changed

Diff for: .gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.env

Diff for: README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ steps:
2929
- uses: trstringer/manual-approval@v1
3030
with:
3131
secret: ${{ github.TOKEN }}
32-
approvers: user1,user2
32+
approvers: user1,user2,org-team1
3333
minimum-approvals: 1
3434
issue-title: "Deploying v1.3.5 to prod from staging"
3535
```
3636
37-
- `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*)
37+
- `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*)
3838
- `minimum-approvals` is an integer that sets the minimum number of approvals required to progress the workflow. Defaults to ALL approvers.
3939
- `issue-title` is a string that will be appened to the title of the issue.
4040

Diff for: approval.go

+6-6
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ type approvalEnvironment struct {
1515
repo string
1616
repoOwner string
1717
runID int
18-
approvers []string
19-
minimumApprovals int
2018
approvalIssue *github.Issue
2119
approvalIssueNumber int
2220
issueTitle string
21+
issueApprovers []string
22+
minimumApprovals int
2323
}
2424

2525
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
3535
repo: repo,
3636
repoOwner: repoOwner,
3737
runID: runID,
38-
approvers: approvers,
38+
issueApprovers: approvers,
3939
minimumApprovals: minimumApprovals,
4040
issueTitle: issueTitle,
4141
}, nil
@@ -59,7 +59,7 @@ Required approvers: %s
5959
6060
Respond %s to continue workflow or %s to cancel.`,
6161
a.runURL(),
62-
a.approvers,
62+
a.issueApprovers,
6363
formatAcceptedWords(approvedWords),
6464
formatAcceptedWords(deniedWords),
6565
)
@@ -69,13 +69,13 @@ Respond %s to continue workflow or %s to cancel.`,
6969
a.repoOwner,
7070
a.repo,
7171
issueTitle,
72-
a.approvers,
72+
a.issueApprovers,
7373
issueBody,
7474
)
7575
a.approvalIssue, _, err = a.client.Issues.Create(ctx, a.repoOwner, a.repo, &github.IssueRequest{
7676
Title: &issueTitle,
7777
Body: &issueBody,
78-
Assignees: &a.approvers,
78+
Assignees: &a.issueApprovers,
7979
})
8080
a.approvalIssueNumber = a.approvalIssue.GetNumber()
8181
return err

Diff for: approvers.go

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"strconv"
8+
"strings"
9+
10+
"github.com/google/go-github/v43/github"
11+
)
12+
13+
func retrieveApprovers(client *github.Client, repoOwner string) ([]string, error) {
14+
approvers := []string{}
15+
16+
requiredApproversRaw := os.Getenv(envVarApprovers)
17+
requiredApprovers := strings.Split(requiredApproversRaw, ",")
18+
19+
for _, approverUser := range requiredApprovers {
20+
expandedUsers := expandGroupFromUser(client, repoOwner, approverUser)
21+
if expandedUsers != nil {
22+
approvers = append(approvers, expandedUsers...)
23+
} else {
24+
approvers = append(approvers, approverUser)
25+
}
26+
}
27+
28+
approvers = deduplicateUsers(approvers)
29+
30+
minimumApprovalsRaw := os.Getenv(envVarMinimumApprovals)
31+
minimumApprovals := len(approvers)
32+
var err error
33+
if minimumApprovalsRaw != "" {
34+
minimumApprovals, err = strconv.Atoi(minimumApprovalsRaw)
35+
if err != nil {
36+
return nil, fmt.Errorf("error parsing minimum number of approvals: %w", err)
37+
}
38+
}
39+
40+
if minimumApprovals > len(approvers) {
41+
return nil, fmt.Errorf("error: minimum required approvals (%d) is greater than the total number of approvers (%d)", minimumApprovals, len(approvers))
42+
}
43+
44+
return approvers, nil
45+
}
46+
47+
func expandGroupFromUser(client *github.Client, org, userOrTeam string) []string {
48+
users, _, err := client.Teams.ListTeamMembersBySlug(context.Background(), org, userOrTeam, &github.TeamListTeamMembersOptions{})
49+
if err != nil || len(users) == 0 {
50+
return nil
51+
}
52+
53+
userNames := make([]string, 0, len(users))
54+
for _, user := range users {
55+
userNames = append(userNames, user.GetLogin())
56+
}
57+
58+
return userNames
59+
}
60+
61+
func deduplicateUsers(users []string) []string {
62+
uniqValuesByKey := make(map[string]bool)
63+
uniqUsers := []string{}
64+
for _, user := range users {
65+
if _, ok := uniqValuesByKey[user]; !ok {
66+
uniqValuesByKey[user] = true
67+
uniqUsers = append(uniqUsers, user)
68+
}
69+
}
70+
return uniqUsers
71+
}

Diff for: approvers_test.go

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package main
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func TestDeduplicateUsers(t *testing.T) {
9+
testCases := []struct {
10+
name string
11+
input []string
12+
expected []string
13+
}{
14+
{
15+
name: "with_duplicate_user",
16+
input: []string{"first", "second", "first"},
17+
expected: []string{"first", "second"},
18+
},
19+
{
20+
name: "without_duplicate_user",
21+
input: []string{"first", "second"},
22+
expected: []string{"first", "second"},
23+
},
24+
}
25+
26+
for _, testCase := range testCases {
27+
t.Run(testCase.name, func(t *testing.T) {
28+
actual := deduplicateUsers(testCase.input)
29+
if !reflect.DeepEqual(testCase.expected, actual) {
30+
t.Fatalf(
31+
"unequal depulicated: expected %v actual %v",
32+
testCase.expected,
33+
actual,
34+
)
35+
}
36+
})
37+
}
38+
}

Diff for: main.go

+44-16
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"os"
77
"os/signal"
88
"strconv"
9-
"strings"
109
"time"
1110

1211
"github.com/google/go-github/v43/github"
@@ -31,7 +30,7 @@ func handleInterrupt(ctx context.Context, client *github.Client, apprv *approval
3130
}
3231
}
3332

34-
func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, client *github.Client, approvers []string, minimumApprovals int) chan int {
33+
func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, client *github.Client) chan int {
3534
channel := make(chan int)
3635
go func() {
3736
for {
@@ -42,7 +41,7 @@ func newCommentLoopChannel(ctx context.Context, apprv *approvalEnvironment, clie
4241
close(channel)
4342
}
4443

45-
approved, err := approvalFromComments(comments, approvers, minimumApprovals)
44+
approved, err := approvalFromComments(comments, apprv.issueApprovers, apprv.minimumApprovals)
4645
if err != nil {
4746
fmt.Printf("error getting approval from comments: %v\n", err)
4847
channel <- 1
@@ -106,7 +105,40 @@ func newGithubClient(ctx context.Context) *github.Client {
106105
return github.NewClient(tc)
107106
}
108107

108+
func validateInput() error {
109+
missingEnvVars := []string{}
110+
if os.Getenv(envVarRepoFullName) == "" {
111+
missingEnvVars = append(missingEnvVars, envVarRepoFullName)
112+
}
113+
114+
if os.Getenv(envVarRunID) == "" {
115+
missingEnvVars = append(missingEnvVars, envVarRunID)
116+
}
117+
118+
if os.Getenv(envVarRepoOwner) == "" {
119+
missingEnvVars = append(missingEnvVars, envVarRepoOwner)
120+
}
121+
122+
if os.Getenv(envVarToken) == "" {
123+
missingEnvVars = append(missingEnvVars, envVarToken)
124+
}
125+
126+
if os.Getenv(envVarApprovers) == "" {
127+
missingEnvVars = append(missingEnvVars, envVarApprovers)
128+
}
129+
130+
if len(missingEnvVars) > 0 {
131+
return fmt.Errorf("missing env vars: %v", missingEnvVars)
132+
}
133+
return nil
134+
}
135+
109136
func main() {
137+
if err := validateInput(); err != nil {
138+
fmt.Printf("%v\n", err)
139+
os.Exit(1)
140+
}
141+
110142
repoFullName := os.Getenv(envVarRepoFullName)
111143
runID, err := strconv.Atoi(os.Getenv(envVarRunID))
112144
if err != nil {
@@ -118,26 +150,22 @@ func main() {
118150
ctx := context.Background()
119151
client := newGithubClient(ctx)
120152

121-
requiredApproversRaw := os.Getenv(envVarApprovers)
122-
fmt.Printf("Required approvers: %s\n", requiredApproversRaw)
123-
approvers := strings.Split(requiredApproversRaw, ",")
153+
approvers, err := retrieveApprovers(client, repoOwner)
154+
if err != nil {
155+
fmt.Printf("error retrieving approvers: %v\n", err)
156+
os.Exit(1)
157+
}
124158

159+
issueTitle := os.Getenv(envVarIssueTitle)
125160
minimumApprovalsRaw := os.Getenv(envVarMinimumApprovals)
126-
minimumApprovals := len(approvers)
161+
minimumApprovals := 0
127162
if minimumApprovalsRaw != "" {
128163
minimumApprovals, err = strconv.Atoi(minimumApprovalsRaw)
129164
if err != nil {
130-
fmt.Printf("error parsing minimum number of approvals: %v\n", err)
165+
fmt.Printf("error parsing minimum approvals: %v\n", err)
131166
os.Exit(1)
132167
}
133168
}
134-
135-
if minimumApprovals > len(approvers) {
136-
fmt.Printf("error: minimum required approvals (%v) is greater than the total number of approvers (%v)\n", minimumApprovals, len(approvers))
137-
os.Exit(1)
138-
}
139-
140-
issueTitle := os.Getenv(envVarIssueTitle)
141169
apprv, err := newApprovalEnvironment(client, repoFullName, repoOwner, runID, approvers, minimumApprovals, issueTitle)
142170
if err != nil {
143171
fmt.Printf("error creating approval environment: %v\n", err)
@@ -153,7 +181,7 @@ func main() {
153181
killSignalChannel := make(chan os.Signal, 1)
154182
signal.Notify(killSignalChannel, os.Interrupt)
155183

156-
commentLoopChannel := newCommentLoopChannel(ctx, apprv, client, approvers, minimumApprovals)
184+
commentLoopChannel := newCommentLoopChannel(ctx, apprv, client)
157185

158186
select {
159187
case exitCode := <-commentLoopChannel:

0 commit comments

Comments
 (0)