diff --git a/github/data_source_github_enterprise_team.go b/github/data_source_github_enterprise_team.go new file mode 100644 index 0000000000..7b1c85f541 --- /dev/null +++ b/github/data_source_github_enterprise_team.go @@ -0,0 +1,140 @@ +package github + +import ( + "context" + "strconv" + "strings" + + "github.com/google/go-github/v81/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func dataSourceGithubEnterpriseTeam() *schema.Resource { + return &schema.Resource{ + Description: "Gets information about a GitHub enterprise team.", + ReadContext: dataSourceGithubEnterpriseTeamRead, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + ValidateDiagFunc: validation.ToDiagFunc(validation.All(validation.StringIsNotWhiteSpace, validation.StringIsNotEmpty)), + }, + "slug": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ExactlyOneOf: []string{"slug", "team_id"}, + Description: "The slug of the enterprise team.", + ValidateDiagFunc: validation.ToDiagFunc(validation.All(validation.StringIsNotWhiteSpace, validation.StringIsNotEmpty)), + }, + "team_id": { + Type: schema.TypeInt, + Optional: true, + Computed: true, + ExactlyOneOf: []string{"slug", "team_id"}, + Description: "The numeric ID of the enterprise team.", + ValidateDiagFunc: validation.ToDiagFunc(validation.IntAtLeast(1)), + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the enterprise team.", + }, + "description": { + Type: schema.TypeString, + Computed: true, + Description: "A description of the enterprise team.", + }, + "organization_selection_type": { + Type: schema.TypeString, + Computed: true, + Description: "Specifies which organizations in the enterprise should have access to this team.", + }, + "group_id": { + Type: schema.TypeString, + Computed: true, + Description: "The ID of the IdP group to assign team membership with.", + }, + }, + } +} + +func dataSourceGithubEnterpriseTeamRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := strings.TrimSpace(d.Get("enterprise_slug").(string)) + + var te *github.EnterpriseTeam + if v, ok := d.GetOk("team_id"); ok { + teamID := int64(v.(int)) + if teamID != 0 { + found, err := findEnterpriseTeamByID(ctx, client, enterpriseSlug, teamID) + if err != nil { + return diag.FromErr(err) + } + if found == nil { + return diag.Errorf("could not find enterprise team %d in enterprise %s", teamID, enterpriseSlug) + } + te = found + } + } + + if te == nil { + teamSlug := strings.TrimSpace(d.Get("slug").(string)) + if teamSlug == "" { + return diag.Errorf("one of slug or team_id must be set") + } + found, _, err := client.Enterprise.GetTeam(ctx, enterpriseSlug, teamSlug) + if err != nil { + return diag.FromErr(err) + } + te = found + } + + d.SetId(buildTwoPartID(enterpriseSlug, strconv.FormatInt(te.ID, 10))) + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + if err := d.Set("slug", te.Slug); err != nil { + return diag.FromErr(err) + } + if err := d.Set("team_id", int(te.ID)); err != nil { + return diag.FromErr(err) + } + if err := d.Set("name", te.Name); err != nil { + return diag.FromErr(err) + } + if te.Description != nil { + if err := d.Set("description", *te.Description); err != nil { + return diag.FromErr(err) + } + } else { + if err := d.Set("description", ""); err != nil { + return diag.FromErr(err) + } + } + orgSel := "" + if te.OrganizationSelectionType != nil { + orgSel = *te.OrganizationSelectionType + } + if orgSel == "" { + orgSel = "disabled" + } + if err := d.Set("organization_selection_type", orgSel); err != nil { + return diag.FromErr(err) + } + if te.GroupID != "" { + if err := d.Set("group_id", te.GroupID); err != nil { + return diag.FromErr(err) + } + } else { + if err := d.Set("group_id", ""); err != nil { + return diag.FromErr(err) + } + } + + return nil +} diff --git a/github/data_source_github_enterprise_team_membership.go b/github/data_source_github_enterprise_team_membership.go new file mode 100644 index 0000000000..64c1c11a64 --- /dev/null +++ b/github/data_source_github_enterprise_team_membership.go @@ -0,0 +1,74 @@ +package github + +import ( + "context" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func dataSourceGithubEnterpriseTeamMembership() *schema.Resource { + return &schema.Resource{ + Description: "Gets information about a user's membership in a GitHub enterprise team.", + ReadContext: dataSourceGithubEnterpriseTeamMembershipRead, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + ValidateDiagFunc: validation.ToDiagFunc(validation.All(validation.StringIsNotWhiteSpace, validation.StringIsNotEmpty)), + }, + "team_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise team.", + ValidateDiagFunc: validation.ToDiagFunc(validation.All(validation.StringIsNotWhiteSpace, validation.StringIsNotEmpty)), + }, + "username": { + Type: schema.TypeString, + Required: true, + Description: "The username of the user.", + ValidateDiagFunc: validation.ToDiagFunc(validation.All(validation.StringIsNotWhiteSpace, validation.StringIsNotEmpty)), + }, + "user_id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the user.", + }, + }, + } +} + +func dataSourceGithubEnterpriseTeamMembershipRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := strings.TrimSpace(d.Get("enterprise_slug").(string)) + teamSlug := strings.TrimSpace(d.Get("team_slug").(string)) + username := strings.TrimSpace(d.Get("username").(string)) + + // Get the membership using the SDK + user, _, err := client.Enterprise.GetTeamMembership(ctx, enterpriseSlug, teamSlug, username) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(buildEnterpriseTeamMembershipID(enterpriseSlug, teamSlug, username)) + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + if err := d.Set("team_slug", teamSlug); err != nil { + return diag.FromErr(err) + } + if err := d.Set("username", username); err != nil { + return diag.FromErr(err) + } + if user != nil && user.ID != nil { + if err := d.Set("user_id", int(*user.ID)); err != nil { + return diag.FromErr(err) + } + } + + return nil +} diff --git a/github/data_source_github_enterprise_team_organizations.go b/github/data_source_github_enterprise_team_organizations.go new file mode 100644 index 0000000000..dcd770ca91 --- /dev/null +++ b/github/data_source_github_enterprise_team_organizations.go @@ -0,0 +1,68 @@ +package github + +import ( + "context" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func dataSourceGithubEnterpriseTeamOrganizations() *schema.Resource { + return &schema.Resource{ + Description: "Lists organizations assigned to a GitHub enterprise team.", + ReadContext: dataSourceGithubEnterpriseTeamOrganizationsRead, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + ValidateDiagFunc: validation.ToDiagFunc(validation.All(validation.StringIsNotWhiteSpace, validation.StringIsNotEmpty)), + }, + "team_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise team.", + ValidateDiagFunc: validation.ToDiagFunc(validation.All(validation.StringIsNotWhiteSpace, validation.StringIsNotEmpty)), + }, + "organization_slugs": { + Type: schema.TypeSet, + Computed: true, + Description: "Set of organization slugs that the enterprise team is assigned to.", + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + }, + }, + } +} + +func dataSourceGithubEnterpriseTeamOrganizationsRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := strings.TrimSpace(d.Get("enterprise_slug").(string)) + teamSlug := strings.TrimSpace(d.Get("team_slug").(string)) + orgs, err := listAllEnterpriseTeamOrganizations(ctx, client, enterpriseSlug, teamSlug) + if err != nil { + return diag.FromErr(err) + } + + slugs := make([]string, 0, len(orgs)) + for _, org := range orgs { + if org.Login != nil && *org.Login != "" { + slugs = append(slugs, *org.Login) + } + } + + d.SetId(buildEnterpriseTeamOrganizationsID(enterpriseSlug, teamSlug)) + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + if err := d.Set("team_slug", teamSlug); err != nil { + return diag.FromErr(err) + } + if err := d.Set("organization_slugs", slugs); err != nil { + return diag.FromErr(err) + } + return nil +} diff --git a/github/data_source_github_enterprise_team_test.go b/github/data_source_github_enterprise_team_test.go new file mode 100644 index 0000000000..7b6faa6b33 --- /dev/null +++ b/github/data_source_github_enterprise_team_test.go @@ -0,0 +1,139 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccGithubEnterpriseTeamDataSource(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + config := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_team" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "tf-acc-ds-team-%s" + } + + data "github_enterprise_team" "by_slug" { + enterprise_slug = data.github_enterprise.enterprise.slug + slug = github_enterprise_team.test.slug + } + + data "github_enterprise_team" "by_id" { + enterprise_slug = data.github_enterprise.enterprise.slug + team_id = github_enterprise_team.test.team_id + } + `, testAccConf.enterpriseSlug, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.github_enterprise_team.by_slug", "id"), + resource.TestCheckResourceAttrPair("data.github_enterprise_team.by_slug", "team_id", "github_enterprise_team.test", "team_id"), + resource.TestCheckResourceAttrPair("data.github_enterprise_team.by_slug", "slug", "github_enterprise_team.test", "slug"), + resource.TestCheckResourceAttrPair("data.github_enterprise_team.by_slug", "name", "github_enterprise_team.test", "name"), + resource.TestCheckResourceAttrSet("data.github_enterprise_team.by_id", "id"), + resource.TestCheckResourceAttrPair("data.github_enterprise_team.by_id", "team_id", "github_enterprise_team.test", "team_id"), + resource.TestCheckResourceAttrPair("data.github_enterprise_team.by_id", "slug", "github_enterprise_team.test", "slug"), + ), + }, + }, + }) +} + +func TestAccGithubEnterpriseTeamOrganizationsDataSource(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + config := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_team" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + organization_selection_type = "selected" + } + + resource "github_enterprise_team_organizations" "assign" { + enterprise_slug = data.github_enterprise.enterprise.slug + team_slug = github_enterprise_team.test.slug + organization_slugs = ["%s"] + } + + data "github_enterprise_team_organizations" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + team_slug = github_enterprise_team.test.slug + depends_on = [github_enterprise_team_organizations.assign] + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID, testAccConf.owner) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.github_enterprise_team_organizations.test", "id"), + resource.TestCheckResourceAttr("data.github_enterprise_team_organizations.test", "organization_slugs.#", "1"), + resource.TestCheckTypeSetElemAttr("data.github_enterprise_team_organizations.test", "organization_slugs.*", testAccConf.owner), + ), + }, + }, + }) +} + +func TestAccGithubEnterpriseTeamMembershipDataSource(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + username := testAccConf.username + + config := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_team" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + } + + resource "github_enterprise_team_membership" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + team_slug = github_enterprise_team.test.slug + username = "%s" + } + + data "github_enterprise_team_membership" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + team_slug = github_enterprise_team.test.slug + username = "%s" + depends_on = [github_enterprise_team_membership.test] + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID, username, username) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.github_enterprise_team_membership.test", "id"), + resource.TestCheckResourceAttr("data.github_enterprise_team_membership.test", "username", username), + ), + }, + }, + }) +} diff --git a/github/data_source_github_enterprise_teams.go b/github/data_source_github_enterprise_teams.go new file mode 100644 index 0000000000..28599e3422 --- /dev/null +++ b/github/data_source_github_enterprise_teams.go @@ -0,0 +1,141 @@ +package github + +import ( + "context" + "strings" + + "github.com/google/go-github/v81/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +const ( + teamIDKey = "team_id" + teamSlugKey = "slug" + teamNameKey = "name" + teamDescriptionKey = "description" + teamOrganizationSelectionKey = "organization_selection_type" + teamGroupIDKey = "group_id" +) + +func dataSourceGithubEnterpriseTeams() *schema.Resource { + return &schema.Resource{ + Description: "Lists all GitHub enterprise teams in an enterprise.", + ReadContext: dataSourceGithubEnterpriseTeamsRead, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + ValidateDiagFunc: validation.ToDiagFunc(validation.All(validation.StringIsNotWhiteSpace, validation.StringIsNotEmpty)), + }, + "teams": { + Type: schema.TypeList, + Computed: true, + Description: "All teams in the enterprise.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + teamIDKey: { + Type: schema.TypeInt, + Computed: true, + Description: "The numeric ID of the enterprise team.", + }, + teamSlugKey: { + Type: schema.TypeString, + Computed: true, + Description: "The slug of the enterprise team.", + }, + teamNameKey: { + Type: schema.TypeString, + Computed: true, + Description: "The name of the enterprise team.", + }, + teamDescriptionKey: { + Type: schema.TypeString, + Computed: true, + Description: "A description of the enterprise team.", + }, + teamOrganizationSelectionKey: { + Type: schema.TypeString, + Computed: true, + Description: "Specifies which organizations in the enterprise should have access to this team.", + }, + teamGroupIDKey: { + Type: schema.TypeString, + Computed: true, + Description: "The ID of the IdP group to assign team membership with.", + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubEnterpriseTeamsRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := strings.TrimSpace(d.Get("enterprise_slug").(string)) + teams, err := listAllEnterpriseTeams(ctx, client, enterpriseSlug) + if err != nil { + return diag.FromErr(err) + } + + flat := make([]any, 0, len(teams)) + for _, t := range teams { + m := map[string]any{ + teamIDKey: int(t.ID), + teamSlugKey: t.Slug, + teamNameKey: t.Name, + } + if t.Description != nil { + m[teamDescriptionKey] = *t.Description + } else { + m[teamDescriptionKey] = "" + } + orgSel := "" + if t.OrganizationSelectionType != nil { + orgSel = *t.OrganizationSelectionType + } + if orgSel == "" { + orgSel = "disabled" + } + m[teamOrganizationSelectionKey] = orgSel + if t.GroupID != "" { + m[teamGroupIDKey] = t.GroupID + } else { + m[teamGroupIDKey] = "" + } + flat = append(flat, m) + } + + d.SetId(enterpriseSlug) + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + if err := d.Set("teams", flat); err != nil { + return diag.FromErr(err) + } + return nil +} + +// listAllEnterpriseTeams returns all enterprise teams with pagination handled. +func listAllEnterpriseTeams(ctx context.Context, client *github.Client, enterpriseSlug string) ([]*github.EnterpriseTeam, error) { + var all []*github.EnterpriseTeam + opt := &github.ListOptions{PerPage: maxPerPage} + + for { + teams, resp, err := client.Enterprise.ListTeams(ctx, enterpriseSlug, opt) + if err != nil { + return nil, err + } + all = append(all, teams...) + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + return all, nil +} diff --git a/github/data_source_github_enterprise_teams_test.go b/github/data_source_github_enterprise_teams_test.go new file mode 100644 index 0000000000..64d9940e77 --- /dev/null +++ b/github/data_source_github_enterprise_teams_test.go @@ -0,0 +1,45 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccGithubEnterpriseTeamsDataSource(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + config := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_team" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + } + + data "github_enterprise_teams" "all" { + enterprise_slug = data.github_enterprise.enterprise.slug + depends_on = [github_enterprise_team.test] + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.github_enterprise_teams.all", "id"), + resource.TestCheckResourceAttrSet("data.github_enterprise_teams.all", "teams.0.team_id"), + resource.TestCheckResourceAttrSet("data.github_enterprise_teams.all", "teams.0.slug"), + resource.TestCheckResourceAttrSet("data.github_enterprise_teams.all", "teams.0.name"), + ), + }, + }, + }) +} diff --git a/github/provider.go b/github/provider.go index 3a3b24863c..202b048f29 100644 --- a/github/provider.go +++ b/github/provider.go @@ -210,6 +210,9 @@ func Provider() *schema.Provider { "github_user_invitation_accepter": resourceGithubUserInvitationAccepter(), "github_user_ssh_key": resourceGithubUserSshKey(), "github_enterprise_organization": resourceGithubEnterpriseOrganization(), + "github_enterprise_team": resourceGithubEnterpriseTeam(), + "github_enterprise_team_membership": resourceGithubEnterpriseTeamMembership(), + "github_enterprise_team_organizations": resourceGithubEnterpriseTeamOrganizations(), "github_enterprise_actions_runner_group": resourceGithubActionsEnterpriseRunnerGroup(), "github_enterprise_actions_workflow_permissions": resourceGithubEnterpriseActionsWorkflowPermissions(), "github_enterprise_security_analysis_settings": resourceGithubEnterpriseSecurityAnalysisSettings(), @@ -288,6 +291,10 @@ func Provider() *schema.Provider { "github_user_external_identity": dataSourceGithubUserExternalIdentity(), "github_users": dataSourceGithubUsers(), "github_enterprise": dataSourceGithubEnterprise(), + "github_enterprise_team": dataSourceGithubEnterpriseTeam(), + "github_enterprise_teams": dataSourceGithubEnterpriseTeams(), + "github_enterprise_team_membership": dataSourceGithubEnterpriseTeamMembership(), + "github_enterprise_team_organizations": dataSourceGithubEnterpriseTeamOrganizations(), "github_repository_environment_deployment_policies": dataSourceGithubRepositoryEnvironmentDeploymentPolicies(), }, } diff --git a/github/resource_github_enterprise_team.go b/github/resource_github_enterprise_team.go new file mode 100644 index 0000000000..d80d1ad6b9 --- /dev/null +++ b/github/resource_github_enterprise_team.go @@ -0,0 +1,280 @@ +package github + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "strings" + + "github.com/google/go-github/v81/github" + "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" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceGithubEnterpriseTeam() *schema.Resource { + return &schema.Resource{ + Description: "Manages a GitHub enterprise team.", + CreateContext: resourceGithubEnterpriseTeamCreate, + ReadContext: resourceGithubEnterpriseTeamRead, + UpdateContext: resourceGithubEnterpriseTeamUpdate, + DeleteContext: resourceGithubEnterpriseTeamDelete, + Importer: &schema.ResourceImporter{StateContext: resourceGithubEnterpriseTeamImport}, + + CustomizeDiff: customdiff.Sequence( + customdiff.ComputedIf("slug", func(_ context.Context, d *schema.ResourceDiff, meta any) bool { + return d.HasChange("name") + }), + ), + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug of the enterprise (e.g. from the enterprise URL).", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringLenBetween(1, 255)), + }, + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the enterprise team.", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringLenBetween(1, 255)), + }, + "description": { + Type: schema.TypeString, + Optional: true, + Description: "A description of the enterprise team.", + }, + "organization_selection_type": { + Type: schema.TypeString, + Optional: true, + Default: "disabled", + Description: "Controls which organizations can see this team: `disabled`, `selected`, or `all`.", + ValidateDiagFunc: toDiagFunc( + validation.StringInSlice([]string{"disabled", "selected", "all"}, false), + "organization_selection_type", + ), + }, + "group_id": { + Type: schema.TypeString, + Optional: true, + Description: "The ID of the IdP group to assign team membership with.", + }, + "slug": { + Type: schema.TypeString, + Computed: true, + Description: "The slug of the enterprise team. GitHub generates the slug from the team name and adds the ent: prefix.", + }, + "team_id": { + Type: schema.TypeInt, + Computed: true, + Description: "The numeric ID of the enterprise team.", + }, + }, + } +} + +func resourceGithubEnterpriseTeamCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + + name := d.Get("name").(string) + description := d.Get("description").(string) + orgSelection := d.Get("organization_selection_type").(string) + groupID := d.Get("group_id").(string) + + req := github.EnterpriseTeamCreateOrUpdateRequest{ + Name: name, + Description: github.Ptr(description), + OrganizationSelectionType: github.Ptr(orgSelection), + GroupID: github.Ptr(groupID), // Empty string is valid for no group + } + + ctx = context.WithValue(ctx, ctxId, d.Id()) + te, _, err := client.Enterprise.CreateTeam(ctx, enterpriseSlug, req) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(strconv.FormatInt(te.ID, 10)) + + // Set computed fields directly from API response + if err := d.Set("slug", te.Slug); err != nil { + return diag.FromErr(err) + } + if err := d.Set("team_id", int(te.ID)); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseTeamRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + + teamID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return diag.FromErr(unconvertibleIdErr(d.Id(), err)) + } + + ctx = context.WithValue(ctx, ctxId, d.Id()) + + // Try to fetch by slug first (faster), but if the team was renamed we need + // to fall back to listing all teams and matching by numeric ID. + var te *github.EnterpriseTeam + if slug, ok := d.GetOk("slug"); ok { + if s := strings.TrimSpace(slug.(string)); s != "" { + candidate, _, getErr := client.Enterprise.GetTeam(ctx, enterpriseSlug, s) + if getErr == nil { + te = candidate + } else { + ghErr := &github.ErrorResponse{} + if errors.As(getErr, &ghErr) && ghErr.Response.StatusCode != http.StatusNotFound { + return diag.FromErr(getErr) + } + } + } + } + + if te == nil { + te, err = findEnterpriseTeamByID(ctx, client, enterpriseSlug, teamID) + if err != nil { + return diag.FromErr(err) + } + if te == nil { + log.Printf("[INFO] Removing enterprise team %s/%s from state because it no longer exists in GitHub", enterpriseSlug, d.Id()) + d.SetId("") + return nil + } + } + + if err = d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + if err = d.Set("name", te.Name); err != nil { + return diag.FromErr(err) + } + if te.Description != nil { + if err = d.Set("description", *te.Description); err != nil { + return diag.FromErr(err) + } + } else { + if err = d.Set("description", ""); err != nil { + return diag.FromErr(err) + } + } + if err = d.Set("slug", te.Slug); err != nil { + return diag.FromErr(err) + } + if err = d.Set("team_id", int(te.ID)); err != nil { + return diag.FromErr(err) + } + orgSelection := "" + if te.OrganizationSelectionType != nil { + orgSelection = *te.OrganizationSelectionType + } + if orgSelection == "" { + orgSelection = "disabled" + } + if err = d.Set("organization_selection_type", orgSelection); err != nil { + return diag.FromErr(err) + } + if te.GroupID != "" { + if err = d.Set("group_id", te.GroupID); err != nil { + return diag.FromErr(err) + } + } else { + if err = d.Set("group_id", ""); err != nil { + return diag.FromErr(err) + } + } + + return nil +} + +func resourceGithubEnterpriseTeamUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + teamSlug := d.Get("slug").(string) + + name := d.Get("name").(string) + description := d.Get("description").(string) + orgSelection := d.Get("organization_selection_type").(string) + groupID := d.Get("group_id").(string) + + req := github.EnterpriseTeamCreateOrUpdateRequest{ + Name: name, + Description: github.Ptr(description), + OrganizationSelectionType: github.Ptr(orgSelection), + GroupID: github.Ptr(groupID), // Empty string clears the group + } + + ctx = context.WithValue(ctx, ctxId, d.Id()) + te, _, err := client.Enterprise.UpdateTeam(ctx, enterpriseSlug, teamSlug, req) + if err != nil { + return diag.FromErr(err) + } + + // Update slug in case it changed (e.g., team was renamed) + if err := d.Set("slug", te.Slug); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseTeamDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + + ctx = context.WithValue(ctx, ctxId, d.Id()) + teamSlug := strings.TrimSpace(d.Get("slug").(string)) + if teamSlug == "" { + teamID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return diag.FromErr(unconvertibleIdErr(d.Id(), err)) + } + te, err := findEnterpriseTeamByID(ctx, client, enterpriseSlug, teamID) + if err != nil { + return diag.FromErr(err) + } + if te == nil { + return nil + } + teamSlug = te.Slug + } + + log.Printf("[INFO] Deleting enterprise team: %s/%s (%s)", enterpriseSlug, teamSlug, d.Id()) + _, err := client.Enterprise.DeleteTeam(ctx, enterpriseSlug, teamSlug) + if err != nil { + // Already gone? That's fine, we wanted it deleted anyway. + ghErr := &github.ErrorResponse{} + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { + return nil + } + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseTeamImport(_ context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + // Import format: / + parts := strings.Split(d.Id(), "/") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid import specified: supplied import must be written as /") + } + + enterpriseSlug, teamID := parts[0], parts[1] + d.SetId(teamID) + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return nil, err + } + return []*schema.ResourceData{d}, nil +} diff --git a/github/resource_github_enterprise_team_membership.go b/github/resource_github_enterprise_team_membership.go new file mode 100644 index 0000000000..2c4b94d5c0 --- /dev/null +++ b/github/resource_github_enterprise_team_membership.go @@ -0,0 +1,173 @@ +package github + +import ( + "context" + "strings" + + "github.com/google/go-github/v81/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceGithubEnterpriseTeamMembership() *schema.Resource { + return &schema.Resource{ + Description: "Manages membership of a user in a GitHub enterprise team.", + CreateContext: resourceGithubEnterpriseTeamMembershipCreate, + ReadContext: resourceGithubEnterpriseTeamMembershipRead, + DeleteContext: resourceGithubEnterpriseTeamMembershipDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug of the enterprise.", + ValidateDiagFunc: validation.ToDiagFunc(validation.All(validation.StringIsNotWhiteSpace, validation.StringIsNotEmpty)), + }, + "team_slug": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "The slug of the enterprise team.", + ExactlyOneOf: []string{"team_slug", "team_id"}, + ValidateDiagFunc: validation.ToDiagFunc(validation.All(validation.StringIsNotWhiteSpace, validation.StringIsNotEmpty)), + }, + "team_id": { + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + Description: "The ID of the enterprise team.", + ExactlyOneOf: []string{"team_slug", "team_id"}, + }, + "username": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The username of the user to add to the team.", + ValidateDiagFunc: validation.ToDiagFunc(validation.All(validation.StringIsNotWhiteSpace, validation.StringIsNotEmpty)), + }, + "user_id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the user.", + }, + }, + } +} + +func resourceGithubEnterpriseTeamMembershipCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := strings.TrimSpace(d.Get("enterprise_slug").(string)) + username := strings.TrimSpace(d.Get("username").(string)) + + // Get team by slug or ID + var team *github.EnterpriseTeam + var err error + if v, ok := d.GetOk("team_slug"); ok { + team, _, err = client.Enterprise.GetTeam(ctx, enterpriseSlug, v.(string)) + } else { + teamID := int64(d.Get("team_id").(int)) + team, err = findEnterpriseTeamByID(ctx, client, enterpriseSlug, teamID) + } + if err != nil { + return diag.FromErr(err) + } + if team == nil { + return diag.Errorf("enterprise team not found") + } + + // Add the user to the team using the SDK + user, _, err := client.Enterprise.AddTeamMember(ctx, enterpriseSlug, team.Slug, username) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(buildEnterpriseTeamMembershipID(enterpriseSlug, team.Slug, username)) + + // Only set team_slug or team_id based on what user provided + if _, ok := d.GetOk("team_slug"); ok { + if err := d.Set("team_slug", team.Slug); err != nil { + return diag.FromErr(err) + } + } else if _, ok := d.GetOk("team_id"); ok { + if err := d.Set("team_id", int(team.ID)); err != nil { + return diag.FromErr(err) + } + } + + if user != nil && user.ID != nil { + if err := d.Set("user_id", int(*user.ID)); err != nil { + return diag.FromErr(err) + } + } + + return nil +} + +func resourceGithubEnterpriseTeamMembershipRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug, teamSlug, username, err := parseEnterpriseTeamMembershipID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + // Get the membership using the SDK + user, resp, err := client.Enterprise.GetTeamMembership(ctx, enterpriseSlug, teamSlug, username) + if err != nil { + if resp != nil && resp.StatusCode == 404 { + d.SetId("") + return nil + } + return diag.FromErr(err) + } + + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + // Only set team_slug if it was configured, or if neither team_slug nor team_id + // is present (e.g., during import). This avoids drift when users configure team_id. + if _, ok := d.GetOk("team_slug"); ok { + if err := d.Set("team_slug", teamSlug); err != nil { + return diag.FromErr(err) + } + } else if _, ok := d.GetOk("team_id"); !ok { + // During import, neither is set, so we populate team_slug + if err := d.Set("team_slug", teamSlug); err != nil { + return diag.FromErr(err) + } + } + if err := d.Set("username", username); err != nil { + return diag.FromErr(err) + } + if user != nil && user.ID != nil { + if err := d.Set("user_id", int(*user.ID)); err != nil { + return diag.FromErr(err) + } + } + + return nil +} + +func resourceGithubEnterpriseTeamMembershipDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug, teamSlug, username, err := parseEnterpriseTeamMembershipID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + // Remove the user from the team using the SDK + resp, err := client.Enterprise.RemoveTeamMember(ctx, enterpriseSlug, teamSlug, username) + if err != nil { + // Already gone? That's fine, we wanted it deleted anyway. + if resp != nil && resp.StatusCode == 404 { + return nil + } + return diag.FromErr(err) + } + + return nil +} diff --git a/github/resource_github_enterprise_team_organizations.go b/github/resource_github_enterprise_team_organizations.go new file mode 100644 index 0000000000..c1a976ddd8 --- /dev/null +++ b/github/resource_github_enterprise_team_organizations.go @@ -0,0 +1,216 @@ +package github + +import ( + "context" + "strings" + + "github.com/google/go-github/v81/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceGithubEnterpriseTeamOrganizations() *schema.Resource { + return &schema.Resource{ + Description: "Manages organization assignments for a GitHub enterprise team.", + CreateContext: resourceGithubEnterpriseTeamOrganizationsCreate, + ReadContext: resourceGithubEnterpriseTeamOrganizationsRead, + UpdateContext: resourceGithubEnterpriseTeamOrganizationsUpdate, + DeleteContext: resourceGithubEnterpriseTeamOrganizationsDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug of the enterprise.", + ValidateDiagFunc: validation.ToDiagFunc(validation.All(validation.StringIsNotWhiteSpace, validation.StringIsNotEmpty)), + }, + "team_slug": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "The slug of the enterprise team.", + ExactlyOneOf: []string{"team_slug", "team_id"}, + ValidateDiagFunc: validation.ToDiagFunc(validation.All(validation.StringIsNotWhiteSpace, validation.StringIsNotEmpty)), + }, + "team_id": { + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + Description: "The ID of the enterprise team.", + ExactlyOneOf: []string{"team_slug", "team_id"}, + }, + "organization_slugs": { + Type: schema.TypeSet, + Required: true, + Description: "Set of organization slugs that the enterprise team should be assigned to.", + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + MinItems: 1, + }, + }, + } +} + +func resourceGithubEnterpriseTeamOrganizationsCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := strings.TrimSpace(d.Get("enterprise_slug").(string)) + + // Get team by slug or ID + var team *github.EnterpriseTeam + var err error + if v, ok := d.GetOk("team_slug"); ok { + team, _, err = client.Enterprise.GetTeam(ctx, enterpriseSlug, v.(string)) + } else { + teamID := int64(d.Get("team_id").(int)) + team, err = findEnterpriseTeamByID(ctx, client, enterpriseSlug, teamID) + } + if err != nil { + return diag.FromErr(err) + } + if team == nil { + return diag.Errorf("enterprise team not found") + } + + orgSlugsSet := d.Get("organization_slugs").(*schema.Set) + orgSlugs := make([]string, 0, orgSlugsSet.Len()) + for _, v := range orgSlugsSet.List() { + orgSlugs = append(orgSlugs, v.(string)) + } + + // Add organizations to the team using the SDK + _, _, err = client.Enterprise.AddMultipleAssignments(ctx, enterpriseSlug, team.Slug, orgSlugs) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(buildEnterpriseTeamOrganizationsID(enterpriseSlug, team.Slug)) + + // Only set team_slug or team_id based on what user provided + if _, ok := d.GetOk("team_slug"); ok { + if err := d.Set("team_slug", team.Slug); err != nil { + return diag.FromErr(err) + } + } else if _, ok := d.GetOk("team_id"); ok { + if err := d.Set("team_id", int(team.ID)); err != nil { + return diag.FromErr(err) + } + } + + return nil +} + +func resourceGithubEnterpriseTeamOrganizationsRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug, teamSlug, err := parseEnterpriseTeamOrganizationsID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + orgs, err := listAllEnterpriseTeamOrganizations(ctx, client, enterpriseSlug, teamSlug) + if err != nil { + return diag.FromErr(err) + } + + slugs := make([]string, 0, len(orgs)) + for _, org := range orgs { + if org.Login != nil && *org.Login != "" { + slugs = append(slugs, *org.Login) + } + } + + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + // Only set team_slug if it was configured, or if neither team_slug nor team_id + // is present (e.g., during import). This avoids drift when users configure team_id. + if _, ok := d.GetOk("team_slug"); ok { + if err := d.Set("team_slug", teamSlug); err != nil { + return diag.FromErr(err) + } + } else if _, ok := d.GetOk("team_id"); !ok { + // During import, neither is set, so we populate team_slug + if err := d.Set("team_slug", teamSlug); err != nil { + return diag.FromErr(err) + } + } + if err := d.Set("organization_slugs", slugs); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseTeamOrganizationsUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug, teamSlug, err := parseEnterpriseTeamOrganizationsID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + if d.HasChange("organization_slugs") { + oldVal, newVal := d.GetChange("organization_slugs") + oldSet := oldVal.(*schema.Set) + newSet := newVal.(*schema.Set) + + toAdd := newSet.Difference(oldSet) + toRemove := oldSet.Difference(newSet) + + // Add new organizations + if toAdd.Len() > 0 { + addSlugs := make([]string, 0, toAdd.Len()) + for _, v := range toAdd.List() { + addSlugs = append(addSlugs, v.(string)) + } + _, _, err = client.Enterprise.AddMultipleAssignments(ctx, enterpriseSlug, teamSlug, addSlugs) + if err != nil { + return diag.FromErr(err) + } + } + + // Remove old organizations + if toRemove.Len() > 0 { + removeSlugs := make([]string, 0, toRemove.Len()) + for _, v := range toRemove.List() { + removeSlugs = append(removeSlugs, v.(string)) + } + _, _, err = client.Enterprise.RemoveMultipleAssignments(ctx, enterpriseSlug, teamSlug, removeSlugs) + if err != nil { + return diag.FromErr(err) + } + } + } + + return nil +} + +func resourceGithubEnterpriseTeamOrganizationsDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug, teamSlug, err := parseEnterpriseTeamOrganizationsID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + // Get organizations from state + orgSlugsSet := d.Get("organization_slugs").(*schema.Set) + if orgSlugsSet.Len() > 0 { + removeSlugs := make([]string, 0, orgSlugsSet.Len()) + for _, v := range orgSlugsSet.List() { + removeSlugs = append(removeSlugs, v.(string)) + } + _, resp, err := client.Enterprise.RemoveMultipleAssignments(ctx, enterpriseSlug, teamSlug, removeSlugs) + if err != nil { + // Already gone? That's fine, we wanted it deleted anyway. + if resp != nil && resp.StatusCode == 404 { + return nil + } + return diag.FromErr(err) + } + } + + return nil +} diff --git a/github/resource_github_enterprise_team_test.go b/github/resource_github_enterprise_team_test.go new file mode 100644 index 0000000000..b8d44e2ee3 --- /dev/null +++ b/github/resource_github_enterprise_team_test.go @@ -0,0 +1,177 @@ +package github + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccGithubEnterpriseTeam(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + config1 := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_team" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "tf-acc-team-%s" + description = "team for acceptance testing" + organization_selection_type = "disabled" + } + `, testAccConf.enterpriseSlug, randomID) + + config2 := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_team" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "tf-acc-team-%s" + description = "updated description" + organization_selection_type = "selected" + } + `, testAccConf.enterpriseSlug, randomID) + + check1 := resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("github_enterprise_team.test", "slug"), + resource.TestCheckResourceAttrSet("github_enterprise_team.test", "team_id"), + resource.TestCheckResourceAttr("github_enterprise_team.test", "organization_selection_type", "disabled"), + ) + check2 := resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_team.test", "description", "updated description"), + resource.TestCheckResourceAttr("github_enterprise_team.test", "organization_selection_type", "selected"), + ) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + {Config: config1, Check: check1}, + {Config: config2, Check: check2}, + { + ResourceName: "github_enterprise_team.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdPrefix: fmt.Sprintf(`%s/`, testAccConf.enterpriseSlug), + }, + }, + }) +} + +func TestAccGithubEnterpriseTeamOrganizations(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + config1 := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_team" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "tf-acc-team-orgs-%s" + organization_selection_type = "selected" + } + + resource "github_enterprise_team_organizations" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + team_slug = github_enterprise_team.test.slug + organization_slugs = ["%s"] + } + `, testAccConf.enterpriseSlug, randomID, testAccConf.owner) + + check1 := resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_team_organizations.test", "organization_slugs.#", "1"), + resource.TestCheckTypeSetElemAttr("github_enterprise_team_organizations.test", "organization_slugs.*", testAccConf.owner), + ) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + {Config: config1, Check: check1}, + { + ResourceName: "github_enterprise_team_organizations.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccGithubEnterpriseTeamOrganizations_emptyOrganizations(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + config := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_team" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "tf-acc-team-empty-orgs-%s" + organization_selection_type = "selected" + } + + resource "github_enterprise_team_organizations" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + team_slug = github_enterprise_team.test.slug + organization_slugs = [] + } + `, testAccConf.enterpriseSlug, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`Attribute organization_slugs requires 1 item minimum`), + }, + }, + }) +} + +func TestAccGithubEnterpriseTeamMembership(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + username := testAccConf.username + + config := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_team" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "tf-acc-team-member-%s" + } + + resource "github_enterprise_team_membership" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + team_slug = github_enterprise_team.test.slug + username = "%s" + } + `, testAccConf.enterpriseSlug, randomID, username) + + check := resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_team_membership.test", "username", username), + ) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + {Config: config, Check: check}, + { + ResourceName: "github_enterprise_team_membership.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} diff --git a/github/util_enterprise_teams.go b/github/util_enterprise_teams.go new file mode 100644 index 0000000000..c9d066613d --- /dev/null +++ b/github/util_enterprise_teams.go @@ -0,0 +1,85 @@ +package github + +import ( + "context" + "fmt" + "strings" + + "github.com/google/go-github/v81/github" +) + +// buildEnterpriseTeamMembershipID creates an ID for enterprise team membership resources. +// Uses "/" as separator because team slugs contain ":" (e.g., "ent:team-name"). +// Note: GitHub slugs only allow alphanumeric characters, hyphens, and colons - never "/". +func buildEnterpriseTeamMembershipID(enterpriseSlug, teamSlug, username string) string { + return fmt.Sprintf("%s/%s/%s", enterpriseSlug, teamSlug, username) +} + +// parseEnterpriseTeamMembershipID parses the ID for enterprise team membership resources. +func parseEnterpriseTeamMembershipID(id string) (enterpriseSlug, teamSlug, username string, err error) { + parts := strings.SplitN(id, "/", 3) + if len(parts) != 3 { + return "", "", "", fmt.Errorf("unexpected ID format (%q); expected enterprise_slug/team_slug/username", id) + } + return parts[0], parts[1], parts[2], nil +} + +// buildEnterpriseTeamOrganizationsID creates an ID for enterprise team organizations resources. +// Uses "/" as separator because team slugs contain ":" (e.g., "ent:team-name"). +// Note: GitHub slugs only allow alphanumeric characters, hyphens, and colons - never "/". +func buildEnterpriseTeamOrganizationsID(enterpriseSlug, teamSlug string) string { + return fmt.Sprintf("%s/%s", enterpriseSlug, teamSlug) +} + +// parseEnterpriseTeamOrganizationsID parses the ID for enterprise team organizations resources. +func parseEnterpriseTeamOrganizationsID(id string) (enterpriseSlug, teamSlug string, err error) { + parts := strings.SplitN(id, "/", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("unexpected ID format (%q); expected enterprise_slug/team_slug", id) + } + return parts[0], parts[1], nil +} + +// findEnterpriseTeamByID lists all enterprise teams and returns the one matching the given ID. +// This is needed because the API doesn't provide a direct lookup by numeric ID. +func findEnterpriseTeamByID(ctx context.Context, client *github.Client, enterpriseSlug string, id int64) (*github.EnterpriseTeam, error) { + opt := &github.ListOptions{PerPage: maxPerPage} + + for { + teams, resp, err := client.Enterprise.ListTeams(ctx, enterpriseSlug, opt) + if err != nil { + return nil, err + } + for _, t := range teams { + if t.ID == id { + return t, nil + } + } + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + return nil, nil +} + +// listAllEnterpriseTeamOrganizations returns all organizations assigned to an enterprise team with pagination handled. +func listAllEnterpriseTeamOrganizations(ctx context.Context, client *github.Client, enterpriseSlug, enterpriseTeam string) ([]*github.Organization, error) { + var all []*github.Organization + opt := &github.ListOptions{PerPage: maxPerPage} + + for { + orgs, resp, err := client.Enterprise.ListAssignments(ctx, enterpriseSlug, enterpriseTeam, opt) + if err != nil { + return nil, err + } + all = append(all, orgs...) + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + return all, nil +} diff --git a/website/docs/d/enterprise_team.html.markdown b/website/docs/d/enterprise_team.html.markdown new file mode 100644 index 0000000000..9711f4ff3f --- /dev/null +++ b/website/docs/d/enterprise_team.html.markdown @@ -0,0 +1,49 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_team" +description: |- + Get information about a GitHub enterprise team. +--- + +# github_enterprise_team + +Use this data source to retrieve information about an enterprise team. + +~> **Note:** Requires GitHub Enterprise Cloud with a classic PAT that has enterprise admin scope. + +## Example Usage + +Lookup by slug: + +```hcl +data "github_enterprise_team" "example" { + enterprise_slug = "my-enterprise" + slug = "ent:platform" +} +``` + +Lookup by numeric ID: + +```hcl +data "github_enterprise_team" "example" { + enterprise_slug = "my-enterprise" + team_id = 123456 +} +``` + +## Argument Reference + +The following arguments are supported: + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `slug` - (Optional) The slug of the enterprise team. Conflicts with `team_id`. +* `team_id` - (Optional) The numeric ID of the enterprise team. Conflicts with `slug`. + +## Attributes Reference + +The following additional attributes are exported: + +* `name` - The name of the enterprise team. +* `description` - The description of the enterprise team. +* `organization_selection_type` - Which organizations in the enterprise should have access to this team. +* `group_id` - The ID of the IdP group to assign team membership with. diff --git a/website/docs/d/enterprise_team_membership.html.markdown b/website/docs/d/enterprise_team_membership.html.markdown new file mode 100644 index 0000000000..a35e2c845f --- /dev/null +++ b/website/docs/d/enterprise_team_membership.html.markdown @@ -0,0 +1,38 @@ +--- +layout: "github" +page_title: "Github: github_enterprise_team_membership" +description: |- + Check if a user is a member of a GitHub enterprise team. +--- + +# github_enterprise_team_membership + +Use this data source to check whether a user belongs to an enterprise team. + +~> **Note:** Requires GitHub Enterprise Cloud with a classic PAT that has enterprise admin scope. + +## Example Usage + +```hcl +data "github_enterprise_team_membership" "example" { + enterprise_slug = "my-enterprise" + team_slug = "ent:platform" + username = "octocat" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `team_slug` - (Required) The slug of the enterprise team. +* `username` - (Required) The GitHub username. + +## Attributes Reference + +The following additional attributes are exported: + +* `role` - The membership role, if returned by the API. +* `state` - The membership state, if returned by the API. +* `etag` - The response ETag. diff --git a/website/docs/d/enterprise_team_organizations.html.markdown b/website/docs/d/enterprise_team_organizations.html.markdown new file mode 100644 index 0000000000..a6c9819139 --- /dev/null +++ b/website/docs/d/enterprise_team_organizations.html.markdown @@ -0,0 +1,38 @@ +--- +layout: "github" +page_title: "Github: github_enterprise_team_organizations" +description: |- + Get organizations assigned to a GitHub enterprise team. +--- + +# github_enterprise_team_organizations + +Use this data source to retrieve the organizations that an enterprise team has access to. + +~> **Note:** Requires GitHub Enterprise Cloud with a classic PAT that has enterprise admin scope. + +## Example Usage + +```hcl +data "github_enterprise_team_organizations" "example" { + enterprise_slug = "my-enterprise" + team_slug = "ent:platform" +} + +output "assigned_orgs" { + value = data.github_enterprise_team_organizations.example.organization_slugs +} +``` + +## Argument Reference + +The following arguments are supported: + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `team_slug` - (Required) The slug of the enterprise team. + +## Attributes Reference + +The following additional attributes are exported: + +* `organization_slugs` - Set of organization slugs the enterprise team is assigned to. diff --git a/website/docs/d/enterprise_teams.html.markdown b/website/docs/d/enterprise_teams.html.markdown new file mode 100644 index 0000000000..acbf23af07 --- /dev/null +++ b/website/docs/d/enterprise_teams.html.markdown @@ -0,0 +1,45 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_teams" +description: |- + List all enterprise teams in a GitHub enterprise. +--- + +# github_enterprise_teams + +Use this data source to retrieve all enterprise teams for an enterprise. + +~> **Note:** Requires GitHub Enterprise Cloud with a classic PAT that has enterprise admin scope. + +## Example Usage + +```hcl +data "github_enterprise_teams" "all" { + enterprise_slug = "my-enterprise" +} + +output "enterprise_team_slugs" { + value = [for t in data.github_enterprise_teams.all.teams : t.slug] +} +``` + +## Argument Reference + +The following arguments are supported: + +* `enterprise_slug` - (Required) The slug of the enterprise. + +## Attributes Reference + +The following additional attributes are exported: + +* `teams` - List of enterprise teams in the enterprise. + +Each `teams` element exports: + +* `team_id` - The numeric ID of the enterprise team. +* `slug` - The slug of the enterprise team. +* `name` - The name of the enterprise team. +* `description` - The description of the enterprise team. +* `organization_selection_type` - Which organizations in the enterprise should have access to this team. +* `group_id` - The ID of the IdP group to assign team membership with. diff --git a/website/docs/r/enterprise_team.html.markdown b/website/docs/r/enterprise_team.html.markdown new file mode 100644 index 0000000000..768add8f5b --- /dev/null +++ b/website/docs/r/enterprise_team.html.markdown @@ -0,0 +1,53 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_team" +description: |- + Creates and manages a GitHub enterprise team. +--- + +# github_enterprise_team + +This resource allows you to create and manage a GitHub enterprise team. + +~> **Note:** These API endpoints are in public preview for GitHub Enterprise Cloud and require a classic personal access token with enterprise admin permissions. + +## Example Usage + +```hcl +data "github_enterprise" "enterprise" { + slug = "my-enterprise" +} + +resource "github_enterprise_team" "example" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "Platform" + description = "Platform Engineering" + organization_selection_type = "selected" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `name` - (Required) The name of the enterprise team. +* `description` - (Optional) A description of the enterprise team. +* `organization_selection_type` - (Optional) Which organizations in the enterprise should have access to this team. One of `disabled`, `selected`, or `all`. Defaults to `disabled`. +* `group_id` - (Optional) The ID of the IdP group to assign team membership with. + +## Attributes Reference + +The following additional attributes are exported: + +* `id` - The numeric ID of the enterprise team. +* `team_id` - The numeric ID of the enterprise team. +* `slug` - The slug of the enterprise team (GitHub generates it and adds the `ent:` prefix). + +## Import + +This resource can be imported using the enterprise slug and the enterprise team numeric ID: + +``` +$ terraform import github_enterprise_team.example enterprise-slug/42 +``` diff --git a/website/docs/r/enterprise_team_membership.html.markdown b/website/docs/r/enterprise_team_membership.html.markdown new file mode 100644 index 0000000000..98eb320e3d --- /dev/null +++ b/website/docs/r/enterprise_team_membership.html.markdown @@ -0,0 +1,48 @@ +--- +layout: "github" +page_title: "Github: github_enterprise_team_membership" +description: |- + Manages membership in a GitHub enterprise team. +--- + +# github_enterprise_team_membership + +This resource manages a user's membership in an enterprise team. + +~> **Note:** Requires GitHub Enterprise Cloud with a classic PAT that has enterprise admin scope. + +## Example Usage + +```hcl +data "github_enterprise" "enterprise" { + slug = "my-enterprise" +} + +resource "github_enterprise_team" "team" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "Platform" +} + +resource "github_enterprise_team_membership" "member" { + enterprise_slug = data.github_enterprise.enterprise.slug + team_slug = github_enterprise_team.team.slug + username = "octocat" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `team_slug` - (Optional) The slug of the enterprise team. Exactly one of `team_slug` or `team_id` must be specified. +* `team_id` - (Optional) The ID of the enterprise team. Exactly one of `team_slug` or `team_id` must be specified. +* `username` - (Required) The GitHub username to manage. + +## Import + +This resource can be imported using: + +``` +$ terraform import github_enterprise_team_membership.member enterprise-slug/ent:platform/octocat +``` diff --git a/website/docs/r/enterprise_team_organizations.html.markdown b/website/docs/r/enterprise_team_organizations.html.markdown new file mode 100644 index 0000000000..6050c92799 --- /dev/null +++ b/website/docs/r/enterprise_team_organizations.html.markdown @@ -0,0 +1,54 @@ +--- +layout: "github" +page_title: "Github: github_enterprise_team_organizations" +description: |- + Manages organization assignments for a GitHub enterprise team. +--- + +# github_enterprise_team_organizations + +This resource manages which organizations an enterprise team is assigned to. It will reconcile +the current assignments with the desired `organization_slugs`, adding and removing as needed. + +~> **Note:** Requires GitHub Enterprise Cloud with a classic PAT that has enterprise admin scope. + +## Example Usage + +```hcl +data "github_enterprise" "enterprise" { + slug = "my-enterprise" +} + +resource "github_enterprise_team" "team" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "Platform" + organization_selection_type = "selected" +} + +resource "github_enterprise_team_organizations" "assignments" { + enterprise_slug = data.github_enterprise.enterprise.slug + team_slug = github_enterprise_team.team.slug + + organization_slugs = [ + "my-org", + "another-org", + ] +} +``` + +## Argument Reference + +The following arguments are supported: + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `team_slug` - (Optional) The slug of the enterprise team. Exactly one of `team_slug` or `team_id` must be specified. +* `team_id` - (Optional) The ID of the enterprise team. Exactly one of `team_slug` or `team_id` must be specified. +* `organization_slugs` - (Required) Set of organization slugs to assign the team to (minimum 1). + +## Import + +This resource can be imported using: + +``` +$ terraform import github_enterprise_team_organizations.assignments enterprise-slug/ent:platform +```