diff --git a/github/data_source_github_enterprise_team.go b/github/data_source_github_enterprise_team.go new file mode 100644 index 000000000..d0646962e --- /dev/null +++ b/github/data_source_github_enterprise_team.go @@ -0,0 +1,135 @@ +package github + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubEnterpriseTeam() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceGithubEnterpriseTeamRead, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + }, + "slug": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ConflictsWith: []string{"team_id"}, + Description: "The slug of the enterprise team.", + }, + "team_id": { + Type: schema.TypeInt, + Optional: true, + Computed: true, + ConflictsWith: []string{"slug"}, + Description: "The numeric ID of the enterprise team.", + }, + "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)) + if enterpriseSlug == "" { + return diag.FromErr(fmt.Errorf("enterprise_slug must not be empty")) + } + + var te *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.FromErr(fmt.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.FromErr(fmt.Errorf("one of slug or team_id must be set")) + } + found, _, err := getEnterpriseTeamBySlug(ctx, client, enterpriseSlug, teamSlug) + if err != nil { + return diag.FromErr(err) + } + te = found + } + + d.SetId(buildSlashTwoPartID(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 := te.OrganizationSelectionType + if orgSel == "" { + orgSel = "disabled" + } + if err := d.Set("organization_selection_type", orgSel); err != nil { + return diag.FromErr(err) + } + if te.GroupID != nil { + 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 000000000..9b55a12e5 --- /dev/null +++ b/github/data_source_github_enterprise_team_membership.go @@ -0,0 +1,94 @@ +package github + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubEnterpriseTeamMembership() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceGithubEnterpriseTeamMembershipRead, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + }, + "enterprise_team": { + Type: schema.TypeString, + Required: true, + Description: "The slug or ID of the enterprise team.", + }, + "username": { + Type: schema.TypeString, + Required: true, + Description: "The GitHub username.", + }, + "role": { + Type: schema.TypeString, + Computed: true, + Description: "The role of the user in the enterprise team, if returned by the API.", + }, + "state": { + Type: schema.TypeString, + Computed: true, + Description: "The membership state, if returned by the API.", + }, + "etag": { + Type: schema.TypeString, + Computed: true, + Description: "ETag of the membership response.", + }, + }, + } +} + +func dataSourceGithubEnterpriseTeamMembershipRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := strings.TrimSpace(d.Get("enterprise_slug").(string)) + enterpriseTeam := strings.TrimSpace(d.Get("enterprise_team").(string)) + username := strings.TrimSpace(d.Get("username").(string)) + if enterpriseSlug == "" { + return diag.FromErr(fmt.Errorf("enterprise_slug must not be empty")) + } + if enterpriseTeam == "" { + return diag.FromErr(fmt.Errorf("enterprise_team must not be empty")) + } + if username == "" { + return diag.FromErr(fmt.Errorf("username must not be empty")) + } + m, resp, err := getEnterpriseTeamMembershipDetails(ctx, client, enterpriseSlug, enterpriseTeam, username) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(buildSlashThreePartID(enterpriseSlug, enterpriseTeam, username)) + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + if err := d.Set("enterprise_team", enterpriseTeam); err != nil { + return diag.FromErr(err) + } + if err := d.Set("username", username); err != nil { + return diag.FromErr(err) + } + if m != nil { + if err := d.Set("role", m.Role); err != nil { + return diag.FromErr(err) + } + if err := d.Set("state", m.State); err != nil { + return diag.FromErr(err) + } + } + if resp != nil { + if err := d.Set("etag", resp.Header.Get("ETag")); 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 000000000..6dba903cd --- /dev/null +++ b/github/data_source_github_enterprise_team_organizations.go @@ -0,0 +1,71 @@ +package github + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubEnterpriseTeamOrganizations() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceGithubEnterpriseTeamOrganizationsRead, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + }, + "enterprise_team": { + Type: schema.TypeString, + Required: true, + Description: "The slug or ID of the enterprise team.", + }, + "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)) + enterpriseTeam := strings.TrimSpace(d.Get("enterprise_team").(string)) + if enterpriseSlug == "" { + return diag.FromErr(fmt.Errorf("enterprise_slug must not be empty")) + } + if enterpriseTeam == "" { + return diag.FromErr(fmt.Errorf("enterprise_team must not be empty")) + } + orgs, err := listEnterpriseTeamOrganizations(ctx, client, enterpriseSlug, enterpriseTeam) + if err != nil { + return diag.FromErr(err) + } + + slugs := make([]string, 0, len(orgs)) + for _, org := range orgs { + if org.Login != "" { + slugs = append(slugs, org.Login) + } + } + + d.SetId(buildSlashTwoPartID(enterpriseSlug, enterpriseTeam)) + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + if err := d.Set("enterprise_team", enterpriseTeam); 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 000000000..6cd5cac2d --- /dev/null +++ b/github/data_source_github_enterprise_team_test.go @@ -0,0 +1,167 @@ +package github + +import ( + "fmt" + "os" + "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) + + if isEnterprise != "true" { + t.Skip("Skipping because `ENTERPRISE_ACCOUNT` is not set or set to false") + } + if testEnterprise == "" { + t.Skip("Skipping because `ENTERPRISE_SLUG` is not set") + } + + 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 + } + `, testEnterprise, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + Providers: testAccProviders, + 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) + + if isEnterprise != "true" { + t.Skip("Skipping because `ENTERPRISE_ACCOUNT` is not set or set to false") + } + if testEnterprise == "" { + t.Skip("Skipping because `ENTERPRISE_SLUG` is not set") + } + if testOrganization == "" { + t.Skip("Skipping because `GITHUB_OWNER`/`GITHUB_ORGANIZATION` is not set") + } + + 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-orgs-%s" + organization_selection_type = "selected" + } + + resource "github_enterprise_team_organizations" "assign" { + enterprise_slug = data.github_enterprise.enterprise.slug + enterprise_team = github_enterprise_team.test.slug + organization_slugs = ["%s"] + } + + data "github_enterprise_team_organizations" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + enterprise_team = github_enterprise_team.test.slug + depends_on = [github_enterprise_team_organizations.assign] + } + `, testEnterprise, randomID, testOrganization) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + Providers: testAccProviders, + 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.*", testOrganization), + ), + }, + }, + }) +} + +func TestAccGithubEnterpriseTeamMembershipDataSource(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + username := os.Getenv("GITHUB_TEST_USER") + + if isEnterprise != "true" { + t.Skip("Skipping because `ENTERPRISE_ACCOUNT` is not set or set to false") + } + if testEnterprise == "" { + t.Skip("Skipping because `ENTERPRISE_SLUG` is not set") + } + if username == "" { + t.Skip("Skipping because `GITHUB_TEST_USER` is not set") + } + + 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-member-%s" + } + + resource "github_enterprise_team_membership" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + enterprise_team = github_enterprise_team.test.slug + username = "%s" + } + + data "github_enterprise_team_membership" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + enterprise_team = github_enterprise_team.test.slug + username = "%s" + depends_on = [github_enterprise_team_membership.test] + } + `, testEnterprise, randomID, username, username) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + Providers: testAccProviders, + 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 000000000..a6eb498f1 --- /dev/null +++ b/github/data_source_github_enterprise_teams.go @@ -0,0 +1,109 @@ +package github + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubEnterpriseTeams() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceGithubEnterpriseTeamsRead, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + }, + "teams": { + Type: schema.TypeList, + Computed: true, + Description: "All teams in the enterprise.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "team_id": { + Type: schema.TypeInt, + Computed: true, + Description: "The numeric ID of the enterprise team.", + }, + "slug": { + Type: schema.TypeString, + Computed: true, + Description: "The slug of the enterprise team.", + }, + "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 dataSourceGithubEnterpriseTeamsRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := strings.TrimSpace(d.Get("enterprise_slug").(string)) + if enterpriseSlug == "" { + return diag.FromErr(fmt.Errorf("enterprise_slug must not be empty")) + } + teams, err := listEnterpriseTeams(ctx, client, enterpriseSlug) + if err != nil { + return diag.FromErr(err) + } + + flat := make([]any, 0, len(teams)) + for _, t := range teams { + m := map[string]any{ + "team_id": int(t.ID), + "slug": t.Slug, + "name": t.Name, + } + if t.Description != nil { + m["description"] = *t.Description + } else { + m["description"] = "" + } + orgSel := t.OrganizationSelectionType + if orgSel == "" { + orgSel = "disabled" + } + m["organization_selection_type"] = orgSel + if t.GroupID != nil { + m["group_id"] = *t.GroupID + } else { + m["group_id"] = "" + } + 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 +} 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 000000000..6975fbf51 --- /dev/null +++ b/github/data_source_github_enterprise_teams_test.go @@ -0,0 +1,52 @@ +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) + + if isEnterprise != "true" { + t.Skip("Skipping because `ENTERPRISE_ACCOUNT` is not set or set to false") + } + if testEnterprise == "" { + t.Skip("Skipping because `ENTERPRISE_SLUG` is not set") + } + + 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-enterprise-teams-%s" + } + + data "github_enterprise_teams" "all" { + enterprise_slug = data.github_enterprise.enterprise.slug + depends_on = [github_enterprise_team.test] + } + `, testEnterprise, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + Providers: testAccProviders, + 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 760968096..ddd0f2bef 100644 --- a/github/provider.go +++ b/github/provider.go @@ -208,6 +208,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_enterprise_cost_center": dataSourceGithubEnterpriseCostCenter(), "github_enterprise_cost_centers": dataSourceGithubEnterpriseCostCenters(), "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 000000000..751b58b33 --- /dev/null +++ b/github/resource_github_enterprise_team.go @@ -0,0 +1,280 @@ +package github + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "strings" + + githubv3 "github.com/google/go-github/v67/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{ + 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).", + }, + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the enterprise team.", + }, + "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 := enterpriseTeamCreateRequest{ + Name: name, + Description: githubv3.String(description), + OrganizationSelectionType: githubv3.String(orgSelection), + } + if groupID != "" { + req.GroupID = githubv3.String(groupID) + } + + ctx = context.WithValue(ctx, ctxId, d.Id()) + te, _, err := createEnterpriseTeam(ctx, client, enterpriseSlug, req) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(strconv.FormatInt(te.ID, 10)) + return resourceGithubEnterpriseTeamRead(context.WithValue(ctx, ctxId, d.Id()), d, meta) +} + +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 *enterpriseTeam + if slug, ok := d.GetOk("slug"); ok { + if s := strings.TrimSpace(slug.(string)); s != "" { + candidate, _, getErr := getEnterpriseTeamBySlug(ctx, client, enterpriseSlug, s) + if getErr == nil { + te = candidate + } else { + ghErr := &githubv3.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 := te.OrganizationSelectionType + if orgSelection == "" { + orgSelection = "disabled" + } + if err = d.Set("organization_selection_type", orgSelection); err != nil { + return diag.FromErr(err) + } + if te.GroupID != nil { + 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) + + // We need a team slug for the API. If state is missing, re-discover it by 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)) + } + ctx = context.WithValue(ctx, ctxId, d.Id()) + te, err := findEnterpriseTeamByID(ctx, client, enterpriseSlug, teamID) + if err != nil { + return diag.FromErr(err) + } + if te == nil { + return diag.FromErr(fmt.Errorf("enterprise team %s no longer exists", d.Id())) + } + teamSlug = te.Slug + } + + name := d.Get("name").(string) + description := d.Get("description").(string) + orgSelection := d.Get("organization_selection_type").(string) + groupID := d.Get("group_id").(string) + + req := enterpriseTeamUpdateRequest{ + Name: githubv3.String(name), + Description: githubv3.String(description), + OrganizationSelectionType: githubv3.String(orgSelection), + } + if groupID != "" { + req.GroupID = githubv3.String(groupID) + } + + ctx = context.WithValue(ctx, ctxId, d.Id()) + _, _, err := updateEnterpriseTeam(ctx, client, enterpriseSlug, teamSlug, req) + if err != nil { + return diag.FromErr(err) + } + + return resourceGithubEnterpriseTeamRead(ctx, d, meta) +} + +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()) + resp, err := deleteEnterpriseTeam(ctx, client, enterpriseSlug, teamSlug) + if err != nil { + // Already gone? That's fine, we wanted it deleted anyway. + ghErr := &githubv3.ErrorResponse{} + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { + return nil + } + _ = resp + 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) + _ = d.Set("enterprise_slug", enterpriseSlug) + 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 000000000..46e75d447 --- /dev/null +++ b/github/resource_github_enterprise_team_membership.go @@ -0,0 +1,130 @@ +package github + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + + githubv3 "github.com/google/go-github/v67/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubEnterpriseTeamMembership() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceGithubEnterpriseTeamMembershipCreate, + ReadContext: resourceGithubEnterpriseTeamMembershipRead, + DeleteContext: resourceGithubEnterpriseTeamMembershipDelete, + Importer: &schema.ResourceImporter{StateContext: resourceGithubEnterpriseTeamMembershipImport}, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug of the enterprise.", + }, + "enterprise_team": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug or ID of the enterprise team.", + }, + "username": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + DiffSuppressFunc: caseInsensitive(), + Description: "The login handle of the user.", + }, + }, + } +} + +func resourceGithubEnterpriseTeamMembershipCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + enterpriseTeam := d.Get("enterprise_team").(string) + username := d.Get("username").(string) + + ctx = context.WithValue(ctx, ctxId, d.Id()) + + // The API is idempotent, so we don't need to check if they're already a member + _, err := addEnterpriseTeamMember(ctx, client, enterpriseSlug, enterpriseTeam, username) + if err != nil { + return diag.FromErr(err) + } + + // NOTE: enterprise team slugs have the "ent:" prefix, so we must not use + // colon-delimited IDs here. + d.SetId(buildSlashThreePartID(enterpriseSlug, enterpriseTeam, username)) + return resourceGithubEnterpriseTeamMembershipRead(context.WithValue(ctx, ctxId, d.Id()), d, meta) +} + +func resourceGithubEnterpriseTeamMembershipRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug, enterpriseTeam, username, err := parseSlashThreePartID(d.Id(), "enterprise_slug", "enterprise_team", "username") + if err != nil { + return diag.FromErr(err) + } + + if err = d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + if err = d.Set("enterprise_team", enterpriseTeam); err != nil { + return diag.FromErr(err) + } + if err = d.Set("username", username); err != nil { + return diag.FromErr(err) + } + + ctx = context.WithValue(ctx, ctxId, d.Id()) + _, err = getEnterpriseTeamMembership(ctx, client, enterpriseSlug, enterpriseTeam, username) + if err != nil { + ghErr := &githubv3.ErrorResponse{} + if errors.As(err, &ghErr) { + if ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[INFO] Removing enterprise team membership %s from state because it no longer exists in GitHub", d.Id()) + d.SetId("") + return nil + } + } + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseTeamMembershipDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + enterpriseTeam := d.Get("enterprise_team").(string) + username := d.Get("username").(string) + + ctx = context.WithValue(ctx, ctxId, d.Id()) + resp, err := removeEnterpriseTeamMember(ctx, client, enterpriseSlug, enterpriseTeam, username) + if err != nil { + ghErr := &githubv3.ErrorResponse{} + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { + return nil + } + _ = resp + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseTeamMembershipImport(_ context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + enterpriseSlug, enterpriseTeam, username, err := parseSlashThreePartID(d.Id(), "enterprise_slug", "enterprise_team", "username") + if err != nil { + return nil, fmt.Errorf("invalid import specified: supplied import must be written as //") + } + d.SetId(buildSlashThreePartID(enterpriseSlug, enterpriseTeam, username)) + _ = d.Set("enterprise_slug", enterpriseSlug) + _ = d.Set("enterprise_team", enterpriseTeam) + _ = d.Set("username", username) + return []*schema.ResourceData{d}, nil +} diff --git a/github/resource_github_enterprise_team_organizations.go b/github/resource_github_enterprise_team_organizations.go new file mode 100644 index 000000000..9e4881e59 --- /dev/null +++ b/github/resource_github_enterprise_team_organizations.go @@ -0,0 +1,184 @@ +package github + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "strings" + + githubv3 "github.com/google/go-github/v67/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubEnterpriseTeamOrganizations() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceGithubEnterpriseTeamOrganizationsCreateOrUpdate, + ReadContext: resourceGithubEnterpriseTeamOrganizationsRead, + UpdateContext: resourceGithubEnterpriseTeamOrganizationsCreateOrUpdate, + DeleteContext: resourceGithubEnterpriseTeamOrganizationsDelete, + Importer: &schema.ResourceImporter{StateContext: resourceGithubEnterpriseTeamOrganizationsImport}, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug of the enterprise.", + }, + "enterprise_team": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug or ID of the enterprise team.", + }, + "organization_slugs": { + Type: schema.TypeSet, + Optional: true, + Description: "Set of organization slugs that the enterprise team should be assigned to.", + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + }, + }, + } +} + +func resourceGithubEnterpriseTeamOrganizationsCreateOrUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + enterpriseTeam := d.Get("enterprise_team").(string) + + desiredSet := map[string]struct{}{} + if v, ok := d.GetOk("organization_slugs"); ok { + for _, s := range v.(*schema.Set).List() { + slug := strings.TrimSpace(s.(string)) + if slug != "" { + desiredSet[slug] = struct{}{} + } + } + } + + ctx = context.WithValue(ctx, ctxId, d.Id()) + current, err := listEnterpriseTeamOrganizations(ctx, client, enterpriseSlug, enterpriseTeam) + if err != nil { + return diag.FromErr(err) + } + + currentSet := map[string]struct{}{} + for _, org := range current { + if org.Login != "" { + currentSet[org.Login] = struct{}{} + } + } + + toAdd := []string{} + for slug := range desiredSet { + if _, ok := currentSet[slug]; !ok { + toAdd = append(toAdd, slug) + } + } + + toRemove := []string{} + for slug := range currentSet { + if _, ok := desiredSet[slug]; !ok { + toRemove = append(toRemove, slug) + } + } + + // Perform adds before removes to avoid transient states where the team has no orgs + if err := addEnterpriseTeamOrganizations(ctx, client, enterpriseSlug, enterpriseTeam, toAdd); err != nil { + return diag.FromErr(err) + } + if _, err := removeEnterpriseTeamOrganizations(ctx, client, enterpriseSlug, enterpriseTeam, toRemove); err != nil { + return diag.FromErr(err) + } + + // NOTE: enterprise team slugs have the "ent:" prefix, so we must not use + // colon-delimited IDs here. + d.SetId(buildSlashTwoPartID(enterpriseSlug, enterpriseTeam)) + return resourceGithubEnterpriseTeamOrganizationsRead(context.WithValue(ctx, ctxId, d.Id()), d, meta) +} + +func resourceGithubEnterpriseTeamOrganizationsRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug, enterpriseTeam, err := parseSlashTwoPartID(d.Id(), "enterprise_slug", "enterprise_team") + if err != nil { + return diag.FromErr(err) + } + + if err = d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + if err = d.Set("enterprise_team", enterpriseTeam); err != nil { + return diag.FromErr(err) + } + + ctx = context.WithValue(ctx, ctxId, d.Id()) + orgs, err := listEnterpriseTeamOrganizations(ctx, client, enterpriseSlug, enterpriseTeam) + if err != nil { + ghErr := &githubv3.ErrorResponse{} + if errors.As(err, &ghErr) { + if ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[INFO] Removing enterprise team organizations %s from state because it no longer exists in GitHub", d.Id()) + d.SetId("") + return nil + } + } + return diag.FromErr(err) + } + + slugs := []string{} + for _, org := range orgs { + if org.Login != "" { + slugs = append(slugs, org.Login) + } + } + if err = d.Set("organization_slugs", slugs); 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 := d.Get("enterprise_slug").(string) + enterpriseTeam := d.Get("enterprise_team").(string) + + ctx = context.WithValue(ctx, ctxId, d.Id()) + orgs, err := listEnterpriseTeamOrganizations(ctx, client, enterpriseSlug, enterpriseTeam) + if err != nil { + ghErr := &githubv3.ErrorResponse{} + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { + return nil + } + return diag.FromErr(err) + } + + toRemove := []string{} + for _, org := range orgs { + if org.Login != "" { + toRemove = append(toRemove, org.Login) + } + } + + log.Printf("[INFO] Removing all organization assignments for enterprise team: %s/%s", enterpriseSlug, enterpriseTeam) + _, err = removeEnterpriseTeamOrganizations(ctx, client, enterpriseSlug, enterpriseTeam, toRemove) + if err != nil { + return diag.FromErr(err) + } + return nil +} + +func resourceGithubEnterpriseTeamOrganizationsImport(_ context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + enterpriseSlug, enterpriseTeam, err := parseSlashTwoPartID(d.Id(), "enterprise_slug", "enterprise_team") + if err != nil { + return nil, fmt.Errorf("invalid import specified: supplied import must be written as /") + } + d.SetId(buildSlashTwoPartID(enterpriseSlug, enterpriseTeam)) + _ = d.Set("enterprise_slug", enterpriseSlug) + _ = d.Set("enterprise_team", enterpriseTeam) + return []*schema.ResourceData{d}, nil +} diff --git a/github/resource_github_enterprise_team_test.go b/github/resource_github_enterprise_team_test.go new file mode 100644 index 000000000..2793d2736 --- /dev/null +++ b/github/resource_github_enterprise_team_test.go @@ -0,0 +1,211 @@ +package github + +import ( + "fmt" + "os" + "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) + + if isEnterprise != "true" { + t.Skip("Skipping because `ENTERPRISE_ACCOUNT` is not set or set to false") + } + if testEnterprise == "" { + t.Skip("Skipping because `ENTERPRISE_SLUG` is not set") + } + + 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" + } + `, testEnterprise, 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" + } + `, testEnterprise, 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"), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + {Config: config1, Check: check1}, + {Config: config2, Check: check2}, + { + ResourceName: "github_enterprise_team.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdPrefix: fmt.Sprintf(`%s/`, testEnterprise), + }, + }, + }) + } + + t.Run("with an enterprise account", func(t *testing.T) { + testCase(t, enterprise) + }) +} + +func TestAccGithubEnterpriseTeamOrganizations(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + if isEnterprise != "true" { + t.Skip("Skipping because `ENTERPRISE_ACCOUNT` is not set or set to false") + } + if testEnterprise == "" { + t.Skip("Skipping because `ENTERPRISE_SLUG` is not set") + } + if testOrganization == "" { + t.Skip("Skipping because `GITHUB_OWNER`/`GITHUB_ORGANIZATION` is not set") + } + + 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 + enterprise_team = github_enterprise_team.test.slug + organization_slugs = ["%s"] + } + `, testEnterprise, randomID, testOrganization) + + 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-orgs-%s" + organization_selection_type = "selected" + } + + resource "github_enterprise_team_organizations" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + enterprise_team = github_enterprise_team.test.slug + organization_slugs = [] + } + `, testEnterprise, randomID) + + check1 := resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_team_organizations.test", "organization_slugs.#", "1"), + resource.TestCheckTypeSetElemAttr("github_enterprise_team_organizations.test", "organization_slugs.*", testOrganization), + ) + check2 := resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_team_organizations.test", "organization_slugs.#", "0"), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + {Config: config1, Check: check1}, + {Config: config2, Check: check2}, + { + ResourceName: "github_enterprise_team_organizations.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + } + + t.Run("with an enterprise account", func(t *testing.T) { + testCase(t, enterprise) + }) +} + +func TestAccGithubEnterpriseTeamMembership(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + username := os.Getenv("GITHUB_TEST_USER") + + if isEnterprise != "true" { + t.Skip("Skipping because `ENTERPRISE_ACCOUNT` is not set or set to false") + } + if testEnterprise == "" { + t.Skip("Skipping because `ENTERPRISE_SLUG` is not set") + } + if username == "" { + t.Skip("Skipping because `GITHUB_TEST_USER` is not set") + } + + 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 + enterprise_team = github_enterprise_team.test.slug + username = "%s" + } + `, testEnterprise, randomID, username) + + check := resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_team_membership.test", "username", username), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + {Config: config, Check: check}, + { + ResourceName: "github_enterprise_team_membership.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + } + + t.Run("with an enterprise account", func(t *testing.T) { + testCase(t, enterprise) + }) +} diff --git a/github/util_enterprise_teams.go b/github/util_enterprise_teams.go new file mode 100644 index 000000000..f972004e3 --- /dev/null +++ b/github/util_enterprise_teams.go @@ -0,0 +1,357 @@ +package github + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + githubv3 "github.com/google/go-github/v67/github" +) + +const enterpriseTeamsAPIVersion = "2022-11-28" + +func parseSlashTwoPartID(id, left, right string) (string, string, error) { + parts := strings.SplitN(id, "/", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("unexpected ID format (%q); expected %s/%s", id, left, right) + } + return parts[0], parts[1], nil +} + +func buildSlashTwoPartID(a, b string) string { + return fmt.Sprintf("%s/%s", a, b) +} + +func parseSlashThreePartID(id, left, center, right string) (string, string, string, error) { + parts := strings.SplitN(id, "/", 3) + if len(parts) != 3 { + return "", "", "", fmt.Errorf("unexpected ID format (%q); expected %s/%s/%s", id, left, center, right) + } + return parts[0], parts[1], parts[2], nil +} + +func buildSlashThreePartID(a, b, c string) string { + return fmt.Sprintf("%s/%s/%s", a, b, c) +} + +func enterpriseTeamsAddListOptions(u string, opt *githubv3.ListOptions) string { + if opt == nil { + return u + } + vals := url.Values{} + if opt.Page != 0 { + vals.Set("page", strconv.Itoa(opt.Page)) + } + if opt.PerPage != 0 { + vals.Set("per_page", strconv.Itoa(opt.PerPage)) + } + enc := vals.Encode() + if enc == "" { + return u + } + if strings.Contains(u, "?") { + return u + "&" + enc + } + return u + "?" + enc +} + +func enterpriseTeamsNewRequest(client *githubv3.Client, method, urlStr string, body any) (*http.Request, error) { + req, err := client.NewRequest(method, urlStr, body) + if err != nil { + return nil, err + } + + // These endpoints are versioned and currently in public preview. + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", enterpriseTeamsAPIVersion) + return req, nil +} + +type enterpriseTeam struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + Slug string `json:"slug"` + GroupID *string `json:"group_id"` + OrganizationSelectionType string `json:"organization_selection_type"` +} + +type enterpriseTeamCreateRequest struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + OrganizationSelectionType *string `json:"organization_selection_type,omitempty"` + GroupID *string `json:"group_id,omitempty"` +} + +type enterpriseTeamUpdateRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + OrganizationSelectionType *string `json:"organization_selection_type,omitempty"` + GroupID *string `json:"group_id,omitempty"` +} + +func parseEnterpriseTeam(raw json.RawMessage) (*enterpriseTeam, error) { + // The API docs are inconsistent about whether this returns an object or an + // array with one element, so we try both. + var t enterpriseTeam + if err := json.Unmarshal(raw, &t); err == nil { + if t.ID != 0 || t.Slug != "" || t.Name != "" { + return &t, nil + } + } + + var ts []enterpriseTeam + if err := json.Unmarshal(raw, &ts); err == nil { + if len(ts) > 0 { + return &ts[0], nil + } + } + + return nil, fmt.Errorf("unexpected enterprise team response") +} + +func listEnterpriseTeams(ctx context.Context, client *githubv3.Client, enterpriseSlug string) ([]enterpriseTeam, error) { + all := []enterpriseTeam{} + opt := &githubv3.ListOptions{PerPage: maxPerPage} + + for { + u := enterpriseTeamsAddListOptions(fmt.Sprintf("enterprises/%s/teams", enterpriseSlug), opt) + req, err := enterpriseTeamsNewRequest(client, "GET", u, nil) + if err != nil { + return nil, err + } + + var pageTeams []enterpriseTeam + resp, err := client.Do(ctx, req, &pageTeams) + if err != nil { + return nil, err + } + all = append(all, pageTeams...) + + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + return all, nil +} + +func getEnterpriseTeamBySlug(ctx context.Context, client *githubv3.Client, enterpriseSlug, teamSlug string) (*enterpriseTeam, *githubv3.Response, error) { + u := fmt.Sprintf("enterprises/%s/teams/%s", enterpriseSlug, teamSlug) + req, err := enterpriseTeamsNewRequest(client, "GET", u, nil) + if err != nil { + return nil, nil, err + } + + var raw json.RawMessage + resp, err := client.Do(ctx, req, &raw) + if err != nil { + return nil, resp, err + } + + te, err := parseEnterpriseTeam(raw) + return te, resp, err +} + +func createEnterpriseTeam(ctx context.Context, client *githubv3.Client, enterpriseSlug string, reqBody enterpriseTeamCreateRequest) (*enterpriseTeam, *githubv3.Response, error) { + u := fmt.Sprintf("enterprises/%s/teams", enterpriseSlug) + req, err := enterpriseTeamsNewRequest(client, "POST", u, reqBody) + if err != nil { + return nil, nil, err + } + + var raw json.RawMessage + resp, err := client.Do(ctx, req, &raw) + if err != nil { + return nil, resp, err + } + + te, err := parseEnterpriseTeam(raw) + return te, resp, err +} + +func updateEnterpriseTeam(ctx context.Context, client *githubv3.Client, enterpriseSlug, teamSlug string, reqBody enterpriseTeamUpdateRequest) (*enterpriseTeam, *githubv3.Response, error) { + u := fmt.Sprintf("enterprises/%s/teams/%s", enterpriseSlug, teamSlug) + req, err := enterpriseTeamsNewRequest(client, "PATCH", u, reqBody) + if err != nil { + return nil, nil, err + } + + var raw json.RawMessage + resp, err := client.Do(ctx, req, &raw) + if err != nil { + return nil, resp, err + } + + te, err := parseEnterpriseTeam(raw) + return te, resp, err +} + +func deleteEnterpriseTeam(ctx context.Context, client *githubv3.Client, enterpriseSlug, teamSlug string) (*githubv3.Response, error) { + u := fmt.Sprintf("enterprises/%s/teams/%s", enterpriseSlug, teamSlug) + req, err := enterpriseTeamsNewRequest(client, "DELETE", u, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(ctx, req, nil) + if err != nil { + return resp, err + } + return resp, nil +} + +func findEnterpriseTeamByID(ctx context.Context, client *githubv3.Client, enterpriseSlug string, id int64) (*enterpriseTeam, error) { + teams, err := listEnterpriseTeams(ctx, client, enterpriseSlug) + if err != nil { + return nil, err + } + for _, t := range teams { + if t.ID == id { + copy := t + return ©, nil + } + } + return nil, nil +} + +type enterpriseOrg struct { + Login string `json:"login"` + ID int64 `json:"id"` +} + +func listEnterpriseTeamOrganizations(ctx context.Context, client *githubv3.Client, enterpriseSlug, enterpriseTeam string) ([]enterpriseOrg, error) { + all := []enterpriseOrg{} + opt := &githubv3.ListOptions{PerPage: maxPerPage} + + for { + u := enterpriseTeamsAddListOptions(fmt.Sprintf("enterprises/%s/teams/%s/organizations", enterpriseSlug, enterpriseTeam), opt) + req, err := enterpriseTeamsNewRequest(client, "GET", u, nil) + if err != nil { + return nil, err + } + + var pageOrgs []enterpriseOrg + resp, err := client.Do(ctx, req, &pageOrgs) + if err != nil { + // Some docs show a single object; tolerate that. + var ghErr *githubv3.ErrorResponse + if errors.As(err, &ghErr) { + return nil, err + } + return nil, err + } + all = append(all, pageOrgs...) + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + return all, nil +} + +type enterpriseTeamOrgSlugsRequest struct { + OrganizationSlugs []string `json:"organization_slugs"` +} + +func addEnterpriseTeamOrganizations(ctx context.Context, client *githubv3.Client, enterpriseSlug, enterpriseTeam string, orgSlugs []string) error { + if len(orgSlugs) == 0 { + return nil + } + u := fmt.Sprintf("enterprises/%s/teams/%s/organizations/add", enterpriseSlug, enterpriseTeam) + req, err := enterpriseTeamsNewRequest(client, "POST", u, enterpriseTeamOrgSlugsRequest{OrganizationSlugs: orgSlugs}) + if err != nil { + return err + } + _, err = client.Do(ctx, req, nil) + return err +} + +func removeEnterpriseTeamOrganizations(ctx context.Context, client *githubv3.Client, enterpriseSlug, enterpriseTeam string, orgSlugs []string) (*githubv3.Response, error) { + if len(orgSlugs) == 0 { + return nil, nil + } + u := fmt.Sprintf("enterprises/%s/teams/%s/organizations/remove", enterpriseSlug, enterpriseTeam) + req, err := enterpriseTeamsNewRequest(client, "POST", u, enterpriseTeamOrgSlugsRequest{OrganizationSlugs: orgSlugs}) + if err != nil { + return nil, err + } + resp, err := client.Do(ctx, req, nil) + return resp, err +} + +func getEnterpriseTeamMembership(ctx context.Context, client *githubv3.Client, enterpriseSlug, enterpriseTeam, username string) (*githubv3.Response, error) { + u := fmt.Sprintf("enterprises/%s/teams/%s/memberships/%s", enterpriseSlug, enterpriseTeam, username) + req, err := enterpriseTeamsNewRequest(client, "GET", u, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(ctx, req, nil) + return resp, err +} + +type enterpriseTeamMembership struct { + State string `json:"state"` + Role string `json:"role"` +} + +func parseEnterpriseTeamMembership(raw json.RawMessage) (*enterpriseTeamMembership, error) { + var m enterpriseTeamMembership + if err := json.Unmarshal(raw, &m); err == nil { + if m.State != "" || m.Role != "" { + return &m, nil + } + } + + var ms []enterpriseTeamMembership + if err := json.Unmarshal(raw, &ms); err == nil { + if len(ms) > 0 { + return &ms[0], nil + } + } + + // If the API ever returns an empty object, keep a non-nil struct for callers. + return &enterpriseTeamMembership{}, nil +} + +func getEnterpriseTeamMembershipDetails(ctx context.Context, client *githubv3.Client, enterpriseSlug, enterpriseTeam, username string) (*enterpriseTeamMembership, *githubv3.Response, error) { + u := fmt.Sprintf("enterprises/%s/teams/%s/memberships/%s", enterpriseSlug, enterpriseTeam, username) + req, err := enterpriseTeamsNewRequest(client, "GET", u, nil) + if err != nil { + return nil, nil, err + } + + var raw json.RawMessage + resp, err := client.Do(ctx, req, &raw) + if err != nil { + return nil, resp, err + } + + m, err := parseEnterpriseTeamMembership(raw) + return m, resp, err +} + +func addEnterpriseTeamMember(ctx context.Context, client *githubv3.Client, enterpriseSlug, enterpriseTeam, username string) (*githubv3.Response, error) { + u := fmt.Sprintf("enterprises/%s/teams/%s/memberships/%s", enterpriseSlug, enterpriseTeam, username) + req, err := enterpriseTeamsNewRequest(client, "PUT", u, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(ctx, req, nil) + return resp, err +} + +func removeEnterpriseTeamMember(ctx context.Context, client *githubv3.Client, enterpriseSlug, enterpriseTeam, username string) (*githubv3.Response, error) { + u := fmt.Sprintf("enterprises/%s/teams/%s/memberships/%s", enterpriseSlug, enterpriseTeam, username) + req, err := enterpriseTeamsNewRequest(client, "DELETE", u, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(ctx, req, nil) + return resp, err +} diff --git a/website/docs/d/enterprise_team.html.markdown b/website/docs/d/enterprise_team.html.markdown new file mode 100644 index 000000000..e2e2bf2e9 --- /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 000000000..53b380f31 --- /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" + enterprise_team = "ent:platform" + username = "octocat" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `enterprise_team` - (Required) The slug or ID 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 000000000..a864d8297 --- /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" + enterprise_team = "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. +* `enterprise_team` - (Required) The slug or ID 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 000000000..1462c9c4a --- /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 000000000..f33b35dae --- /dev/null +++ b/website/docs/r/enterprise_team.html.markdown @@ -0,0 +1,53 @@ +--- +layout: "github" +page_title: "Github: github_enterprise_team" +description: |- + Create 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 000000000..e13e9ad3c --- /dev/null +++ b/website/docs/r/enterprise_team_membership.html.markdown @@ -0,0 +1,47 @@ +--- +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 + enterprise_team = github_enterprise_team.team.slug + username = "octocat" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `enterprise_team` - (Required) The slug or ID of the enterprise team. +* `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 000000000..a5ba4eb10 --- /dev/null +++ b/website/docs/r/enterprise_team_organizations.html.markdown @@ -0,0 +1,53 @@ +--- +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 + enterprise_team = 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. +* `enterprise_team` - (Required) The slug or ID of the enterprise team. +* `organization_slugs` - (Optional) Set of organization slugs to assign the team to. + +## Import + +This resource can be imported using: + +``` +$ terraform import github_enterprise_team_organizations.assignments enterprise-slug/ent:platform +```