diff --git a/PR_3008_UNRESOLVED_COMMENTS_STATUS.md b/PR_3008_UNRESOLVED_COMMENTS_STATUS.md new file mode 100644 index 0000000000..3d6dd915ef --- /dev/null +++ b/PR_3008_UNRESOLVED_COMMENTS_STATUS.md @@ -0,0 +1,232 @@ +# PR #3008 Unresolved Comments Status Report + +**Pull Request**: [#3008 - Add support for Enterprise Teams](https://github.com/integrations/terraform-provider-github/pull/3008) +**Repository**: integrations/terraform-provider-github +**Status**: Open +**Total Review Comments**: 45 +**Generated**: 2026-01-16 + +## Summary + +This report provides a comprehensive status of all review comments on PR #3008, focusing specifically on the **3 unresolved threads** that require attention before the PR can be merged. + +## PR Overview + +- **Title**: [FEAT] Add support for Enterprise Teams +- **Author**: @vmvarela +- **Created**: 2025-12-17 +- **Last Updated**: 2026-01-10 +- **Commits**: 24 +- **Files Changed**: 19 +- **Additions**: 1,870 lines +- **Review Comments**: 45 total (42 resolved, 3 unresolved) + +## Unresolved Comments (3) + +### 1. Data Source Description Inconsistency +**Thread ID**: PRRT_kwDOBZHf085oO5Gs +**File**: `github/data_source_github_enterprise_team_membership.go` +**Status**: ⚠️ **UNRESOLVED** +**Reviewer**: @copilot-pull-request-reviewer +**Date**: 2026-01-06 + +**Issue**: The Description field says "Manages membership in a GitHub enterprise team" which is incorrect for a data source. Data sources read/query existing data rather than manage it. + +**Current Code**: +```go +Description: "Manages membership in a GitHub enterprise team.", +``` + +**Suggested Fix**: +```go +Description: "Retrieves information about membership in a GitHub enterprise team.", +``` + +**Impact**: Medium - This is a documentation/description issue that affects user understanding but not functionality. + +**Recommendation**: Accept the suggestion and update the description to accurately reflect that this is a data source (read-only) not a resource (manages state). + +--- + +### 2. Empty Description Field Handling +**Thread ID**: PRRT_kwDOBZHf085oO5HT +**File**: `github/resource_github_enterprise_team.go` +**Status**: ⚠️ **UNRESOLVED** +**Reviewer**: @copilot-pull-request-reviewer +**Date**: 2026-01-06 + +**Issue**: The description field is unconditionally wrapped with `githubv3.String()` even when it's empty. This means an empty string will be sent in the API request. + +**Current Code**: +```go +OrganizationSelectionType: githubv3.String(orgSelection), +} +// Description is always set, even if empty +``` + +**Suggested Fix**: +```go +OrganizationSelectionType: githubv3.String(orgSelection), +} +if description != "" { + req.Description = githubv3.String(description) +} +``` + +**Impact**: Low - API might receive unnecessary empty strings, but this is unlikely to cause functional issues. + +**Recommendation**: Accept the suggestion to conditionally set the Description field only when non-empty, similar to how groupID is handled in the same function. + +--- + +### 3. Read-after-Create/Update Pattern (Critical) +**Thread ID**: PRRT_kwDOBZHf085oxj2- +**File**: `github/resource_github_enterprise_team_membership.go` +**Status**: ⚠️ **UNRESOLVED** +**Reviewer**: @deiga +**Date**: 2026-01-09 + +**Issue**: The code uses the deprecated "Read-after-Create/Update" pattern. The project no longer wants to use this pattern. + +**Context**: This is related to comment #35 which states: +> "We don't want to use the `Read` after `Create` or `Update` pattern anymore. If there are computed fields, you should set them in `Update` or `Create` directly" + +**Current Pattern**: +```go +func resourceGithubEnterpriseTeamMembershipCreate(...) { + // ... create logic ... + return resourceGithubEnterpriseTeamMembershipRead(ctx, d, meta) +} + +func resourceGithubEnterpriseTeamMembershipUpdate(...) { + // ... update logic ... + return resourceGithubEnterpriseTeamMembershipRead(ctx, d, meta) +} +``` + +**Recommended Fix**: +```go +func resourceGithubEnterpriseTeamMembershipCreate(...) { + // ... create logic ... + // Set computed fields directly from API response + return nil +} + +func resourceGithubEnterpriseTeamMembershipUpdate(...) { + // ... update logic ... + // Set computed fields directly from API response + return nil +} +``` + +**Impact**: High - This is a code pattern/architectural issue that affects maintainability and aligns with project standards. + +**Recommendation**: +1. Remove the `return resourceGithubEnterpriseTeamMembershipRead(...)` calls from Create and Update functions +2. Set any computed fields directly from the API response instead +3. Apply this pattern to `resource_github_enterprise_team.go` and `resource_github_enterprise_team_organizations.go` as well (per related comments #33 and #34) + +**Note**: The PR author has already addressed this pattern in PR #6 of their fork (vmvarela/terraform-provider-github), so they're aware of this requirement. The fix needs to be applied to the upstream PR #3008. + +--- + +## Resolved Comments (42) + +The following categories of comments have been successfully resolved: + +### Schema and Validation (7 comments - All Resolved ✅) +- Added `ValidateDiagFunc` for enterprise slug validation +- Added `ValidateDiagFunc` for team_id validation +- Implemented `ExactlyOneOf` for slug/team_id fields +- Removed redundant `ConflictsWith` constraints +- Removed unnecessary validation checks from CRUD functions + +### Documentation Branding (6 comments - All Resolved ✅) +- Fixed "Github" → "GitHub" capitalization in all page_title fields +- Fixed grammatical error "Create and manages" → "Creates and manages" + +### go-github SDK Usage (1 comment - Resolved ✅) +- Acknowledged need to use go-github SDK instead of direct REST API calls +- Waiting for SDK v81+ release with Enterprise Teams support + +### Code Quality and Best Practices (28 comments - All Resolved ✅) +- Removed meaningless `_ = resp` assignments +- Removed redundant `testCase` wrapper pattern in tests +- Added top-level `Description` fields to data sources +- Used `testResourcePrefix` for consistent test resource naming +- Used constants for field names in data sources +- Moved utility functions to `util_enterprise_teams.go` +- Separated `enterprise_team` into `team_slug` and `team_id` fields with `ExactlyOneOf` +- Improved error handling and messages +- Removed unused variables and dead code + +--- + +## Critical Path to Merge + +To get PR #3008 ready for merge, the following must be addressed **in priority order**: + +### Priority 1: Architectural Pattern (MUST FIX) +1. **Remove Read-after-Create/Update pattern** across all three resources: + - `resource_github_enterprise_team.go` + - `resource_github_enterprise_team_membership.go` + - `resource_github_enterprise_team_organizations.go` + +### Priority 2: Code Quality (SHOULD FIX) +2. **Fix empty description handling** in `resource_github_enterprise_team.go` +3. **Update data source description** in `data_source_github_enterprise_team_membership.go` + +### Priority 3: SDK Dependency (BLOCKED - External) +- **Wait for go-github v81+** release with Enterprise Teams API support +- Once available, replace direct REST API calls with SDK methods + +--- + +## Reviewer Activity Summary + +| Reviewer | Comments | Status | +|----------|----------|--------| +| @deiga | 28 | 25 resolved, 3 unresolved | +| @copilot-pull-request-reviewer | 17 | 15 resolved, 2 unresolved | + +**Note**: @deiga is the primary human reviewer and maintainer. Their unresolved comments should be prioritized. + +--- + +## Recommendations for PR Author + +1. **Immediate Action Required**: + - Address the 3 unresolved comments, particularly the Read-after-Create/Update pattern issue + - Since you've already fixed this pattern in your fork's PR #6, you can apply the same changes to the upstream PR + +2. **Reference Implementation**: + - Your fork's PR #6 ([vmvarela/terraform-provider-github#6](https://github.com/vmvarela/terraform-provider-github/pull/6)) shows the correct pattern + - The commit "feat(enterprise_team): address PR review feedback" demonstrates the required changes + +3. **Next Steps**: + - Update the PR with fixes for the 3 unresolved comments + - Wait for go-github SDK v81+ release (external dependency) + - Request re-review from @deiga once changes are complete + +--- + +## Additional Context + +### Related PRs +- **Fork PR #6**: Contains fixes for review feedback from upstream PR #3008 +- Already merged into fork's enterprise-teams branch +- Shows the correct implementation pattern requested by reviewers + +### Testing Status +- All 7 acceptance tests passing in fork +- Tests include: enterprise team CRUD, membership, organizations, and data sources + +--- + +## Conclusion + +**Overall Status**: PR is nearly ready for merge, pending resolution of 3 minor unresolved comments and one external dependency (go-github SDK update). + +**Time Estimate**: The 3 unresolved comments can be addressed in 1-2 hours of focused work, based on the complexity and existing reference implementation in the fork. + +**Blocker**: The go-github SDK dependency (waiting for v81+ release) is the only hard blocker, but this doesn't prevent addressing the code review comments now. 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 +```