diff --git a/github/resource_github_team_members.go b/github/resource_github_team_members.go index 9be2b337b5..f6f36c2395 100644 --- a/github/resource_github_team_members.go +++ b/github/resource_github_team_members.go @@ -62,6 +62,7 @@ func resourceGithubTeamMembers() *schema.Resource { "members": { Type: schema.TypeSet, Required: true, + Set: hashTeamMember, Description: "List of users that should be members of the team.", Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ @@ -85,6 +86,18 @@ func resourceGithubTeamMembers() *schema.Resource { } } +// hashTeamMember keys a members block on the lowercased username. GitHub logins +// are case-insensitive and the API returns the canonical casing, so without this +// a configured login that differs only in case lands on a different set key than +// the one read back, and every plan removes and re-adds the member. +func hashTeamMember(v any) int { + m, _ := v.(map[string]any) + username, _ := m["username"].(string) + role, _ := m["role"].(string) + + return schema.HashString(strings.ToLower(username) + ":" + role) +} + func resourceGithubTeamMembersCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { meta, _ := m.(*Owner) client := meta.v3client diff --git a/github/resource_github_team_members_test.go b/github/resource_github_team_members_test.go index 3911bce9fa..5f0dc1de0c 100644 --- a/github/resource_github_team_members_test.go +++ b/github/resource_github_team_members_test.go @@ -57,6 +57,41 @@ resource "github_team_members" "test" { }) }) + t.Run("members_are_case_insensitive", func(t *testing.T) { + t.Parallel() + + team := mustCreateTestTeam(t) + + config := func(username string) string { + return fmt.Sprintf(` +resource "github_team_members" "test" { + team_slug = "%s" + + members { + username = "%s" + role = "member" + } +} +`, team.GetSlug(), username) + } + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config(testAccConf.testOrgUser1), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_team_members.test", tfjsonpath.New("members"), knownvalue.SetSizeExact(1)), + }, + }, + { + Config: config(flipUsernameCase(testAccConf.testOrgUser1)), + PlanOnly: true, + }, + }, + }) + }) + t.Run("team_by_id_as_slug", func(t *testing.T) { t.Parallel() @@ -431,3 +466,23 @@ resource "github_team_members" "test" { }) }) } + +func Test_hashTeamMember(t *testing.T) { + t.Parallel() + + member := func(username, role string) map[string]any { + return map[string]any{"username": username, "role": role} + } + + if hashTeamMember(member("OctoCat", "member")) != hashTeamMember(member("octocat", "member")) { + t.Error("expected the same hash for a username that differs only in case") + } + + if hashTeamMember(member("OctoCat", "member")) == hashTeamMember(member("OctoCat", "maintainer")) { + t.Error("expected different hashes for different roles") + } + + if hashTeamMember(member("OctoCat", "member")) == hashTeamMember(member("octodog", "member")) { + t.Error("expected different hashes for different usernames") + } +}