Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 107 additions & 6 deletions github/resource_github_team_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,59 @@ import (
"context"
"errors"
"fmt"
"reflect"
"strconv"
"strings"

"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/shurcooL/githubv4"
)

// getBatchUserNodeIds retrieves the GraphQL node IDs for multiple usernames in a single request.
func getBatchUserNodeIds(ctx context.Context, meta any, usernames []string) (map[string]string, error) {
if len(usernames) == 0 {
return make(map[string]string), nil
}

client := meta.(*Owner).v4client

// Create GraphQL variables and query struct using reflection (similar to data_source_github_users.go)
type UserFragment struct {
ID string `graphql:"id"`
}

var fields []reflect.StructField
variables := make(map[string]any)

for idx, username := range usernames {
label := fmt.Sprintf("User%d", idx)
variables[label] = githubv4.String(username)
fields = append(fields, reflect.StructField{
Name: label,
Type: reflect.TypeFor[UserFragment](),
Tag: reflect.StructTag(fmt.Sprintf("graphql:\"%[1]s: user(login: $%[1]s)\"", label)),
})
}

query := reflect.New(reflect.StructOf(fields)).Elem()

err := client.Query(ctx, query.Addr().Interface(), variables)
if err != nil && !strings.Contains(err.Error(), "Could not resolve to a User with the login of") {
return nil, fmt.Errorf("failed to query users in batch: %w", err)
}

result := make(map[string]string)
for idx, username := range usernames {
label := fmt.Sprintf("User%d", idx)
user := query.FieldByName(label).Interface().(UserFragment)
if user.ID != "" {
result[username] = user.ID
}
}

return result, nil
}

func resourceGithubTeamSettings() *schema.Resource {
return &schema.Resource{
Create: resourceGithubTeamSettingsCreate,
Expand Down Expand Up @@ -83,6 +130,14 @@ func resourceGithubTeamSettings() *schema.Resource {
Default: false,
Description: "whether to notify the entire team when at least one member is also assigned to the pull request.",
},
"excluded_members": {
Type: schema.TypeSet,
Optional: true,
Description: "A list of team member usernames to exclude from the PR review process.",
Elem: &schema.Schema{
Type: schema.TypeString,
},
},
},
},
},
Expand Down Expand Up @@ -143,6 +198,19 @@ func resourceGithubTeamSettingsRead(d *schema.ResourceData, meta any) error {
reviewRequestDelegation["algorithm"] = query.Organization.Team.ReviewRequestDelegationAlgorithm
reviewRequestDelegation["member_count"] = query.Organization.Team.ReviewRequestDelegationCount
reviewRequestDelegation["notify"] = query.Organization.Team.ReviewRequestDelegationNotifyAll

// NOTE: The exclusion list is not available via the GraphQL read query yet.
// The excluded_team_member_node_ids field can be set but cannot be read back from the GitHub API.
// This is because the GraphQL API for team review assignments is currently in preview.
// As a workaround, we preserve the excluded_members from the current state.
if currentDelegation := d.Get("review_request_delegation").([]any); len(currentDelegation) > 0 {
if currentSettings, ok := currentDelegation[0].(map[string]any); ok {
if excludedMembers, exists := currentSettings["excluded_members"]; exists {
reviewRequestDelegation["excluded_members"] = excludedMembers
}
}
}

if err = d.Set("review_request_delegation", []any{reviewRequestDelegation}); err != nil {
return err
}
Expand All @@ -151,6 +219,8 @@ func resourceGithubTeamSettingsRead(d *schema.ResourceData, meta any) error {
return err
}
}
// NOTE: The excluded members are preserved from the current state in the read logic above
// since the GitHub API doesn't currently support reading them back.

return nil
}
Expand All @@ -177,12 +247,42 @@ func resourceGithubTeamSettingsUpdate(d *schema.ResourceData, meta any) error {
} `graphql:"updateTeamReviewAssignment(input:$input)"`
}

exclusionList := make([]githubv4.ID, 0)
if excludedMembers, ok := settings["excluded_members"]; ok && excludedMembers != nil {
// Collect all usernames first
usernames := make([]string, 0)
for _, v := range excludedMembers.(*schema.Set).List() {
if v != nil {
username := v.(string)
usernames = append(usernames, username)
}
}

// Get all node IDs in a single batch request
if len(usernames) > 0 {
nodeIds, err := getBatchUserNodeIds(ctx, meta, usernames)
if err != nil {
return fmt.Errorf("failed to get node IDs for excluded members: %w", err)
}

// Convert to the exclusion list
for _, username := range usernames {
if nodeId, exists := nodeIds[username]; exists {
exclusionList = append(exclusionList, githubv4.ID(nodeId))
} else {
return fmt.Errorf("failed to get node ID for user %s: user not found", username)
}
}
}
}

return graphql.Mutate(ctx, &mutation, UpdateTeamReviewAssignmentInput{
TeamID: d.Id(),
ReviewRequestDelegation: true,
ReviewRequestDelegationAlgorithm: settings["algorithm"].(string),
ReviewRequestDelegationCount: settings["member_count"].(int),
ReviewRequestDelegationNotifyAll: settings["notify"].(bool),
ExcludedTeamMemberIds: exclusionList,
}, nil)
}
}
Expand Down Expand Up @@ -252,12 +352,13 @@ func resolveTeamIDs(idOrSlug string, meta *Owner, ctx context.Context) (nodeId,
}

type UpdateTeamReviewAssignmentInput struct {
ClientMutationID string `json:"clientMutationId,omitempty"`
TeamID string `graphql:"id" json:"id"`
ReviewRequestDelegation bool `graphql:"enabled" json:"enabled"`
ReviewRequestDelegationAlgorithm string `graphql:"algorithm" json:"algorithm"`
ReviewRequestDelegationCount int `graphql:"teamMemberCount" json:"teamMemberCount"`
ReviewRequestDelegationNotifyAll bool `graphql:"notifyTeam" json:"notifyTeam"`
ClientMutationID string `json:"clientMutationId,omitempty"`
TeamID string `graphql:"id" json:"id"`
ReviewRequestDelegation bool `graphql:"enabled" json:"enabled"`
ReviewRequestDelegationAlgorithm string `graphql:"algorithm" json:"algorithm"`
ReviewRequestDelegationCount int `graphql:"teamMemberCount" json:"teamMemberCount"`
ReviewRequestDelegationNotifyAll bool `graphql:"notifyTeam" json:"notifyTeam"`
ExcludedTeamMemberIds []githubv4.ID `graphql:"excludedTeamMemberIds" json:"excludedTeamMemberIds"`
}

func defaultTeamReviewAssignmentSettings(id string) UpdateTeamReviewAssignmentInput {
Expand Down
146 changes: 146 additions & 0 deletions github/resource_github_team_settings_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package github

import (
"context"
"fmt"
"regexp"
"strings"
Expand Down Expand Up @@ -123,6 +124,49 @@ func TestAccGithubTeamSettings(t *testing.T) {
})
})

t.Run("manages team code review settings with excluded members", func(t *testing.T) {
randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)
teamName := fmt.Sprintf("%steam-settings-%s", testResourcePrefix, randomID)
config := fmt.Sprintf(`
resource "github_team" "test" {
name = "%s"
description = "generated by terraform provider automated testing"
}

resource "github_team_settings" "test" {
team_id = "${github_team.test.id}"
review_request_delegation {
algorithm = "ROUND_ROBIN"
member_count = 1
notify = true
excluded_members = ["octocat", "defunkt"]
}
}
`, teamName)

check := resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(
"github_team_settings.test", "review_request_delegation.0.algorithm",
"ROUND_ROBIN",
),
resource.TestCheckResourceAttr(
"github_team_settings.test", "review_request_delegation.0.excluded_members.#",
"2",
),
)

resource.Test(t, resource.TestCase{
PreCheck: func() { skipUnlessHasOrgs(t) },
ProviderFactories: providerFactories,
Steps: []resource.TestStep{
{
Config: config,
Check: check,
},
},
})
})

t.Run("cannot manage team code review settings if disabled", func(t *testing.T) {
randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)
teamName := fmt.Sprintf("%steam-settings-%s", testResourcePrefix, randomID)
Expand Down Expand Up @@ -156,3 +200,105 @@ func TestAccGithubTeamSettings(t *testing.T) {
})
})
}

func TestBatchUserNodeIds(t *testing.T) {
t.Run("fetches user node IDs in batch", func(t *testing.T) {
if len(testAccConf.testExternalUser) == 0 {
t.Skip("No external user provided")
}

// Skip if not authenticated
skipUnauthenticated(t)

// Create a test owner/meta object
owner, err := getTestMeta()
if err != nil {
t.Fatalf("Failed to get test meta: %v", err)
}

ctx := context.Background()

// Test with single user
t.Run("single user", func(t *testing.T) {
usernames := []string{testAccConf.testExternalUser}
result, err := getBatchUserNodeIds(ctx, owner, usernames)

if err != nil {
t.Fatalf("getBatchUserNodeIds failed: %v", err)
}

if len(result) != 1 {
t.Errorf("Expected 1 result, got %d", len(result))
}

nodeId, exists := result[testAccConf.testExternalUser]
if !exists {
t.Errorf("Expected node ID for user %s", testAccConf.testExternalUser)
}

if nodeId == "" {
t.Errorf("Node ID should not be empty")
}

t.Logf("Successfully fetched node ID %s for user %s", nodeId, testAccConf.testExternalUser)
})

// Test with multiple users (using same user multiple times for simplicity)
t.Run("multiple users", func(t *testing.T) {
usernames := []string{testAccConf.testExternalUser, "octocat"} // octocat is a well-known GitHub user
result, err := getBatchUserNodeIds(ctx, owner, usernames)

if err != nil {
t.Fatalf("getBatchUserNodeIds failed: %v", err)
}

// We expect at least the test user to exist
if len(result) == 0 {
t.Errorf("Expected at least 1 result, got %d", len(result))
}

// Check that our test user exists
nodeId, exists := result[testAccConf.testExternalUser]
if !exists {
t.Errorf("Expected node ID for user %s", testAccConf.testExternalUser)
} else if nodeId == "" {
t.Errorf("Node ID should not be empty")
}

t.Logf("Successfully fetched %d node IDs in batch request", len(result))
for username, nodeId := range result {
t.Logf(" User: %s, Node ID: %s", username, nodeId)
}
})

// Test with empty list
t.Run("empty usernames list", func(t *testing.T) {
usernames := []string{}
result, err := getBatchUserNodeIds(ctx, owner, usernames)

if err != nil {
t.Fatalf("getBatchUserNodeIds failed: %v", err)
}

if len(result) != 0 {
t.Errorf("Expected 0 results for empty list, got %d", len(result))
}
})

// Test with non-existent user
t.Run("non-existent user", func(t *testing.T) {
nonExistentUser := "this-user-definitely-does-not-exist-12345"
usernames := []string{nonExistentUser}
result, err := getBatchUserNodeIds(ctx, owner, usernames)

if err != nil {
t.Fatalf("getBatchUserNodeIds failed: %v", err)
}

// Non-existent users should not appear in results
if len(result) != 0 {
t.Errorf("Expected 0 results for non-existent user, got %d", len(result))
}
})
})
}
19 changes: 11 additions & 8 deletions website/docs/r/team_settings.html.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This resource manages the team settings (in particular the request review delega

Creating this resource will alter the team Code Review settings.

The team must both belong to the same organization configured in the provider on GitHub.
The team must both belong to the same organization configured in the provider on GitHub.

~> **Note**: This resource relies on the v4 GraphQl GitHub API. If this API is not available, or the Stone Crop schema preview is not available, then this resource will not work as intended.

Expand All @@ -30,6 +30,7 @@ resource "github_team_settings" "code_review_settings" {
algorithm = "ROUND_ROBIN"
member_count = 1
notify = true
excluded_members = ["octocat", "defunkt"]
}
}
```
Expand All @@ -38,17 +39,17 @@ resource "github_team_settings" "code_review_settings" {

The following arguments are supported:

* `team_id` - (Required) The GitHub team id or the GitHub team slug
* `review_request_delegation` - (Optional) The settings for delegating code reviews to individuals on behalf of the team. If this block is present, even without any fields, then review request delegation will be enabled for the team. See [GitHub Review Request Delegation](#github-review-request-delegation-configuration) below for details. See [GitHub's documentation](https://docs.github.com/en/organizations/organizing-members-into-teams/managing-code-review-settings-for-your-team#configuring-team-notifications) for more configuration details.
- `team_id` - (Required) The GitHub team id or the GitHub team slug
- `review_request_delegation` - (Optional) The settings for delegating code reviews to individuals on behalf of the team. If this block is present, even without any fields, then review request delegation will be enabled for the team. See [GitHub Review Request Delegation](#github-review-request-delegation-configuration) below for details. See [GitHub's documentation](https://docs.github.com/en/organizations/organizing-members-into-teams/managing-code-review-settings-for-your-team#configuring-team-notifications) for more configuration details.

### GitHub Review Request Delegation Configuration

The following arguments are supported:

* `algorithm` - (Optional) The algorithm to use when assigning pull requests to team members. Supported values are `ROUND_ROBIN` and `LOAD_BALANCE`. Default value is `ROUND_ROBIN`
* `member_count` - (Optional) The number of team members to assign to a pull request
* `notify` - (Optional) whether to notify the entire team when at least one member is also assigned to the pull request

- `algorithm` - (Optional) The algorithm to use when assigning pull requests to team members. Supported values are `ROUND_ROBIN` and `LOAD_BALANCE`. Default value is `ROUND_ROBIN`
- `member_count` - (Optional) The number of team members to assign to a pull request
- `notify` - (Optional) whether to notify the entire team when at least one member is also assigned to the pull request
- `excluded_members` - (Optional) A list of team member usernames to exclude from the PR review process.

## Import

Expand All @@ -57,7 +58,9 @@ GitHub Teams can be imported using the GitHub team ID, or the team slug e.g.
```
$ terraform import github_team.code_review_settings 1234567
```

or,

```
$ terraform import github_team_settings.code_review_settings SomeTeam
```
```