diff --git a/.golangci.yml b/.golangci.yml index 0be5721693..b10f1f5839 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -42,9 +42,6 @@ linters: linters: - unparam text: always receives - - linters: - - staticcheck - text: "SA1019.*ByID.*deprecated" issues: max-issues-per-linter: 0 diff --git a/github/data_source_github_organization_repository_role.go b/github/data_source_github_organization_repository_role.go index 1f970b1b66..453faf2a67 100644 --- a/github/data_source_github_organization_repository_role.go +++ b/github/data_source_github_organization_repository_role.go @@ -2,10 +2,8 @@ package github import ( "context" - "fmt" "strconv" - "github.com/google/go-github/v81/github" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) @@ -53,28 +51,11 @@ func dataSourceGithubOrganizationRepositoryRoleRead(ctx context.Context, d *sche roleId := int64(d.Get("role_id").(int)) - // TODO: Use this code when go-github is at v68+ - // role, _, err := client.Organizations.GetCustomRepoRole(ctx, orgName, roleId) - // if err != nil { - // return diag.FromErr(err) - // } - - roles, _, err := client.Organizations.ListCustomRepoRoles(ctx, orgName) + role, _, err := client.Organizations.GetCustomRepoRole(ctx, orgName, roleId) if err != nil { return diag.FromErr(err) } - var role *github.CustomRepoRoles - for _, r := range roles.CustomRepoRoles { - if r.GetID() == roleId { - role = r - break - } - } - if role == nil { - return diag.FromErr(fmt.Errorf("custom organization repo role with ID %d not found", roleId)) - } - r := map[string]any{ "role_id": role.GetID(), "name": role.GetName(), diff --git a/github/data_source_github_organization_role_teams.go b/github/data_source_github_organization_role_teams.go index b93d40a795..809bb07a45 100644 --- a/github/data_source_github_organization_role_teams.go +++ b/github/data_source_github_organization_role_teams.go @@ -48,23 +48,21 @@ func dataSourceGithubOrganizationRoleTeams() *schema.Resource { Type: schema.TypeString, Computed: true, }, - // TODO: Add these fields when go-github is v68+ - // See https://github.com/google/go-github/issues/3364 - // "assignment": { - // Description: "Determines if the team has a direct, indirect, or mixed relationship to a role.", - // Type: schema.TypeString, - // Computed: true, - // }, - // "parent_team_id": { - // Description: "The ID of the parent team if this is an indirect assignment.", - // Type: schema.TypeString, - // Computed: true, - // }, - // "parent_team_slug": { - // Description: "The slug of the parent team if this is an indirect assignment.", - // Type: schema.TypeString, - // Computed: true, - // }, + "assignment": { + Description: "Determines if the team has a direct, indirect, or mixed relationship to a role.", + Type: schema.TypeString, + Computed: true, + }, + "parent_team_id": { + Description: "The ID of the parent team if this is an indirect assignment.", + Type: schema.TypeString, + Computed: true, + }, + "parent_team_slug": { + Description: "The slug of the parent team if this is an indirect assignment.", + Type: schema.TypeString, + Computed: true, + }, }, }, }, diff --git a/github/data_source_github_organization_role_users.go b/github/data_source_github_organization_role_users.go index 47973ed651..1d95a1e6aa 100644 --- a/github/data_source_github_organization_role_users.go +++ b/github/data_source_github_organization_role_users.go @@ -38,23 +38,21 @@ func dataSourceGithubOrganizationRoleUsers() *schema.Resource { Type: schema.TypeString, Computed: true, }, - // TODO: Add these fields when go-github is v68+ - // See https://github.com/google/go-github/issues/3364 - // "assignment": { - // Description: "Determines if the team has a direct, indirect, or mixed relationship to a role.", - // Type: schema.TypeString, - // Computed: true, - // }, - // "parent_team_id": { - // Description: "The ID of the parent team if this is an indirect assignment.", - // Type: schema.TypeString, - // Computed: true, - // }, - // "parent_team_slug": { - // Description: "The slug of the parent team if this is an indirect assignment.", - // Type: schema.TypeString, - // Computed: true, - // }, + "assignment": { + Description: "Determines if the team has a direct, indirect, or mixed relationship to a role.", + Type: schema.TypeString, + Computed: true, + }, + "parent_team_id": { + Description: "The ID of the parent team if this is an indirect assignment.", + Type: schema.TypeString, + Computed: true, + }, + "parent_team_slug": { + Description: "The slug of the parent team if this is an indirect assignment.", + Type: schema.TypeString, + Computed: true, + }, }, }, }, diff --git a/github/resource_github_organization_repository_role.go b/github/resource_github_organization_repository_role.go index e1d248e47f..5870e0cd1c 100644 --- a/github/resource_github_organization_repository_role.go +++ b/github/resource_github_organization_repository_role.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "net/http" "strconv" "github.com/google/go-github/v81/github" @@ -99,36 +100,16 @@ func resourceGithubOrganizationRepositoryRoleRead(ctx context.Context, d *schema return diag.FromErr(err) } - // TODO: Use this code when go-github is v68+ - // role, _, err := client.Organizations.GetCustomRepoRole(ctx, orgName, roleId) - // if err != nil { - // if ghErr, ok := err.(*github.ErrorResponse); ok { - // if ghErr.Response.StatusCode == http.StatusNotFound { - // log.Printf("[WARN] GitHub organization repository role (%s/%d) not found, removing from state", orgName, roleId) - // d.SetId("") - // return nil - // } - // } - // return err - // } - - roles, _, err := client.Organizations.ListCustomRepoRoles(ctx, orgName) + role, _, err := client.Organizations.GetCustomRepoRole(ctx, orgName, roleId) if err != nil { - return diag.FromErr(err) - } - - var role *github.CustomRepoRoles - for _, r := range roles.CustomRepoRoles { - if r.GetID() == roleId { - role = r - break + if ghErr, ok := err.(*github.ErrorResponse); ok { + if ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[WARN] GitHub organization repository role (%s/%d) not found, removing from state", orgName, roleId) + d.SetId("") + return nil + } } - } - - if role == nil { - log.Printf("[WARN] GitHub organization repository role (%s/%d) not found, removing from state", orgName, roleId) - d.SetId("") - return nil + return diag.FromErr(err) } if err = d.Set("role_id", role.GetID()); err != nil { diff --git a/github/resource_github_repository_collaborators.go b/github/resource_github_repository_collaborators.go index 72b676c750..ab9edf835f 100644 --- a/github/resource_github_repository_collaborators.go +++ b/github/resource_github_repository_collaborators.go @@ -3,22 +3,22 @@ package github import ( "context" "fmt" - "log" "slices" - "sort" "strconv" "github.com/google/go-github/v81/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) func resourceGithubRepositoryCollaborators() *schema.Resource { return &schema.Resource{ - Create: resourceGithubRepositoryCollaboratorsCreate, - Read: resourceGithubRepositoryCollaboratorsRead, - Update: resourceGithubRepositoryCollaboratorsUpdate, - Delete: resourceGithubRepositoryCollaboratorsDelete, + CreateContext: resourceGithubRepositoryCollaboratorsCreateOrUpdate, + ReadContext: resourceGithubRepositoryCollaboratorsRead, + UpdateContext: resourceGithubRepositoryCollaboratorsCreateOrUpdate, + DeleteContext: resourceGithubRepositoryCollaboratorsDelete, Importer: &schema.ResourceImporter{ StateContext: schema.ImportStatePassthroughContext, }, @@ -60,36 +60,47 @@ func resourceGithubRepositoryCollaborators() *schema.Resource { Optional: true, Default: "push", }, + "slug": { + Type: schema.TypeString, + Description: "Slug of the team to add to the repository as a collaborator.", + Optional: true, + }, "team_id": { Type: schema.TypeString, - Description: "Team ID or slug to add to the repository as a collaborator.", - Required: true, + Description: "ID of the team to add to the repository as a collaborator.", + Optional: true, + Deprecated: "Use slug.", }, }, }, }, - "invitation_ids": { - Type: schema.TypeMap, - Description: "Map of usernames to invitation ID for any users added", - Elem: &schema.Schema{ - Type: schema.TypeString, - }, - Computed: true, - }, "ignore_team": { Type: schema.TypeSet, Optional: true, Description: "List of teams to ignore.", Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ + "slug": { + Type: schema.TypeString, + Description: "Slug of the team to add to ignore.", + Optional: true, + }, "team_id": { Type: schema.TypeString, Description: "ID or slug of the team to ignore.", - Required: true, + Optional: true, }, }, }, }, + "invitation_ids": { + Type: schema.TypeMap, + Description: "Map of usernames to invitation ID for any users added", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Computed: true, + }, }, CustomizeDiff: customdiff.Sequence( @@ -102,123 +113,180 @@ func resourceGithubRepositoryCollaborators() *schema.Resource { } } -type userCollaborator struct { - permission string - username string -} +func resourceGithubRepositoryCollaboratorsCreateOrUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + isOrg := meta.IsOrganization -func (c userCollaborator) Empty() bool { - return c == userCollaborator{} -} + repoName := d.Get("repository").(string) + users := d.Get("user").(*schema.Set).List() + teams := d.Get("team").(*schema.Set).List() + ignoreTeams := d.Get("ignore_team").(*schema.Set).List() -type invitedCollaborator struct { - userCollaborator - invitationID int64 -} + inUsers, err := getUserCollaborators(users) + if err != nil { + return diag.FromErr(err) + } -func flattenUserCollaborator(obj userCollaborator) any { - if obj.Empty() { - return nil + if checkDuplicateUsers(inUsers) { + return diag.Errorf("duplicate usernames found in user collaborators") } - transformed := map[string]any{ - "permission": obj.permission, - "username": obj.username, + + inTeams, err := getTeamCollaborators(ctx, meta, teams) + if err != nil { + return diag.FromErr(err) } - return transformed -} + if checkDuplicateTeams(inTeams) { + return diag.Errorf("duplicate teams found in team collaborators") + } -func flattenUserCollaborators(objs []userCollaborator, invites []invitedCollaborator) []any { - if objs == nil && invites == nil { - return nil + inIgnoreTeams, err := getTeamIdentities(ctx, meta, ignoreTeams) + if err != nil { + return diag.FromErr(err) } - for _, invite := range invites { - objs = append(objs, invite.userCollaborator) + err = updateUserCollaboratorsAndInvites(ctx, client, owner, repoName, inUsers) + if err != nil { + return diag.FromErr(err) } - sort.SliceStable(objs, func(i, j int) bool { - return objs[i].username < objs[j].username - }) + if isOrg { + err = updateTeamCollaborators(ctx, client, owner, repoName, inTeams, inIgnoreTeams) + if err != nil { + return diag.FromErr(err) + } + } + + d.SetId(repoName) - items := make([]any, len(objs)) - for i, obj := range objs { - items[i] = flattenUserCollaborator(obj) + ghInvitations, err := listInvitations(ctx, client, owner, repoName) + if err != nil { + return diag.FromErr(err) } - return items -} + invitationIds := make(map[string]string, len(ghInvitations)) + for _, i := range ghInvitations { + invitationIds[i.login] = strconv.FormatInt(*i.invitationID, 10) + } -type teamCollaborator struct { - permission string - teamID int64 - teamSlug string -} + if err = d.Set("invitation_ids", invitationIds); err != nil { + return diag.FromErr(err) + } -func (c teamCollaborator) Empty() bool { - return c == teamCollaborator{} + return nil } -func flattenTeamCollaborator(obj teamCollaborator, teamSlugs []string) any { - if obj.Empty() { - return nil +func resourceGithubRepositoryCollaboratorsRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + isOrg := meta.IsOrganization + + repoName := d.Id() + ignoreTeams := d.Get("ignore_team").(*schema.Set).List() + + inIgnoreTeams, err := getTeamIdentities(ctx, meta, ignoreTeams) + if err != nil { + return diag.FromErr(err) } - var teamIDString string - if slices.Contains(teamSlugs, obj.teamSlug) { - teamIDString = obj.teamSlug - } else { - teamIDString = strconv.FormatInt(obj.teamID, 10) + ghUsers, err := listUserCollaborators(ctx, client, owner, repoName) + if err != nil { + return diag.FromErr(err) } - transformed := map[string]any{ - "permission": obj.permission, - "team_id": teamIDString, + err = d.Set("user", ghUsers.flatten()) + if err != nil { + return diag.FromErr(err) } - return transformed + if isOrg { + ghTeams, err := listTeamCollaborators(ctx, client, owner, repoName, inIgnoreTeams) + if err != nil { + return diag.FromErr(err) + } + + err = d.Set("team", ghTeams.flatten()) + if err != nil { + return diag.FromErr(err) + } + } + + ghInvitations, err := listInvitations(ctx, client, owner, repoName) + if err != nil { + return diag.FromErr(err) + } + + invitationIds := make(map[string]string, len(ghInvitations)) + for _, i := range ghInvitations { + invitationIds[i.login] = strconv.FormatInt(*i.invitationID, 10) + } + + if err = d.Set("invitation_ids", invitationIds); err != nil { + return diag.FromErr(err) + } + + return nil } -func flattenTeamCollaborators(objs []teamCollaborator, teamSlugs []string) []any { - if objs == nil { - return nil +func resourceGithubRepositoryCollaboratorsDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + isOrg := meta.IsOrganization + + repoName := d.Id() + ignoreTeams := d.Get("ignore_team").(*schema.Set).List() + + inIgnoreTeams, err := getTeamIdentities(ctx, meta, ignoreTeams) + if err != nil { + return diag.FromErr(err) } - sort.SliceStable(objs, func(i, j int) bool { - return objs[i].teamID < objs[j].teamID - }) + tflog.Debug(ctx, fmt.Sprintf("Removing all collaborators from repository %s.", repoName)) - items := make([]any, len(objs)) - for i, obj := range objs { - items[i] = flattenTeamCollaborator(obj, teamSlugs) + err = updateUserCollaboratorsAndInvites(ctx, client, owner, repoName, nil) + if err != nil { + return diag.FromErr(err) } - return items + if isOrg { + err = updateTeamCollaborators(ctx, client, owner, repoName, nil, inIgnoreTeams) + if err != nil { + return diag.FromErr(err) + } + } + + return nil } -func listUserCollaborators(client *github.Client, isOrg bool, ctx context.Context, owner, repoName string) ([]userCollaborator, error) { - userCollaborators := make([]userCollaborator, 0) +func listUserCollaborators(ctx context.Context, client *github.Client, owner, repoName string) (userCollaborators, error) { + col := make([]userCollaborator, 0) + affiliations := []string{"direct", "outside"} for _, affiliation := range affiliations { - opt := &github.ListCollaboratorsOptions{ListOptions: github.ListOptions{ - PerPage: maxPerPage, - }, Affiliation: affiliation} + opt := &github.ListCollaboratorsOptions{ + ListOptions: github.ListOptions{ + PerPage: maxPerPage, + }, + Affiliation: affiliation, + } for { - collaborators, resp, err := client.Repositories.ListCollaborators(ctx, - owner, repoName, opt) + collaborators, resp, err := client.Repositories.ListCollaborators(ctx, owner, repoName, opt) if err != nil { return nil, err } for _, c := range collaborators { - // owners are listed in the collaborators list even though they don't have direct permissions - if !isOrg && c.GetLogin() == owner { - continue - } - permissionName := getPermission(c.GetRoleName()) - - userCollaborators = append(userCollaborators, userCollaborator{permissionName, c.GetLogin()}) + col = append(col, userCollaborator{ + userIdentity: userIdentity{ + login: c.GetLogin(), + }, + permission: getPermission(c.GetRoleName()), + }) } if resp.NextPage == 0 { @@ -227,11 +295,11 @@ func listUserCollaborators(client *github.Client, isOrg bool, ctx context.Contex opt.Page = resp.NextPage } } - return userCollaborators, nil + return col, nil } -func listInvitations(client *github.Client, ctx context.Context, owner, repoName string) ([]invitedCollaborator, error) { - invitedCollaborators := make([]invitedCollaborator, 0) +func listInvitations(ctx context.Context, client *github.Client, owner, repoName string) ([]userCollaborator, error) { + col := make([]userCollaborator, 0) opt := &github.ListOptions{PerPage: maxPerPage} for { @@ -241,10 +309,14 @@ func listInvitations(client *github.Client, ctx context.Context, owner, repoName } for _, i := range invitations { - permissionName := getPermission(i.GetPermissions()) + id := i.GetID() - invitedCollaborators = append(invitedCollaborators, invitedCollaborator{ - userCollaborator{permissionName, i.GetInvitee().GetLogin()}, i.GetID(), + col = append(col, userCollaborator{ + userIdentity: userIdentity{ + login: i.GetInvitee().GetLogin(), + }, + permission: getPermission(i.GetPermissions()), + invitationID: &id, }) } @@ -253,17 +325,17 @@ func listInvitations(client *github.Client, ctx context.Context, owner, repoName } opt.Page = resp.NextPage } - return invitedCollaborators, nil + + return col, nil } -func listTeams(client *github.Client, isOrg bool, ctx context.Context, owner, repoName string, ignoreTeamIds []int64) ([]teamCollaborator, error) { - allTeams := make([]teamCollaborator, 0) +func listTeamCollaborators(ctx context.Context, client *github.Client, owner, repoName string, ignoreTeams []teamIdentity) (teamCollaborators, error) { + col := make([]teamCollaborator, 0) - if !isOrg { - return allTeams, nil + opt := &github.ListOptions{ + PerPage: maxPerPage, } - opt := &github.ListOptions{PerPage: maxPerPage} for { repoTeams, resp, err := client.Repositories.ListTeams(ctx, owner, repoName, opt) if err != nil { @@ -271,11 +343,19 @@ func listTeams(client *github.Client, isOrg bool, ctx context.Context, owner, re } for _, t := range repoTeams { - if slices.Contains(ignoreTeamIds, t.GetID()) { + slug := t.GetSlug() + if slices.ContainsFunc(ignoreTeams, func(ignore teamIdentity) bool { + return ignore.slug == slug + }) { continue } - allTeams = append(allTeams, teamCollaborator{permission: getPermission(t.GetPermission()), teamID: t.GetID(), teamSlug: t.GetSlug()}) + col = append(col, teamCollaborator{ + teamIdentity: teamIdentity{ + slug: slug, + }, + permission: getPermission(t.GetPermission()), + }) } if resp.NextPage == 0 { @@ -284,359 +364,148 @@ func listTeams(client *github.Client, isOrg bool, ctx context.Context, owner, re opt.Page = resp.NextPage } - return allTeams, nil + return col, nil } -func listAllCollaborators(client *github.Client, isOrg bool, ctx context.Context, owner, repoName string, ignoreTeamIds []int64) ([]userCollaborator, []invitedCollaborator, []teamCollaborator, error) { - userCollaborators, err := listUserCollaborators(client, isOrg, ctx, owner, repoName) - if err != nil { - return nil, nil, nil, err - } - invitations, err := listInvitations(client, ctx, owner, repoName) - if err != nil { - return nil, nil, nil, err +func updateUserCollaboratorsAndInvites(ctx context.Context, client *github.Client, owner, repoName string, inUsers userCollaborators) error { + lookup := make(map[string]userCollaborator) + seen := make(map[string]any) + remove := make([]string, 0) + + for _, inUser := range inUsers { + lookup[inUser.login] = inUser } - teamCollaborators, err := listTeams(client, isOrg, ctx, owner, repoName, ignoreTeamIds) + + ghUsers, err := listUserCollaborators(ctx, client, owner, repoName) if err != nil { - return nil, nil, nil, err + return err } - return userCollaborators, invitations, teamCollaborators, err -} - -func matchUserCollaboratorsAndInvites(repoName string, want []any, hasUsers []userCollaborator, hasInvites []invitedCollaborator, meta any) error { - client := meta.(*Owner).v3client - owner := meta.(*Owner).name - ctx := context.Background() + for _, ghUser := range ghUsers { + inUser, ok := lookup[ghUser.login] + if ok { + seen[ghUser.login] = nil - for _, has := range hasUsers { - var wantPermission string - for _, w := range want { - userData := w.(map[string]any) - if userData["username"] == has.username { - wantPermission = userData["permission"].(string) - break - } - } - if wantPermission == "" { // user should NOT have permission - log.Printf("[DEBUG] Removing user %s from repo: %s.", has.username, repoName) - _, err := client.Repositories.RemoveCollaborator(ctx, owner, repoName, has.username) - if err != nil { - err = handleArchivedRepoDelete(err, "repository collaborator", has.username, owner, repoName) + if ghUser.permission != inUser.permission { + tflog.Info(ctx, fmt.Sprintf("Updating user %s permission from %s to %s for repo %s.", inUser.login, ghUser.permission, inUser.permission, repoName)) + _, _, err := client.Repositories.AddCollaborator( + ctx, owner, repoName, inUser.login, &github.RepositoryAddCollaboratorOptions{ + Permission: inUser.permission, + }) if err != nil { return err } } - } else if wantPermission != has.permission { // permission should be updated - log.Printf("[DEBUG] Updating user %s permission from %s to %s for repo: %s.", has.username, has.permission, wantPermission, repoName) - _, _, err := client.Repositories.AddCollaborator( - ctx, owner, repoName, has.username, &github.RepositoryAddCollaboratorOptions{ - Permission: wantPermission, - }, - ) - if err != nil { - return err - } + } else { + remove = append(remove, ghUser.login) } } - for _, has := range hasInvites { - var wantPermission string - for _, u := range want { - userData := u.(map[string]any) - if userData["username"] == has.username { - wantPermission = userData["permission"].(string) - break - } - } - if wantPermission == "" { // user should NOT have permission - log.Printf("[DEBUG] Deleting invite for user %s from repo: %s.", has.username, repoName) - _, err := client.Repositories.DeleteInvitation(ctx, owner, repoName, has.invitationID) - if err != nil { - err = handleArchivedRepoDelete(err, "repository collaborator invitation", has.username, owner, repoName) + ghInvites, err := listInvitations(ctx, client, owner, repoName) + if err != nil { + return err + } + + for _, ghInvite := range ghInvites { + inInvite, ok := lookup[ghInvite.login] + if ok { + seen[ghInvite.login] = nil + + if ghInvite.permission != inInvite.permission { + tflog.Info(ctx, fmt.Sprintf("Updating invite for user %s permission from %s to %s for repo %s.", inInvite.login, ghInvite.permission, inInvite.permission, repoName)) + _, _, err := client.Repositories.UpdateInvitation(ctx, owner, repoName, *ghInvite.invitationID, inInvite.permission) if err != nil { return err } } - } else if wantPermission != has.permission { // permission should be updated - log.Printf("[DEBUG] Updating invite for user %s permission from %s to %s for repo: %s.", has.username, has.permission, wantPermission, repoName) - _, _, err := client.Repositories.UpdateInvitation(ctx, owner, repoName, has.invitationID, wantPermission) + } else { + tflog.Info(ctx, fmt.Sprintf("Deleting invite for user %s from repo %s.", ghInvite.login, repoName)) + _, err := client.Repositories.DeleteInvitation(ctx, owner, repoName, *ghInvite.invitationID) if err != nil { - return err + return handleArchivedRepoDelete(err, "repository collaborator invitation", ghInvite.login, owner, repoName) } } } - for _, w := range want { - userData := w.(map[string]any) - username := userData["username"].(string) - permission := userData["permission"].(string) - var found bool - for _, has := range hasUsers { - if username == has.username { - found = true - break - } - } - if found { - continue - } - for _, has := range hasInvites { - if username == has.username { - found = true - break - } - } - if found { + for _, inUser := range inUsers { + if _, ok := seen[inUser.login]; ok { continue } - // user needs to be added - log.Printf("[DEBUG] Inviting user %s with permission %s for repo: %s.", username, permission, repoName) - _, _, err := client.Repositories.AddCollaborator( - ctx, owner, repoName, username, &github.RepositoryAddCollaboratorOptions{ - Permission: permission, - }, - ) - if err != nil { - return err - } - } - - return nil -} -func matchTeamCollaborators(repoName string, want []any, has []teamCollaborator, meta any) error { - client := meta.(*Owner).v3client - orgID := meta.(*Owner).id - owner := meta.(*Owner).name - ctx := context.Background() - - remove := make([]teamCollaborator, 0) - for _, hasTeam := range has { - var wantPerm string - for _, w := range want { - teamData := w.(map[string]any) - teamIDString := teamData["team_id"].(string) - teamID, err := getTeamID(teamIDString, meta) - if err != nil { - return err - } - if teamID == hasTeam.teamID { - wantPerm = teamData["permission"].(string) - break - } - } - if wantPerm == "" { // user should NOT have permission - remove = append(remove, hasTeam) - } else if wantPerm != hasTeam.permission { // permission should be updated - log.Printf("[DEBUG] Updating team %d permission from %s to %s for repo: %s.", hasTeam.teamID, hasTeam.permission, wantPerm, repoName) - _, err := client.Teams.AddTeamRepoByID( - ctx, orgID, hasTeam.teamID, owner, repoName, &github.TeamAddTeamRepoOptions{ - Permission: wantPerm, - }, - ) - if err != nil { - return err - } - } - } - - for _, t := range want { - teamData := t.(map[string]any) - teamIDString := teamData["team_id"].(string) - teamID, err := getTeamID(teamIDString, meta) - if err != nil { - return err - } - var found bool - for _, c := range has { - if teamID == c.teamID { - found = true - break - } - } - if found { - continue - } - permission := teamData["permission"].(string) - // team needs to be added - log.Printf("[DEBUG] Adding team %s with permission %s for repo: %s.", teamIDString, permission, repoName) - _, err = client.Teams.AddTeamRepoByID( - ctx, orgID, teamID, owner, repoName, &github.TeamAddTeamRepoOptions{ - Permission: permission, - }, - ) + tflog.Info(ctx, fmt.Sprintf("Inviting user %s to repo %s with permission %s.", inUser.login, repoName, inUser.permission)) + _, _, err := client.Repositories.AddCollaborator(ctx, owner, repoName, inUser.login, &github.RepositoryAddCollaboratorOptions{ + Permission: inUser.permission, + }) if err != nil { return err } } - for _, team := range remove { - log.Printf("[DEBUG] Removing team %d from repo: %s.", team.teamID, repoName) - _, err := client.Teams.RemoveTeamRepoByID(ctx, orgID, team.teamID, owner, repoName) + for _, l := range remove { + tflog.Info(ctx, fmt.Sprintf("Removing user %s from repo %s.", l, repoName)) + _, err := client.Repositories.RemoveCollaborator(ctx, owner, repoName, l) if err != nil { - err = handleArchivedRepoDelete(err, "team repository access", fmt.Sprintf("team %d", team.teamID), owner, repoName) - if err != nil { - return err - } + return handleArchivedRepoDelete(err, "repository collaborator", l, owner, repoName) } } return nil } -func resourceGithubRepositoryCollaboratorsCreate(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client +func updateTeamCollaborators(ctx context.Context, client *github.Client, owner, repoName string, inTeams teamCollaborators, ignoreTeams []teamIdentity) error { + lookup := make(map[string]teamCollaborator) + seen := make(map[string]any) + remove := make([]string, 0) - owner := meta.(*Owner).name - isOrg := meta.(*Owner).IsOrganization - users := d.Get("user").(*schema.Set).List() - teams := d.Get("team").(*schema.Set).List() - repoName := d.Get("repository").(string) - ctx := context.Background() - - teamsMap := make(map[string]struct{}, len(teams)) - for _, team := range teams { - teamIDString := team.(map[string]any)["team_id"].(string) - if _, found := teamsMap[teamIDString]; found { - return fmt.Errorf("duplicate set member: %s", teamIDString) - } - teamsMap[teamIDString] = struct{}{} - } - usersMap := make(map[string]struct{}, len(users)) - for _, user := range users { - username := user.(map[string]any)["username"].(string) - if _, found := usersMap[username]; found { - return fmt.Errorf("duplicate set member found: %s", username) - } - usersMap[username] = struct{}{} + for _, inTeam := range inTeams { + lookup[inTeam.slug] = inTeam } - ignoreTeamIds, err := getIgnoreTeamIds(d, meta) + ghTeams, err := listTeamCollaborators(ctx, client, owner, repoName, ignoreTeams) if err != nil { return err } - userCollaborators, invitations, teamCollaborators, err := listAllCollaborators(client, isOrg, ctx, owner, repoName, ignoreTeamIds) - if err != nil { - return deleteResourceOn404AndSwallow304OtherwiseReturnError(err, d, "repository collaborators (%s/%s)", owner, repoName) - } - - err = matchTeamCollaborators(repoName, teams, teamCollaborators, meta) - if err != nil { - return err - } - - err = matchUserCollaboratorsAndInvites(repoName, users, userCollaborators, invitations, meta) - if err != nil { - return err - } - - d.SetId(repoName) - - return resourceGithubRepositoryCollaboratorsRead(d, meta) -} + for _, ghTeam := range ghTeams { + inTeam, ok := lookup[ghTeam.slug] + if ok { + seen[ghTeam.slug] = nil -func resourceGithubRepositoryCollaboratorsRead(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - - owner := meta.(*Owner).name - isOrg := meta.(*Owner).IsOrganization - repoName := d.Id() - ctx := context.WithValue(context.Background(), ctxId, d.Id()) - - ignoreTeamIds, err := getIgnoreTeamIds(d, meta) - if err != nil { - return err - } - - userCollaborators, invitedCollaborators, teamCollaborators, err := listAllCollaborators(client, isOrg, ctx, owner, repoName, ignoreTeamIds) - if err != nil { - return deleteResourceOn404AndSwallow304OtherwiseReturnError(err, d, "repository collaborators (%s/%s)", owner, repoName) - } - - invitationIds := make(map[string]string, len(invitedCollaborators)) - for _, i := range invitedCollaborators { - invitationIds[i.username] = strconv.FormatInt(i.invitationID, 10) - } - - sourceTeams := d.Get("team").(*schema.Set).List() - teamSlugs := make([]string, len(sourceTeams)) - for i, t := range sourceTeams { - teamIdString := t.(map[string]any)["team_id"].(string) - _, parseIntErr := strconv.ParseInt(teamIdString, 10, 64) - if parseIntErr != nil { - teamSlugs[i] = teamIdString + if ghTeam.permission != inTeam.permission { + tflog.Info(ctx, fmt.Sprintf("Updating team %s permission from %s to %s for repo %s.", inTeam.slug, ghTeam.permission, inTeam.permission, repoName)) + _, err := client.Teams.AddTeamRepoBySlug(ctx, owner, inTeam.slug, owner, repoName, &github.TeamAddTeamRepoOptions{ + Permission: inTeam.permission, + }) + if err != nil { + return err + } + } + } else { + remove = append(remove, ghTeam.slug) } } - err = d.Set("repository", repoName) - if err != nil { - return err - } - err = d.Set("user", flattenUserCollaborators(userCollaborators, invitedCollaborators)) - if err != nil { - return err - } - err = d.Set("team", flattenTeamCollaborators(teamCollaborators, teamSlugs)) - if err != nil { - return err - } - err = d.Set("invitation_ids", invitationIds) - if err != nil { - return err - } - - return nil -} - -func resourceGithubRepositoryCollaboratorsUpdate(d *schema.ResourceData, meta any) error { - return resourceGithubRepositoryCollaboratorsCreate(d, meta) -} - -func resourceGithubRepositoryCollaboratorsDelete(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - - owner := meta.(*Owner).name - isOrg := meta.(*Owner).IsOrganization - repoName := d.Get("repository").(string) - ctx := context.Background() - - ignoreTeamIds, err := getIgnoreTeamIds(d, meta) - if err != nil { - return err - } - - userCollaborators, invitations, teamCollaborators, err := listAllCollaborators(client, isOrg, ctx, owner, repoName, ignoreTeamIds) - if err != nil { - return deleteResourceOn404AndSwallow304OtherwiseReturnError(err, d, "repository collaborators (%s/%s)", owner, repoName) - } - - log.Printf("[DEBUG] Deleting all users, invites and collaborators for repo: %s.", repoName) + for _, inTeam := range inTeams { + if _, ok := seen[inTeam.slug]; ok { + continue + } - // delete all users - err = matchUserCollaboratorsAndInvites(repoName, nil, userCollaborators, invitations, meta) - if err != nil { - return err + tflog.Info(ctx, fmt.Sprintf("Adding team %s to repo % with permission %s.", inTeam.slug, repoName, inTeam.permission)) + _, err := client.Teams.AddTeamRepoBySlug(ctx, owner, inTeam.slug, owner, repoName, &github.TeamAddTeamRepoOptions{ + Permission: inTeam.permission, + }) + if err != nil { + return err + } } - // delete all teams - err = matchTeamCollaborators(repoName, nil, teamCollaborators, meta) - return err -} - -func getIgnoreTeamIds(d *schema.ResourceData, meta any) ([]int64, error) { - ignoreTeams := d.Get("ignore_team").(*schema.Set).List() - ignoreTeamIds := make([]int64, len(ignoreTeams)) - - for i, t := range ignoreTeams { - s := t.(map[string]any)["team_id"].(string) - id, err := getTeamID(s, meta) + for _, s := range remove { + tflog.Info(ctx, fmt.Sprintf("Removing team %s from repo %s.", s, repoName)) + _, err := client.Teams.RemoveTeamRepoBySlug(ctx, owner, s, owner, repoName) if err != nil { - return nil, err + return handleArchivedRepoDelete(err, "team repository access", fmt.Sprintf("team %s", s), owner, repoName) } - ignoreTeamIds[i] = id } - return ignoreTeamIds, nil + return nil } diff --git a/github/resource_github_repository_environment.go b/github/resource_github_repository_environment.go index f4fa9fda79..7b99da0ce8 100644 --- a/github/resource_github_repository_environment.go +++ b/github/resource_github_repository_environment.go @@ -14,10 +14,10 @@ import ( func resourceGithubRepositoryEnvironment() *schema.Resource { return &schema.Resource{ - Create: resourceGithubRepositoryEnvironmentCreate, - Read: resourceGithubRepositoryEnvironmentRead, - Update: resourceGithubRepositoryEnvironmentUpdate, - Delete: resourceGithubRepositoryEnvironmentDelete, + CreateContext: resourceGithubRepositoryEnvironmentCreate, + ReadContext: resourceGithubRepositoryEnvironmentRead, + UpdateContext: resourceGithubRepositoryEnvironmentUpdate, + DeleteContext: resourceGithubRepositoryEnvironmentDelete, Importer: &schema.ResourceImporter{ StateContext: schema.ImportStatePassthroughContext, }, @@ -55,21 +55,39 @@ func resourceGithubRepositoryEnvironment() *schema.Resource { "reviewers": { Type: schema.TypeList, Optional: true, - MaxItems: 6, + MaxItems: 1, Description: "The environment reviewers configuration.", Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ + "team_slugs": { + Type: schema.TypeSet, + Optional: true, + MaxItems: 6, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Up to 6 slugs for teams who may review jobs that reference the environment. Reviewers must have at least read access to the repository. Only one of the required reviewers needs to approve the job for it to proceed.", + }, + "user_logins": { + Type: schema.TypeSet, + Optional: true, + MaxItems: 6, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Up to 6 logins for users who may review jobs that reference the environment. Reviewers must have at least read access to the repository. Only one of the required reviewers needs to approve the job for it to proceed.", + }, "teams": { Type: schema.TypeSet, Optional: true, + MaxItems: 6, Elem: &schema.Schema{Type: schema.TypeInt}, Description: "Up to 6 IDs for teams who may review jobs that reference the environment. Reviewers must have at least read access to the repository. Only one of the required reviewers needs to approve the job for it to proceed.", + Deprecated: "Use team_slugs", }, "users": { Type: schema.TypeSet, Optional: true, + MaxItems: 6, Elem: &schema.Schema{Type: schema.TypeInt}, Description: "Up to 6 IDs for users who may review jobs that reference the environment. Reviewers must have at least read access to the repository. Only one of the required reviewers needs to approve the job for it to proceed.", + Deprecated: "Use user_logins.", }, }, }, diff --git a/github/resource_github_team.go b/github/resource_github_team.go index 5eb0d6f757..4982c3d8cb 100644 --- a/github/resource_github_team.go +++ b/github/resource_github_team.go @@ -84,8 +84,9 @@ func resourceGithubTeam() *schema.Resource { Description: "The slug of the created team.", }, "members_count": { - Type: schema.TypeInt, - Computed: true, + Type: schema.TypeInt, + Computed: true, + Deprecated: "This will be removed in a future version.", }, "parent_team_read_id": { Type: schema.TypeString, @@ -112,14 +113,15 @@ func resourceGithubTeam() *schema.Resource { } } -func resourceGithubTeamCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - err := checkOrganization(meta) +func resourceGithubTeamCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + err := checkOrganization(m) if err != nil { return diag.FromErr(err) } - client := meta.(*Owner).v3client - ownerName := meta.(*Owner).name + meta := m.(*Owner) + client := meta.v3client + owner := meta.name name := d.Get("name").(string) @@ -134,15 +136,21 @@ func resourceGithubTeamCreate(ctx context.Context, d *schema.ResourceData, meta newTeam.LDAPDN = &ldapDN } - if parentTeamID, ok := d.GetOk("parent_team_id"); ok { - teamId, err := getTeamID(parentTeamID.(string), meta) - if err != nil { - return diag.FromErr(err) + if p, ok := d.GetOk("parent_team_id"); ok { + if s, ok := p.(string); ok && len(s) > 0 { + if parentTeamID, ok := parseTeamID(s); ok { + newTeam.ParentTeamID = github.Ptr(parentTeamID) + } else { + parentTeamID, err := lookupTeamID(ctx, meta, s) + if err != nil { + return diag.FromErr(err) + } + newTeam.ParentTeamID = github.Ptr(parentTeamID) + } } - newTeam.ParentTeamID = &teamId } - team, resp, err := client.Teams.CreateTeam(ctx, ownerName, newTeam) + team, resp, err := client.Teams.CreateTeam(ctx, owner, newTeam) if err != nil { return diag.FromErr(err) } @@ -164,7 +172,7 @@ func resourceGithubTeamCreate(ctx context.Context, d *schema.ResourceData, meta on the parent team, the operation might still fail to set the parent team. */ if newTeam.ParentTeamID != nil && team.Parent == nil { - _, resp, err = client.Teams.EditTeamBySlug(ctx, ownerName, slug, newTeam, false) + _, resp, err = client.Teams.EditTeamBySlug(ctx, owner, slug, newTeam, false) if err != nil { return diag.FromErr(err) } @@ -172,8 +180,8 @@ func resourceGithubTeamCreate(ctx context.Context, d *schema.ResourceData, meta create_default_maintainer := d.Get("create_default_maintainer").(bool) if !create_default_maintainer && membersCount > 0 { - log.Printf("[DEBUG] Removing default maintainer from team: %s (%s)", name, ownerName) - err := removeTeamMembers(ctx, client, ownerName, slug) + log.Printf("[DEBUG] Removing default maintainer from team: %s (%s)", name, owner) + err := removeTeamMembers(ctx, client, owner, slug) if err != nil { return diag.FromErr(err) } @@ -219,25 +227,26 @@ func resourceGithubTeamCreate(ctx context.Context, d *schema.ResourceData, meta return nil } -func resourceGithubTeamRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - err := checkOrganization(meta) +func resourceGithubTeamRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + err := checkOrganization(m) if err != nil { return diag.FromErr(err) } - client := meta.(*Owner).v3client - orgId := meta.(*Owner).id + meta := m.(*Owner) + client := meta.v3client + owner := meta.name - id, err := strconv.ParseInt(d.Id(), 10, 64) + identity, err := getTeamIdentityStrict(d) if err != nil { - return diag.FromErr(unconvertibleIdErr(d.Id(), err)) + return diag.FromErr(err) } if !d.IsNewResource() { ctx = context.WithValue(ctx, ctxEtag, d.Get("etag").(string)) } - team, resp, err := client.Teams.GetTeamByID(ctx, orgId, id) + team, resp, err := client.Teams.GetTeamBySlug(ctx, owner, identity.slug) if err != nil { var ghErr *github.ErrorResponse if errors.As(err, &ghErr) { @@ -254,9 +263,7 @@ func resourceGithubTeamRead(ctx context.Context, d *schema.ResourceData, meta an return diag.FromErr(err) } - if err = d.Set("slug", team.GetSlug()); err != nil { - return diag.FromErr(err) - } + d.SetId(strconv.FormatInt(team.GetID(), 10)) if err = d.Set("description", team.GetDescription()); err != nil { return diag.FromErr(err) } @@ -306,15 +313,20 @@ func resourceGithubTeamRead(ctx context.Context, d *schema.ResourceData, meta an return nil } -func resourceGithubTeamUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - err := checkOrganization(meta) +func resourceGithubTeamUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + err := checkOrganization(m) if err != nil { return diag.FromErr(err) } - client := meta.(*Owner).v3client - orgId := meta.(*Owner).id - var removeParentTeam bool + meta := m.(*Owner) + client := meta.v3client + owner := meta.name + + identity, err := getTeamIdentityStrict(d) + if err != nil { + return diag.FromErr(err) + } editedTeam := github.NewTeam{ Name: d.Get("name").(string), @@ -322,23 +334,31 @@ func resourceGithubTeamUpdate(ctx context.Context, d *schema.ResourceData, meta Privacy: github.Ptr(d.Get("privacy").(string)), NotificationSetting: github.Ptr(d.Get("notification_setting").(string)), } - if parentTeamID, ok := d.GetOk("parent_team_id"); ok { - teamId, err := getTeamID(parentTeamID.(string), meta) - if err != nil { - return diag.FromErr(err) - } - editedTeam.ParentTeamID = &teamId - removeParentTeam = false - } else { + + var removeParentTeam bool + if d.HasChange("parent_team_id") { removeParentTeam = true - } - teamId, err := strconv.ParseInt(d.Id(), 10, 64) - if err != nil { - return diag.FromErr(unconvertibleIdErr(d.Id(), err)) + if p, ok := d.GetOk("parent_team_id"); ok { + if s, ok := p.(string); ok && len(s) > 0 { + if parentTeamID, ok := parseTeamID(s); ok { + editedTeam.ParentTeamID = github.Ptr(parentTeamID) + removeParentTeam = false + } else { + parentTeamID, err := lookupTeamID(ctx, meta, s) + if err != nil { + return diag.FromErr(err) + } + editedTeam.ParentTeamID = github.Ptr(parentTeamID) + removeParentTeam = false + } + } + } + } else { + removeParentTeam = false } - team, resp, err := client.Teams.EditTeamByID(ctx, orgId, teamId, editedTeam, removeParentTeam) + team, resp, err := client.Teams.EditTeamBySlug(ctx, owner, identity.slug, editedTeam, removeParentTeam) if err != nil { return diag.FromErr(err) } @@ -392,21 +412,22 @@ func resourceGithubTeamUpdate(ctx context.Context, d *schema.ResourceData, meta return nil } -func resourceGithubTeamDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - err := checkOrganization(meta) +func resourceGithubTeamDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + err := checkOrganization(m) if err != nil { return diag.FromErr(err) } - client := meta.(*Owner).v3client - orgId := meta.(*Owner).id + meta := m.(*Owner) + client := meta.v3client + owner := meta.name - id, err := strconv.ParseInt(d.Id(), 10, 64) + identity, err := getTeamIdentityStrict(d) if err != nil { - return diag.FromErr(unconvertibleIdErr(d.Id(), err)) + return diag.FromErr(err) } - _, err = client.Teams.DeleteTeamByID(ctx, orgId, id) + _, err = client.Teams.DeleteTeamBySlug(ctx, owner, identity.slug) /* When deleting a team and it failed, we need to check if it has already been deleted meanwhile. This could be the case when deleting nested teams via Terraform by looping through a module @@ -415,8 +436,7 @@ func resourceGithubTeamDelete(ctx context.Context, d *schema.ResourceData, meta GitHub automatically). So we're checking if it still exists and if not, simply remove it from TF state. */if err != nil { - // Fetch the team in order to see if it exists or not (http 404) - _, _, err = client.Teams.GetTeamByID(ctx, orgId, id) + _, _, err = client.Teams.GetTeamBySlug(ctx, owner, identity.slug) if err != nil { var ghErr *github.ErrorResponse if errors.As(err, &ghErr) { @@ -434,14 +454,30 @@ func resourceGithubTeamDelete(ctx context.Context, d *schema.ResourceData, meta return nil } -func resourceGithubTeamImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { - teamId, err := getTeamID(d.Id(), meta) - if err != nil { - return nil, err +func resourceGithubTeamImport(ctx context.Context, d *schema.ResourceData, m any) ([]*schema.ResourceData, error) { + meta := m.(*Owner) + + var slug string + id, ok := parseTeamID(d.Id()) + if ok { + s, err := lookupTeamSlug(ctx, meta, id) + if err != nil { + return nil, err + } + slug = s + } else { + i, err := lookupTeamID(ctx, meta, d.Id()) + if err != nil { + return nil, err + } + slug = d.Id() + id = i } - d.SetId(strconv.FormatInt(teamId, 10)) - if err = d.Set("create_default_maintainer", false); err != nil { + if err := d.Set("slug", slug); err != nil { + return nil, err + } + if err := d.Set("create_default_maintainer", false); err != nil { return nil, err } diff --git a/github/resource_github_team_members.go b/github/resource_github_team_members.go index f972473e84..27bbfd9af9 100644 --- a/github/resource_github_team_members.go +++ b/github/resource_github_team_members.go @@ -18,20 +18,28 @@ type MemberChange struct { func resourceGithubTeamMembers() *schema.Resource { return &schema.Resource{ - Create: resourceGithubTeamMembersCreate, - Read: resourceGithubTeamMembersRead, - Update: resourceGithubTeamMembersUpdate, - Delete: resourceGithubTeamMembersDelete, + CreateContext: resourceGithubTeamMembersCreate, + ReadContext: resourceGithubTeamMembersRead, + UpdateContext: resourceGithubTeamMembersUpdate, + DeleteContext: resourceGithubTeamMembersDelete, Importer: &schema.ResourceImporter{ - State: resourceGithubTeamMembersImport, + StateContext: resourceGithubTeamMembersImport, }, Schema: map[string]*schema.Schema{ + "team_slug": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ExactlyOneOf: []string{"team_slug", "team_id"}, + Description: "The team slug.", + }, "team_id": { Type: schema.TypeString, - Required: true, + Optional: true, ForceNew: true, - Description: "The GitHub team id or slug", + Description: "The team id or slug", + Deprecated: "Use team_slug.", }, "members": { Type: schema.TypeSet, diff --git a/github/resource_github_team_membership.go b/github/resource_github_team_membership.go index e7932a791b..95328d28ca 100644 --- a/github/resource_github_team_membership.go +++ b/github/resource_github_team_membership.go @@ -13,12 +13,12 @@ import ( func resourceGithubTeamMembership() *schema.Resource { return &schema.Resource{ - Create: resourceGithubTeamMembershipCreateOrUpdate, - Read: resourceGithubTeamMembershipRead, - Update: resourceGithubTeamMembershipCreateOrUpdate, - Delete: resourceGithubTeamMembershipDelete, + CreateContext: resourceGithubTeamMembershipCreateOrUpdate, + ReadContext: resourceGithubTeamMembershipRead, + UpdateContext: resourceGithubTeamMembershipCreateOrUpdate, + DeleteContext: resourceGithubTeamMembershipDelete, Importer: &schema.ResourceImporter{ - State: func(d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + StateContext: func(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { teamIdString, username, err := parseTwoPartID(d.Id(), "team_id", "username") if err != nil { return nil, err @@ -35,11 +35,19 @@ func resourceGithubTeamMembership() *schema.Resource { }, Schema: map[string]*schema.Schema{ + "team_slug": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ExactlyOneOf: []string{"team_slug", "team_id"}, + Description: "The team slug.", + }, "team_id": { Type: schema.TypeString, Required: true, ForceNew: true, - Description: "The GitHub team id or the GitHub team slug.", + Description: "The team id or slug.", + Deprecated: "Use team_slug.", }, "username": { Type: schema.TypeString, diff --git a/github/resource_github_team_repository.go b/github/resource_github_team_repository.go index ecdbe26216..6353a778a9 100644 --- a/github/resource_github_team_repository.go +++ b/github/resource_github_team_repository.go @@ -14,12 +14,12 @@ import ( func resourceGithubTeamRepository() *schema.Resource { return &schema.Resource{ - Create: resourceGithubTeamRepositoryCreate, - Read: resourceGithubTeamRepositoryRead, - Update: resourceGithubTeamRepositoryUpdate, - Delete: resourceGithubTeamRepositoryDelete, + CreateContext: resourceGithubTeamRepositoryCreate, + ReadContext: resourceGithubTeamRepositoryRead, + UpdateContext: resourceGithubTeamRepositoryUpdate, + DeleteContext: resourceGithubTeamRepositoryDelete, Importer: &schema.ResourceImporter{ - State: func(d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + StateContext: func(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { teamIdString, username, err := parseTwoPartID(d.Id(), "team_id", "username") if err != nil { return nil, err @@ -36,11 +36,19 @@ func resourceGithubTeamRepository() *schema.Resource { }, Schema: map[string]*schema.Schema{ + "team_slug": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ExactlyOneOf: []string{"team_slug", "team_id"}, + Description: "The team slug.", + }, "team_id": { Type: schema.TypeString, Required: true, ForceNew: true, Description: "ID or slug of team", + Deprecated: "Use team_slug.", }, "repository": { Type: schema.TypeString, diff --git a/github/resource_github_team_settings.go b/github/resource_github_team_settings.go index 38841d3e80..c5c3b3b9b7 100644 --- a/github/resource_github_team_settings.go +++ b/github/resource_github_team_settings.go @@ -12,24 +12,27 @@ import ( func resourceGithubTeamSettings() *schema.Resource { return &schema.Resource{ - Create: resourceGithubTeamSettingsCreate, - Read: resourceGithubTeamSettingsRead, - Update: resourceGithubTeamSettingsUpdate, - Delete: resourceGithubTeamSettingsDelete, + CreateContext: resourceGithubTeamSettingsCreate, + ReadContext: resourceGithubTeamSettingsRead, + UpdateContext: resourceGithubTeamSettingsUpdate, + DeleteContext: resourceGithubTeamSettingsDelete, Importer: &schema.ResourceImporter{ - State: resourceGithubTeamSettingsImport, + StateContext: resourceGithubTeamSettingsImport, }, Schema: map[string]*schema.Schema{ + "team_slug": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ExactlyOneOf: []string{"team_slug", "team_id"}, + Description: "The team slug.", + }, "team_id": { Type: schema.TypeString, Required: true, ForceNew: true, - Description: "The GitHub team id or the GitHub team slug.", - }, - "team_slug": { - Type: schema.TypeString, - Computed: true, - Description: "The slug of the Team within the Organization.", + Description: "The team id or slug.", + Deprecated: "Use team_slug.", }, "team_uid": { Type: schema.TypeString, diff --git a/github/util.go b/github/util.go index 693bf55d0f..40bd69c348 100644 --- a/github/util.go +++ b/github/util.go @@ -1,7 +1,6 @@ package github import ( - "context" "crypto/md5" "errors" "fmt" @@ -10,7 +9,6 @@ import ( "regexp" "slices" "sort" - "strconv" "strings" "github.com/google/go-github/v81/github" @@ -171,57 +169,6 @@ func splitRepoFilePath(path string) (string, string) { return parts[0], strings.Join(parts[1:], "/") } -func getTeamID(teamIDString string, meta any) (int64, error) { - // Given a string that is either a team id or team slug, return the - // id of the team it is referring to. - ctx := context.Background() - client := meta.(*Owner).v3client - orgName := meta.(*Owner).name - - teamId, parseIntErr := strconv.ParseInt(teamIDString, 10, 64) - if parseIntErr == nil { - return teamId, nil - } - - // The given id not an integer, assume it is a team slug - team, _, slugErr := client.Teams.GetTeamBySlug(ctx, orgName, teamIDString) - if slugErr != nil { - return -1, errors.New(parseIntErr.Error() + slugErr.Error()) - } - return team.GetID(), nil -} - -func getTeamSlug(teamIDString string, meta any) (string, error) { - // Given a string that is either a team id or team slug, return the - // team slug it is referring to. - ctx := context.Background() - client := meta.(*Owner).v3client - orgName := meta.(*Owner).name - orgId := meta.(*Owner).id - - teamId, parseIntErr := strconv.ParseInt(teamIDString, 10, 64) - if parseIntErr != nil { - // The given id not an integer, assume it is a team slug - team, _, slugErr := client.Teams.GetTeamBySlug(ctx, orgName, teamIDString) - if slugErr != nil { - return "", errors.New(parseIntErr.Error() + slugErr.Error()) - } - return team.GetSlug(), nil - } - - // The given id is an integer, assume it is a team id - team, _, teamIdErr := client.Teams.GetTeamByID(ctx, orgId, teamId) - if teamIdErr != nil { - // There isn't a team with the given ID, assume it is a teamslug - team, _, slugErr := client.Teams.GetTeamBySlug(ctx, orgName, teamIDString) - if slugErr != nil { - return "", errors.New(teamIdErr.Error() + slugErr.Error()) - } - return team.GetSlug(), nil - } - return team.GetSlug(), nil -} - // https://docs.github.com/en/actions/reference/encrypted-secrets#naming-your-secrets var secretNameRegexp = regexp.MustCompile("^[a-zA-Z_][a-zA-Z0-9_]*$") diff --git a/github/util_team.go b/github/util_team.go new file mode 100644 index 0000000000..4c8b0de521 --- /dev/null +++ b/github/util_team.go @@ -0,0 +1,207 @@ +package github + +import ( + "context" + "fmt" + "strconv" +) + +// teamWithSlug is an interface representing a GitHub team that has a slug. +type teamWithSlug interface { + getSlug() string +} + +// teamIdentity represents a GitHub team by its slug. +// It also can optionally include the team ID as a string for legacy support. +type teamIdentity struct { + slug string + teamID *string +} + +// getSlug returns the slug of the team. +func (t teamIdentity) getSlug() string { + return t.slug +} + +// teamCollaborator represents a GitHub team collaborator with its identity and permission level. +type teamCollaborator struct { + teamIdentity + permission string +} + +// flatten converts the teamCollaborator into a format suitable for Terraform schema. +func (t teamCollaborator) flatten() any { + m := map[string]any{ + "slug": t.slug, + "permission": t.permission, + } + if t.teamID != nil { + m["team_id"] = *t.teamID + } + return m +} + +// teamCollaborators is a slice of teamCollaborator. +type teamCollaborators []teamCollaborator + +// flatten converts the teamCollaborators slice into a format suitable for Terraform schema. +func (tc teamCollaborators) flatten() any { + items := make([]any, len(tc)) + + for i, t := range tc { + items[i] = t.flatten() + } + + return items +} + +// parseTeamID attempts to parse the given string as a team ID (int64). +// It returns the parsed ID and a boolean indicating whether the parsing was successful. +func parseTeamID(s string) (int64, bool) { + id, err := strconv.ParseInt(s, 10, 64) + return id, err == nil +} + +// getTeamSlug returns the slug of the team identified by the given string, which may be either a team ID or a team slug. +func getTeamSlug(ctx context.Context, meta *Owner, s string) (string, error) { + id, ok := parseTeamID(s) + if !ok { + // The given id not an integer, assume it is a team slug + return s, nil + } + + return lookupTeamSlug(ctx, meta, id) +} + +// lookupTeamSlug looks up the slug of a team by its ID. +func lookupTeamSlug(ctx context.Context, meta *Owner, id int64) (string, error) { + client := meta.v3client + orgId := meta.id + + team, _, err := client.Teams.GetTeamByID(ctx, orgId, id) //nolint:staticcheck + if err != nil { + return "", err + } + return team.GetSlug(), nil +} + +// lookupTeamID looks up the ID of a team by its slug. +func lookupTeamID(ctx context.Context, meta *Owner, slug string) (int64, error) { + client := meta.v3client + owner := meta.name + + team, _, err := client.Teams.GetTeamBySlug(ctx, owner, slug) + if err != nil { + return 0, err + } + return team.GetID(), nil +} + +// getTeamIdentity returns a team identity represented by the input. +// The input may include a team slug or a team ID. +// The output will include the team slug. +func getTeamIdentity(ctx context.Context, meta *Owner, d any) (teamIdentity, error) { + m, ok := d.(map[string]any) + if !ok { + return teamIdentity{}, fmt.Errorf("team input invalid") + } + + if s, ok := m["slug"]; ok { + if slug, ok := s.(string); ok && len(slug) > 0 { + return teamIdentity{slug: slug}, nil + } + } + + if d, ok := m["team_id"]; ok { + if teamID, ok := d.(string); ok && len(teamID) > 0 { + slug, err := getTeamSlug(ctx, meta, teamID) + if err != nil { + return teamIdentity{}, err + } + + return teamIdentity{slug: slug, teamID: &teamID}, nil + } + } + + return teamIdentity{}, fmt.Errorf("team input must include either 'slug' or 'team_id'") +} + +// getTeamIdentity returns a team identity represented by the input. +// The input must include a team slug. +func getTeamIdentityStrict(d any) (teamIdentity, error) { + m, ok := d.(map[string]any) + if !ok { + return teamIdentity{}, fmt.Errorf("team input invalid") + } + + if s, ok := m["slug"]; ok { + if slug, ok := s.(string); ok && len(slug) > 0 { + return teamIdentity{slug: slug}, nil + } + } + + return teamIdentity{}, fmt.Errorf("input must include 'slug'") +} + +// getTeamIdentities returns a list of team identities represented by the input. +// Each input may include a team slug or a team ID. +// Each team identity in the output will include the slug. +func getTeamIdentities(ctx context.Context, meta *Owner, col []any) ([]teamIdentity, error) { + identities := make([]teamIdentity, len(col)) + + for i, t := range col { + id, err := getTeamIdentity(ctx, meta, t) + if err != nil { + return nil, err + } + identities[i] = id + } + + return identities, nil +} + +// getTeamCollaborators returns a list of team collaborators represented by the input. +// Each input may include a team slug or a team ID, along with a permission level. +// Each team collaborator in the output will include the slug. +func getTeamCollaborators(ctx context.Context, meta *Owner, col []any) (teamCollaborators, error) { + collaborators := make([]teamCollaborator, len(col)) + + for i, t := range col { + m, ok := t.(map[string]any) + if !ok { + return nil, fmt.Errorf("input invalid") + } + + id, err := getTeamIdentity(ctx, meta, m) + if err != nil { + return nil, err + } + + permission, ok := m["permission"].(string) + if !ok || len(permission) == 0 { + return nil, fmt.Errorf("team input must include 'permission'") + } + + collaborators[i] = teamCollaborator{ + teamIdentity: id, + permission: permission, + } + } + + return collaborators, nil +} + +// checkDuplicateTeams checks for duplicate team slugs in the given list of team identities. +func checkDuplicateTeams[T teamWithSlug](teams []T) bool { + seen := make(map[string]any) + + for _, team := range teams { + slug := team.getSlug() + if _, ok := seen[slug]; ok { + return true + } + seen[slug] = nil + } + + return false +} diff --git a/github/util_user.go b/github/util_user.go new file mode 100644 index 0000000000..c3a2e47b4e --- /dev/null +++ b/github/util_user.go @@ -0,0 +1,111 @@ +package github + +import "fmt" + +// userWithLogin is an interface representing a GitHub user that has a login. +type userWithLogin interface { + getLogin() string +} + +// userIdentity represents a GitHub user by their login. +type userIdentity struct { + login string +} + +// getLogin returns the login of the user. +func (u userIdentity) getLogin() string { + return u.login +} + +// userCollaborator represents a GitHub user collaborator with its identity and permission level. +type userCollaborator struct { + userIdentity + permission string + invitationID *int64 +} + +// flatten converts the userCollaborator into a format suitable for Terraform schema. +func (u userCollaborator) flatten() any { + m := map[string]any{ + "username": u.login, + "permission": u.permission, + } + + if u.invitationID != nil { + m["invitation_id"] = *u.invitationID + } + + return m +} + +// userCollaborators is a slice of userCollaborator. +type userCollaborators []userCollaborator + +// flatten converts the userCollaborators slice into a format suitable for Terraform schema. +func (uc userCollaborators) flatten() any { + items := make([]any, len(uc)) + + for i, u := range uc { + items[i] = u.flatten() + } + + return items +} + +// getUserCollaborators converts a slice of any type to a slice of userCollaborator. +func getUserCollaborators(col []any) (userCollaborators, error) { + collaborators := make([]userCollaborator, len(col)) + + for i, u := range col { + m, ok := u.(map[string]any) + if !ok { + return nil, fmt.Errorf("input invalid") + } + + n, ok := m["username"] + if !ok { + return nil, fmt.Errorf("username missing") + } + + username, ok := n.(string) + if !ok || len(username) == 0 { + return nil, fmt.Errorf("username invalid") + } + + p, ok := m["permission"] + if !ok { + return nil, fmt.Errorf("permission missing") + } + + permission, ok := p.(string) + if !ok || len(permission) == 0 { + return nil, fmt.Errorf("permission invalid") + } + + uc := userCollaborator{ + userIdentity: userIdentity{ + login: username, + }, + permission: permission, + } + + collaborators[i] = uc + } + + return collaborators, nil +} + +// checkDuplicateUsers checks for duplicate usernames in a slice of userWithLogin. +func checkDuplicateUsers[T userWithLogin](users []T) bool { + seen := make(map[string]any) + + for _, u := range users { + login := u.getLogin() + if _, ok := seen[login]; ok { + return true + } + seen[login] = struct{}{} + } + + return false +} diff --git a/go.mod b/go.mod index f67d5e9ce1..c67609c969 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/google/go-github/v81 v81.0.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-cty v1.5.0 + github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.38.1 github.com/shurcooL/githubv4 v0.0.0-20221126192849-0b5c4c7994eb github.com/stretchr/testify v1.11.1 @@ -39,7 +40,6 @@ require ( github.com/hashicorp/terraform-exec v0.23.1 // indirect github.com/hashicorp/terraform-json v0.27.1 // indirect github.com/hashicorp/terraform-plugin-go v0.29.0 // indirect - github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect github.com/hashicorp/terraform-registry-address v0.4.0 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.2 // indirect