From efab2d8c48fc3d54b4497b7b9413c7c17f8701ac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:10:15 +0100 Subject: [PATCH 01/33] feat(utils): add shared helper functions for cost centers Add utility functions needed for enterprise cost center resources: - errIs404(): check if error is a GitHub 404 Not Found response - errIsRetryable(): check if error is retryable (409, 5xx) - expandStringSet(): convert schema.Set to []string - chunkStringSlice(): split slice into chunks for batching --- github/util.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/github/util.go b/github/util.go index 0d9bc0d32c..dcb3dfddcd 100644 --- a/github/util.go +++ b/github/util.go @@ -270,6 +270,53 @@ func getTeamSlugContext(ctx context.Context, teamIDString string, meta any) (str return team.GetSlug(), nil } +// errIs404 checks if the error is a GitHub 404 Not Found response. +func errIs404(err error) bool { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil { + return ghErr.Response.StatusCode == http.StatusNotFound + } + return false +} + +// errIsRetryable checks if the error is a retryable GitHub API error. +func errIsRetryable(err error) bool { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil { + switch ghErr.Response.StatusCode { + case http.StatusConflict, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: + return true + default: + return false + } + } + return false +} + +// expandStringSet converts a schema.Set to a string slice. +func expandStringSet(set *schema.Set) []string { + if set == nil { + return nil + } + list := set.List() + return expandStringList(list) +} + +// chunkStringSlice splits a slice into chunks of the specified max size. +// +//nolint:unparam // maxSize is parameterized for reusability across different contexts +func chunkStringSlice(items []string, maxSize int) [][]string { + if len(items) == 0 { + return nil + } + chunks := make([][]string, 0, (len(items)+maxSize-1)/maxSize) + for start := 0; start < len(items); start += maxSize { + end := min(start+maxSize, len(items)) + chunks = append(chunks, items[start:end]) + } + return chunks +} + // https://docs.github.com/en/actions/reference/encrypted-secrets#naming-your-secrets var secretNameRegexp = regexp.MustCompile("^[a-zA-Z_][a-zA-Z0-9_]*$") From 7da8d3ca46d65f42e6caef4340849ea9a1a9c51f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:10:35 +0100 Subject: [PATCH 02/33] feat(cost-centers): add retry logic utilities for cost center operations Add util_enterprise_cost_center.go with helper functions for managing cost center assignments with proper retry logic and batching support. --- github/util_enterprise_cost_center.go | 54 +++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 github/util_enterprise_cost_center.go diff --git a/github/util_enterprise_cost_center.go b/github/util_enterprise_cost_center.go new file mode 100644 index 0000000000..98f5b63753 --- /dev/null +++ b/github/util_enterprise_cost_center.go @@ -0,0 +1,54 @@ +package github + +import ( + "context" + "time" + + "github.com/google/go-github/v81/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" +) + +// Cost center resource management constants and retry functions. +const ( + maxResourcesPerRequest = 50 + costCenterResourcesRetryTimeout = 5 * time.Minute +) + +// retryCostCenterRemoveResources removes resources from a cost center with retry logic. +// Uses retry.RetryContext for exponential backoff on transient errors. +func retryCostCenterRemoveResources(ctx context.Context, client *github.Client, enterpriseSlug, costCenterID string, req github.CostCenterResourceRequest) diag.Diagnostics { + err := retry.RetryContext(ctx, costCenterResourcesRetryTimeout, func() *retry.RetryError { + _, _, err := client.Enterprise.RemoveResourcesFromCostCenter(ctx, enterpriseSlug, costCenterID, req) + if err == nil { + return nil + } + if errIsRetryable(err) { + return retry.RetryableError(err) + } + return retry.NonRetryableError(err) + }) + if err != nil { + return diag.FromErr(err) + } + return nil +} + +// retryCostCenterAddResources adds resources to a cost center with retry logic. +// Uses retry.RetryContext for exponential backoff on transient errors. +func retryCostCenterAddResources(ctx context.Context, client *github.Client, enterpriseSlug, costCenterID string, req github.CostCenterResourceRequest) diag.Diagnostics { + err := retry.RetryContext(ctx, costCenterResourcesRetryTimeout, func() *retry.RetryError { + _, _, err := client.Enterprise.AddResourcesToCostCenter(ctx, enterpriseSlug, costCenterID, req) + if err == nil { + return nil + } + if errIsRetryable(err) { + return retry.RetryableError(err) + } + return retry.NonRetryableError(err) + }) + if err != nil { + return diag.FromErr(err) + } + return nil +} From 4034934cdae880901b40b50a0d14e6c8188f19b1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:11:03 +0100 Subject: [PATCH 03/33] feat(cost-centers): add github_enterprise_cost_center resource Manages GitHub Enterprise cost center entities (create, read, update, archive). Includes: - Resource implementation with CRUD operations - Acceptance tests - Documentation --- .../resource_github_enterprise_cost_center.go | 174 ++++++++++++++++++ ...urce_github_enterprise_cost_center_test.go | 120 ++++++++++++ .../r/enterprise_cost_center.html.markdown | 52 ++++++ 3 files changed, 346 insertions(+) create mode 100644 github/resource_github_enterprise_cost_center.go create mode 100644 github/resource_github_enterprise_cost_center_test.go create mode 100644 website/docs/r/enterprise_cost_center.html.markdown diff --git a/github/resource_github_enterprise_cost_center.go b/github/resource_github_enterprise_cost_center.go new file mode 100644 index 0000000000..7cae70355f --- /dev/null +++ b/github/resource_github_enterprise_cost_center.go @@ -0,0 +1,174 @@ +package github + +import ( + "context" + "fmt" + + "github.com/google/go-github/v81/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubEnterpriseCostCenter() *schema.Resource { + return &schema.Resource{ + Description: "Manages an enterprise cost center in GitHub.", + CreateContext: resourceGithubEnterpriseCostCenterCreate, + ReadContext: resourceGithubEnterpriseCostCenterRead, + UpdateContext: resourceGithubEnterpriseCostCenterUpdate, + DeleteContext: resourceGithubEnterpriseCostCenterDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubEnterpriseCostCenterImport, + }, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug of the enterprise.", + }, + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the cost center.", + }, + "state": { + Type: schema.TypeString, + Computed: true, + Description: "The state of the cost center.", + }, + "azure_subscription": { + Type: schema.TypeString, + Computed: true, + Description: "The Azure subscription associated with the cost center.", + }, + }, + } +} + +func resourceGithubEnterpriseCostCenterCreate(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) + + tflog.Info(ctx, "Creating enterprise cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "name": name, + }) + + cc, _, err := client.Enterprise.CreateCostCenter(ctx, enterpriseSlug, github.CostCenterRequest{Name: name}) + if err != nil { + return diag.FromErr(err) + } + + if cc == nil || cc.ID == "" { + return diag.Errorf("failed to create cost center: missing id in response (unexpected API response; please retry or contact support)") + } + + d.SetId(cc.ID) + + // Set computed fields from the API response + if err := d.Set("state", cc.GetState()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("azure_subscription", cc.GetAzureSubscription()); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseCostCenterRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Id() + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + if errIs404(err) { + tflog.Warn(ctx, "Cost center not found, removing from state", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + }) + d.SetId("") + return nil + } + return diag.FromErr(err) + } + + if err := d.Set("name", cc.Name); err != nil { + return diag.FromErr(err) + } + + if err := d.Set("state", cc.GetState()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("azure_subscription", cc.GetAzureSubscription()); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseCostCenterUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Id() + + // Check current state to prevent updates on archived cost centers + currentState := d.Get("state").(string) + if currentState == "deleted" { + return diag.Errorf("cannot update cost center %q because it is archived", costCenterID) + } + + if d.HasChange("name") { + name := d.Get("name").(string) + tflog.Info(ctx, "Updating enterprise cost center name", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "name": name, + }) + _, _, err := client.Enterprise.UpdateCostCenter(ctx, enterpriseSlug, costCenterID, github.CostCenterRequest{Name: name}) + if err != nil { + return diag.FromErr(err) + } + } + + return nil +} + +func resourceGithubEnterpriseCostCenterDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Id() + + tflog.Info(ctx, "Archiving enterprise cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + }) + + _, _, err := client.Enterprise.DeleteCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + if errIs404(err) { + return nil + } + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseCostCenterImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + enterpriseSlug, costCenterID, err := parseID2(d.Id()) + if err != nil { + return nil, fmt.Errorf("invalid import ID %q: expected format :", d.Id()) + } + + d.SetId(costCenterID) + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} diff --git a/github/resource_github_enterprise_cost_center_test.go b/github/resource_github_enterprise_cost_center_test.go new file mode 100644 index 0000000000..b03bb889ed --- /dev/null +++ b/github/resource_github_enterprise_cost_center_test.go @@ -0,0 +1,120 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccGithubEnterpriseCostCenter(t *testing.T) { + t.Run("creates cost center without error", func(t *testing.T) { + randomID := acctest.RandString(5) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "enterprise_slug", testAccConf.enterpriseSlug), + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "name", testResourcePrefix+randomID), + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "state", "active"), + ), + }, + }, + }) + }) + + t.Run("updates cost center name without error", func(t *testing.T) { + randomID := acctest.RandString(5) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "name", testResourcePrefix+randomID), + ), + }, + { + Config: fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%supdated-%s" + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "name", testResourcePrefix+"updated-"+randomID), + ), + }, + }, + }) + }) + + t.Run("imports cost center without error", func(t *testing.T) { + randomID := acctest.RandString(5) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID), + }, + { + ResourceName: "github_enterprise_cost_center.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + rs, ok := s.RootModule().Resources["github_enterprise_cost_center.test"] + if !ok { + return "", fmt.Errorf("resource not found in state") + } + id, err := buildID(testAccConf.enterpriseSlug, rs.Primary.ID) + if err != nil { + return "", err + } + return id, nil + }, + }, + }, + }) + }) +} diff --git a/website/docs/r/enterprise_cost_center.html.markdown b/website/docs/r/enterprise_cost_center.html.markdown new file mode 100644 index 0000000000..8c3bf413a8 --- /dev/null +++ b/website/docs/r/enterprise_cost_center.html.markdown @@ -0,0 +1,52 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_cost_center" +description: |- + Create and manage a GitHub enterprise cost center. +--- + +# github_enterprise_cost_center + +This resource allows you to create and manage a GitHub enterprise cost center. + +~> **Note:** This resource manages only the cost center entity itself. To assign users, organizations, or repositories, use the separate `github_enterprise_cost_center_users`, `github_enterprise_cost_center_organizations`, and `github_enterprise_cost_center_repositories` resources. + +Deleting this resource archives the cost center (GitHub calls this state `deleted`). + +## Example Usage + +```hcl +resource "github_enterprise_cost_center" "example" { + enterprise_slug = "example-enterprise" + name = "platform-cost-center" +} + +# Use separate resources to manage assignments +resource "github_enterprise_cost_center_users" "example" { + enterprise_slug = "example-enterprise" + cost_center_id = github_enterprise_cost_center.example.id + usernames = ["alice", "bob"] +} +``` + +## Argument Reference + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `name` - (Required) The name of the cost center. + +## Attributes Reference + +The following additional attributes are exported: + +* `id` - The cost center ID. +* `state` - The state of the cost center. +* `azure_subscription` - The Azure subscription associated with the cost center. + +## Import + +GitHub Enterprise Cost Center can be imported using the `enterprise_slug` and the `cost_center_id`, separated by a `:` character. + +``` +$ terraform import github_enterprise_cost_center.example example-enterprise: +``` + From 2c5f59cafbc95e337905b3c9d3f56b9a5ea32a26 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:11:27 +0100 Subject: [PATCH 04/33] feat(cost-centers): add github_enterprise_cost_center_users resource Authoritative management of user assignments to cost centers. Includes: - Resource implementation with batched add/remove operations - Acceptance tests - Documentation --- ...rce_github_enterprise_cost_center_users.go | 201 ++++++++++++++++++ ...ithub_enterprise_cost_center_users_test.go | 92 ++++++++ ...enterprise_cost_center_users.html.markdown | 45 ++++ 3 files changed, 338 insertions(+) create mode 100644 github/resource_github_enterprise_cost_center_users.go create mode 100644 github/resource_github_enterprise_cost_center_users_test.go create mode 100644 website/docs/r/enterprise_cost_center_users.html.markdown diff --git a/github/resource_github_enterprise_cost_center_users.go b/github/resource_github_enterprise_cost_center_users.go new file mode 100644 index 0000000000..669dbf3a03 --- /dev/null +++ b/github/resource_github_enterprise_cost_center_users.go @@ -0,0 +1,201 @@ +package github + +import ( + "context" + "fmt" + + "github.com/google/go-github/v81/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubEnterpriseCostCenterUsers() *schema.Resource { + return &schema.Resource{ + Description: "Manages user assignments for a GitHub enterprise cost center (authoritative).", + CreateContext: resourceGithubEnterpriseCostCenterUsersCreateOrUpdate, + ReadContext: resourceGithubEnterpriseCostCenterUsersRead, + UpdateContext: resourceGithubEnterpriseCostCenterUsersCreateOrUpdate, + DeleteContext: resourceGithubEnterpriseCostCenterUsersDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubEnterpriseCostCenterUsersImport, + }, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug of the enterprise.", + }, + "cost_center_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The ID of the cost center.", + }, + "usernames": { + Type: schema.TypeSet, + Required: true, + MinItems: 1, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Usernames to assign to the cost center. This is authoritative - users not in this set will be removed.", + }, + }, + } +} + +func resourceGithubEnterpriseCostCenterUsersCreateOrUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + // If this is Create, set the ID + if d.Id() == "" { + id, err := buildID(enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + } + + // Get current assignments from API + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + + // Extract current users + currentUsers := make(map[string]bool) + for _, r := range cc.Resources { + if r != nil && r.Type == "User" { + currentUsers[r.Name] = true + } + } + + // Get desired users from config + desiredUsersSet := d.Get("usernames").(*schema.Set) + desiredUsers := make(map[string]bool) + for _, u := range desiredUsersSet.List() { + desiredUsers[u.(string)] = true + } + + // Calculate additions and removals + var toAdd, toRemove []string + for user := range desiredUsers { + if !currentUsers[user] { + toAdd = append(toAdd, user) + } + } + for user := range currentUsers { + if !desiredUsers[user] { + toRemove = append(toRemove, user) + } + } + + // Remove users no longer desired + if len(toRemove) > 0 { + tflog.Info(ctx, "Removing users from cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toRemove), + }) + + for _, batch := range chunkStringSlice(toRemove, maxResourcesPerRequest) { + if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Users: batch}); diags.HasError() { + return diags + } + } + } + + // Add new users + if len(toAdd) > 0 { + tflog.Info(ctx, "Adding users to cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toAdd), + }) + + for _, batch := range chunkStringSlice(toAdd, maxResourcesPerRequest) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Users: batch}); diags.HasError() { + return diags + } + } + } + + return resourceGithubEnterpriseCostCenterUsersRead(ctx, d, meta) +} + +func resourceGithubEnterpriseCostCenterUsersRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + if errIs404(err) { + tflog.Warn(ctx, "Cost center not found, removing from state", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + }) + d.SetId("") + return nil + } + return diag.FromErr(err) + } + + // Extract users from resources + var users []string + for _, r := range cc.Resources { + if r != nil && r.Type == "User" { + users = append(users, r.Name) + } + } + + if err := d.Set("usernames", flattenStringList(users)); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseCostCenterUsersDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + usernamesSet := d.Get("usernames").(*schema.Set) + usernames := expandStringSet(usernamesSet) + + if len(usernames) > 0 { + tflog.Info(ctx, "Removing all users from cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(usernames), + }) + + for _, batch := range chunkStringSlice(usernames, maxResourcesPerRequest) { + if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Users: batch}); diags.HasError() { + return diags + } + } + } + + return nil +} + +func resourceGithubEnterpriseCostCenterUsersImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + enterpriseSlug, costCenterID, err := parseID2(d.Id()) + if err != nil { + return nil, fmt.Errorf("invalid import ID %q: expected format :", d.Id()) + } + + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return nil, err + } + if err := d.Set("cost_center_id", costCenterID); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} diff --git a/github/resource_github_enterprise_cost_center_users_test.go b/github/resource_github_enterprise_cost_center_users_test.go new file mode 100644 index 0000000000..2ab4bd5aa4 --- /dev/null +++ b/github/resource_github_enterprise_cost_center_users_test.go @@ -0,0 +1,92 @@ +package github + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccGithubEnterpriseCostCenterUsers(t *testing.T) { + t.Run("manages user assignments without error", func(t *testing.T) { + randomID := acctest.RandString(5) + user := testAccConf.username + + config := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + } + + resource "github_enterprise_cost_center_users" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + cost_center_id = github_enterprise_cost_center.test.id + usernames = [%q] + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID, user) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGithubEnterpriseCostCenterUsersDestroy, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_cost_center_users.test", "enterprise_slug", testAccConf.enterpriseSlug), + resource.TestCheckResourceAttr("github_enterprise_cost_center_users.test", "usernames.#", "1"), + resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center_users.test", "usernames.*", user), + ), + }, + { + ResourceName: "github_enterprise_cost_center_users.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) +} + +func testAccCheckGithubEnterpriseCostCenterUsersDestroy(s *terraform.State) error { + meta, err := getTestMeta() + if err != nil { + return err + } + client := meta.v3client + + for _, rs := range s.RootModule().Resources { + if rs.Type != "github_enterprise_cost_center_users" { + continue + } + + enterpriseSlug, costCenterID, err := parseID2(rs.Primary.ID) + if err != nil { + return err + } + + cc, _, err := client.Enterprise.GetCostCenter(context.Background(), enterpriseSlug, costCenterID) + if errIs404(err) { + return nil + } + if err != nil { + return err + } + + // Check if users are still assigned + for _, resource := range cc.Resources { + if resource.Type == "user" { + return fmt.Errorf("cost center %s still has user assignments", costCenterID) + } + } + } + + return nil +} diff --git a/website/docs/r/enterprise_cost_center_users.html.markdown b/website/docs/r/enterprise_cost_center_users.html.markdown new file mode 100644 index 0000000000..b14a51b270 --- /dev/null +++ b/website/docs/r/enterprise_cost_center_users.html.markdown @@ -0,0 +1,45 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_cost_center_users" +description: |- + Manages user assignments to a GitHub enterprise cost center. +--- + +# github_enterprise_cost_center_users + +This resource manages user assignments to a GitHub enterprise cost center. + +~> **Note:** This resource is authoritative. It will manage the full set of users assigned to the cost center. To add users without affecting other assignments, you must include all desired users in the `usernames` set. + +## Example Usage + +```hcl +resource "github_enterprise_cost_center" "example" { + enterprise_slug = "example-enterprise" + name = "platform-cost-center" +} + +resource "github_enterprise_cost_center_users" "example" { + enterprise_slug = "example-enterprise" + cost_center_id = github_enterprise_cost_center.example.id + usernames = ["alice", "bob", "charlie"] +} +``` + +## Argument Reference + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `cost_center_id` - (Required) The ID of the cost center. +* `usernames` - (Required) Set of usernames to assign to the cost center. Must contain at least one username. + +## Attributes Reference + +This resource exports no additional attributes. + +## Import + +GitHub Enterprise Cost Center User assignments can be imported using the `enterprise_slug` and the `cost_center_id`, separated by a `:` character. + +``` +$ terraform import github_enterprise_cost_center_users.example example-enterprise: +``` From a1cdf11a9e9609828cd380eee052079a9b554024 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:11:41 +0100 Subject: [PATCH 05/33] feat(cost-centers): add github_enterprise_cost_center_organizations resource Authoritative management of organization assignments to cost centers. Includes: - Resource implementation with batched add/remove operations - Acceptance tests - Documentation --- ...ub_enterprise_cost_center_organizations.go | 193 ++++++++++++++++++ ...terprise_cost_center_organizations_test.go | 97 +++++++++ ...se_cost_center_organizations.html.markdown | 45 ++++ 3 files changed, 335 insertions(+) create mode 100644 github/resource_github_enterprise_cost_center_organizations.go create mode 100644 github/resource_github_enterprise_cost_center_organizations_test.go create mode 100644 website/docs/r/enterprise_cost_center_organizations.html.markdown diff --git a/github/resource_github_enterprise_cost_center_organizations.go b/github/resource_github_enterprise_cost_center_organizations.go new file mode 100644 index 0000000000..2f9de126ed --- /dev/null +++ b/github/resource_github_enterprise_cost_center_organizations.go @@ -0,0 +1,193 @@ +package github + +import ( + "context" + "fmt" + + "github.com/google/go-github/v81/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubEnterpriseCostCenterOrganizations() *schema.Resource { + return &schema.Resource{ + Description: "Manages organization assignments for a GitHub enterprise cost center (authoritative).", + CreateContext: resourceGithubEnterpriseCostCenterOrganizationsCreateOrUpdate, + ReadContext: resourceGithubEnterpriseCostCenterOrganizationsRead, + UpdateContext: resourceGithubEnterpriseCostCenterOrganizationsCreateOrUpdate, + DeleteContext: resourceGithubEnterpriseCostCenterOrganizationsDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubEnterpriseCostCenterOrganizationsImport, + }, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug of the enterprise.", + }, + "cost_center_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The ID of the cost center.", + }, + "organization_logins": { + Type: schema.TypeSet, + Required: true, + MinItems: 1, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Organization logins to assign to the cost center. This is authoritative - organizations not in this set will be removed.", + }, + }, + } +} + +func resourceGithubEnterpriseCostCenterOrganizationsCreateOrUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + if d.Id() == "" { + id, err := buildID(enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + } + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + + currentOrgs := make(map[string]bool) + for _, r := range cc.Resources { + if r != nil && r.Type == "Org" { + currentOrgs[r.Name] = true + } + } + + desiredOrgsSet := d.Get("organization_logins").(*schema.Set) + desiredOrgs := make(map[string]bool) + for _, o := range desiredOrgsSet.List() { + desiredOrgs[o.(string)] = true + } + + var toAdd, toRemove []string + for org := range desiredOrgs { + if !currentOrgs[org] { + toAdd = append(toAdd, org) + } + } + for org := range currentOrgs { + if !desiredOrgs[org] { + toRemove = append(toRemove, org) + } + } + + if len(toRemove) > 0 { + tflog.Info(ctx, "Removing organizations from cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toRemove), + }) + + for _, batch := range chunkStringSlice(toRemove, maxResourcesPerRequest) { + if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Organizations: batch}); diags.HasError() { + return diags + } + } + } + + if len(toAdd) > 0 { + tflog.Info(ctx, "Adding organizations to cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toAdd), + }) + + for _, batch := range chunkStringSlice(toAdd, maxResourcesPerRequest) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Organizations: batch}); diags.HasError() { + return diags + } + } + } + + return resourceGithubEnterpriseCostCenterOrganizationsRead(ctx, d, meta) +} + +func resourceGithubEnterpriseCostCenterOrganizationsRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + if errIs404(err) { + tflog.Warn(ctx, "Cost center not found, removing from state", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + }) + d.SetId("") + return nil + } + return diag.FromErr(err) + } + + var organizations []string + for _, r := range cc.Resources { + if r != nil && r.Type == "Org" { + organizations = append(organizations, r.Name) + } + } + + if err := d.Set("organization_logins", flattenStringList(organizations)); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseCostCenterOrganizationsDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + organizationsSet := d.Get("organization_logins").(*schema.Set) + organizations := expandStringSet(organizationsSet) + + if len(organizations) > 0 { + tflog.Info(ctx, "Removing all organizations from cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(organizations), + }) + + for _, batch := range chunkStringSlice(organizations, maxResourcesPerRequest) { + if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Organizations: batch}); diags.HasError() { + return diags + } + } + } + + return nil +} + +func resourceGithubEnterpriseCostCenterOrganizationsImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + enterpriseSlug, costCenterID, err := parseID2(d.Id()) + if err != nil { + return nil, fmt.Errorf("invalid import ID %q: expected format :", d.Id()) + } + + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return nil, err + } + if err := d.Set("cost_center_id", costCenterID); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} diff --git a/github/resource_github_enterprise_cost_center_organizations_test.go b/github/resource_github_enterprise_cost_center_organizations_test.go new file mode 100644 index 0000000000..7c6d36652e --- /dev/null +++ b/github/resource_github_enterprise_cost_center_organizations_test.go @@ -0,0 +1,97 @@ +package github + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccGithubEnterpriseCostCenterOrganizations(t *testing.T) { + orgLogin := os.Getenv("ENTERPRISE_TEST_ORGANIZATION") + if orgLogin == "" { + t.Skip("ENTERPRISE_TEST_ORGANIZATION not set") + } + + t.Run("manages organization assignments without error", func(t *testing.T) { + randomID := acctest.RandString(5) + + config := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + } + + resource "github_enterprise_cost_center_organizations" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + cost_center_id = github_enterprise_cost_center.test.id + organization_logins = [%q] + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID, orgLogin) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGithubEnterpriseCostCenterOrganizationsDestroy, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_cost_center_organizations.test", "enterprise_slug", testAccConf.enterpriseSlug), + resource.TestCheckResourceAttr("github_enterprise_cost_center_organizations.test", "organization_logins.#", "1"), + resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center_organizations.test", "organization_logins.*", orgLogin), + ), + }, + { + ResourceName: "github_enterprise_cost_center_organizations.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) +} + +func testAccCheckGithubEnterpriseCostCenterOrganizationsDestroy(s *terraform.State) error { + meta, err := getTestMeta() + if err != nil { + return err + } + client := meta.v3client + + for _, rs := range s.RootModule().Resources { + if rs.Type != "github_enterprise_cost_center_organizations" { + continue + } + + enterpriseSlug, costCenterID, err := parseID2(rs.Primary.ID) + if err != nil { + return err + } + + cc, _, err := client.Enterprise.GetCostCenter(context.Background(), enterpriseSlug, costCenterID) + if errIs404(err) { + return nil + } + if err != nil { + return err + } + + // Check if organizations are still assigned + for _, resource := range cc.Resources { + if resource.Type == "organization" { + return fmt.Errorf("cost center %s still has organization assignments", costCenterID) + } + } + } + + return nil +} diff --git a/website/docs/r/enterprise_cost_center_organizations.html.markdown b/website/docs/r/enterprise_cost_center_organizations.html.markdown new file mode 100644 index 0000000000..1ca1f7b06d --- /dev/null +++ b/website/docs/r/enterprise_cost_center_organizations.html.markdown @@ -0,0 +1,45 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_cost_center_organizations" +description: |- + Manages organization assignments to a GitHub enterprise cost center. +--- + +# github_enterprise_cost_center_organizations + +This resource manages organization assignments to a GitHub enterprise cost center. + +~> **Note:** This resource is authoritative. It will manage the full set of organizations assigned to the cost center. To add organizations without affecting other assignments, you must include all desired organizations in the `organization_logins` set. + +## Example Usage + +```hcl +resource "github_enterprise_cost_center" "example" { + enterprise_slug = "example-enterprise" + name = "platform-cost-center" +} + +resource "github_enterprise_cost_center_organizations" "example" { + enterprise_slug = "example-enterprise" + cost_center_id = github_enterprise_cost_center.example.id + organization_logins = ["octo-org", "acme-corp"] +} +``` + +## Argument Reference + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `cost_center_id` - (Required) The ID of the cost center. +* `organization_logins` - (Required) Set of organization logins to assign to the cost center. Must contain at least one organization. + +## Attributes Reference + +This resource exports no additional attributes. + +## Import + +GitHub Enterprise Cost Center Organization assignments can be imported using the `enterprise_slug` and the `cost_center_id`, separated by a `:` character. + +``` +$ terraform import github_enterprise_cost_center_organizations.example example-enterprise: +``` From 790d3f9fbf518c84d3027a4d0264a0ba9c2be04c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:11:52 +0100 Subject: [PATCH 06/33] feat(cost-centers): add github_enterprise_cost_center_repositories resource Authoritative management of repository assignments to cost centers. Includes: - Resource implementation with batched add/remove operations - Acceptance tests - Documentation --- ...hub_enterprise_cost_center_repositories.go | 193 ++++++++++++++++++ ...nterprise_cost_center_repositories_test.go | 97 +++++++++ ...ise_cost_center_repositories.html.markdown | 45 ++++ 3 files changed, 335 insertions(+) create mode 100644 github/resource_github_enterprise_cost_center_repositories.go create mode 100644 github/resource_github_enterprise_cost_center_repositories_test.go create mode 100644 website/docs/r/enterprise_cost_center_repositories.html.markdown diff --git a/github/resource_github_enterprise_cost_center_repositories.go b/github/resource_github_enterprise_cost_center_repositories.go new file mode 100644 index 0000000000..83fdd7d572 --- /dev/null +++ b/github/resource_github_enterprise_cost_center_repositories.go @@ -0,0 +1,193 @@ +package github + +import ( + "context" + "fmt" + + "github.com/google/go-github/v81/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubEnterpriseCostCenterRepositories() *schema.Resource { + return &schema.Resource{ + Description: "Manages repository assignments for a GitHub enterprise cost center (authoritative).", + CreateContext: resourceGithubEnterpriseCostCenterRepositoriesCreateOrUpdate, + ReadContext: resourceGithubEnterpriseCostCenterRepositoriesRead, + UpdateContext: resourceGithubEnterpriseCostCenterRepositoriesCreateOrUpdate, + DeleteContext: resourceGithubEnterpriseCostCenterRepositoriesDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubEnterpriseCostCenterRepositoriesImport, + }, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug of the enterprise.", + }, + "cost_center_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The ID of the cost center.", + }, + "repository_names": { + Type: schema.TypeSet, + Required: true, + MinItems: 1, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Repository names (full name, e.g. org/repo) to assign to the cost center. This is authoritative - repositories not in this set will be removed.", + }, + }, + } +} + +func resourceGithubEnterpriseCostCenterRepositoriesCreateOrUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + if d.Id() == "" { + id, err := buildID(enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + } + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + + currentRepos := make(map[string]bool) + for _, r := range cc.Resources { + if r != nil && r.Type == "Repo" { + currentRepos[r.Name] = true + } + } + + desiredReposSet := d.Get("repository_names").(*schema.Set) + desiredRepos := make(map[string]bool) + for _, repo := range desiredReposSet.List() { + desiredRepos[repo.(string)] = true + } + + var toAdd, toRemove []string + for repo := range desiredRepos { + if !currentRepos[repo] { + toAdd = append(toAdd, repo) + } + } + for repo := range currentRepos { + if !desiredRepos[repo] { + toRemove = append(toRemove, repo) + } + } + + if len(toRemove) > 0 { + tflog.Info(ctx, "Removing repositories from cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toRemove), + }) + + for _, batch := range chunkStringSlice(toRemove, maxResourcesPerRequest) { + if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Repositories: batch}); diags.HasError() { + return diags + } + } + } + + if len(toAdd) > 0 { + tflog.Info(ctx, "Adding repositories to cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toAdd), + }) + + for _, batch := range chunkStringSlice(toAdd, maxResourcesPerRequest) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Repositories: batch}); diags.HasError() { + return diags + } + } + } + + return resourceGithubEnterpriseCostCenterRepositoriesRead(ctx, d, meta) +} + +func resourceGithubEnterpriseCostCenterRepositoriesRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + if errIs404(err) { + tflog.Warn(ctx, "Cost center not found, removing from state", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + }) + d.SetId("") + return nil + } + return diag.FromErr(err) + } + + var repositories []string + for _, r := range cc.Resources { + if r != nil && r.Type == "Repo" { + repositories = append(repositories, r.Name) + } + } + + if err := d.Set("repository_names", flattenStringList(repositories)); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseCostCenterRepositoriesDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + repositoriesSet := d.Get("repository_names").(*schema.Set) + repositories := expandStringSet(repositoriesSet) + + if len(repositories) > 0 { + tflog.Info(ctx, "Removing all repositories from cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(repositories), + }) + + for _, batch := range chunkStringSlice(repositories, maxResourcesPerRequest) { + if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Repositories: batch}); diags.HasError() { + return diags + } + } + } + + return nil +} + +func resourceGithubEnterpriseCostCenterRepositoriesImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + enterpriseSlug, costCenterID, err := parseID2(d.Id()) + if err != nil { + return nil, fmt.Errorf("invalid import ID %q: expected format :", d.Id()) + } + + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return nil, err + } + if err := d.Set("cost_center_id", costCenterID); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} diff --git a/github/resource_github_enterprise_cost_center_repositories_test.go b/github/resource_github_enterprise_cost_center_repositories_test.go new file mode 100644 index 0000000000..c9c483ca75 --- /dev/null +++ b/github/resource_github_enterprise_cost_center_repositories_test.go @@ -0,0 +1,97 @@ +package github + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccGithubEnterpriseCostCenterRepositories(t *testing.T) { + repoName := os.Getenv("ENTERPRISE_TEST_REPOSITORY") + if repoName == "" { + t.Skip("ENTERPRISE_TEST_REPOSITORY not set") + } + + t.Run("manages repository assignments without error", func(t *testing.T) { + randomID := acctest.RandString(5) + + config := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + } + + resource "github_enterprise_cost_center_repositories" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + cost_center_id = github_enterprise_cost_center.test.id + repository_names = [%q] + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID, repoName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGithubEnterpriseCostCenterRepositoriesDestroy, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_cost_center_repositories.test", "enterprise_slug", testAccConf.enterpriseSlug), + resource.TestCheckResourceAttr("github_enterprise_cost_center_repositories.test", "repository_names.#", "1"), + resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center_repositories.test", "repository_names.*", repoName), + ), + }, + { + ResourceName: "github_enterprise_cost_center_repositories.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) +} + +func testAccCheckGithubEnterpriseCostCenterRepositoriesDestroy(s *terraform.State) error { + meta, err := getTestMeta() + if err != nil { + return err + } + client := meta.v3client + + for _, rs := range s.RootModule().Resources { + if rs.Type != "github_enterprise_cost_center_repositories" { + continue + } + + enterpriseSlug, costCenterID, err := parseID2(rs.Primary.ID) + if err != nil { + return err + } + + cc, _, err := client.Enterprise.GetCostCenter(context.Background(), enterpriseSlug, costCenterID) + if errIs404(err) { + return nil + } + if err != nil { + return err + } + + // Check if repositories are still assigned + for _, resource := range cc.Resources { + if resource.Type == "repository" { + return fmt.Errorf("cost center %s still has repository assignments", costCenterID) + } + } + } + + return nil +} diff --git a/website/docs/r/enterprise_cost_center_repositories.html.markdown b/website/docs/r/enterprise_cost_center_repositories.html.markdown new file mode 100644 index 0000000000..dc1767f8a2 --- /dev/null +++ b/website/docs/r/enterprise_cost_center_repositories.html.markdown @@ -0,0 +1,45 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_cost_center_repositories" +description: |- + Manages repository assignments to a GitHub enterprise cost center. +--- + +# github_enterprise_cost_center_repositories + +This resource manages repository assignments to a GitHub enterprise cost center. + +~> **Note:** This resource is authoritative. It will manage the full set of repositories assigned to the cost center. To add repositories without affecting other assignments, you must include all desired repositories in the `repository_names` set. + +## Example Usage + +```hcl +resource "github_enterprise_cost_center" "example" { + enterprise_slug = "example-enterprise" + name = "platform-cost-center" +} + +resource "github_enterprise_cost_center_repositories" "example" { + enterprise_slug = "example-enterprise" + cost_center_id = github_enterprise_cost_center.example.id + repository_names = ["octo-org/my-app", "acme-corp/backend-service"] +} +``` + +## Argument Reference + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `cost_center_id` - (Required) The ID of the cost center. +* `repository_names` - (Required) Set of repository names (in `owner/repo` format) to assign to the cost center. Must contain at least one repository. + +## Attributes Reference + +This resource exports no additional attributes. + +## Import + +GitHub Enterprise Cost Center Repository assignments can be imported using the `enterprise_slug` and the `cost_center_id`, separated by a `:` character. + +``` +$ terraform import github_enterprise_cost_center_repositories.example example-enterprise: +``` From 0169707437c42b5e4d124ee6abc02d07cbd3d773 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:13:27 +0100 Subject: [PATCH 07/33] feat(cost-centers): add data sources for enterprise cost centers Add two data sources: - github_enterprise_cost_center: retrieve a cost center by ID - github_enterprise_cost_centers: list cost centers with optional state filter Includes: - Data source implementations - Acceptance tests - Documentation --- ...ta_source_github_enterprise_cost_center.go | 114 ++++++++++++++++++ ...urce_github_enterprise_cost_center_test.go | 40 ++++++ ...a_source_github_enterprise_cost_centers.go | 101 ++++++++++++++++ ...rce_github_enterprise_cost_centers_test.go | 42 +++++++ .../d/enterprise_cost_center.html.markdown | 34 ++++++ .../d/enterprise_cost_centers.html.markdown | 33 +++++ 6 files changed, 364 insertions(+) create mode 100644 github/data_source_github_enterprise_cost_center.go create mode 100644 github/data_source_github_enterprise_cost_center_test.go create mode 100644 github/data_source_github_enterprise_cost_centers.go create mode 100644 github/data_source_github_enterprise_cost_centers_test.go create mode 100644 website/docs/d/enterprise_cost_center.html.markdown create mode 100644 website/docs/d/enterprise_cost_centers.html.markdown diff --git a/github/data_source_github_enterprise_cost_center.go b/github/data_source_github_enterprise_cost_center.go new file mode 100644 index 0000000000..195e2831cd --- /dev/null +++ b/github/data_source_github_enterprise_cost_center.go @@ -0,0 +1,114 @@ +package github + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubEnterpriseCostCenter() *schema.Resource { + return &schema.Resource{ + Description: "Use this data source to retrieve information about a specific enterprise cost center.", + ReadContext: dataSourceGithubEnterpriseCostCenterRead, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + }, + "cost_center_id": { + Type: schema.TypeString, + Required: true, + Description: "The ID of the cost center.", + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the cost center.", + }, + "state": { + Type: schema.TypeString, + Computed: true, + Description: "The state of the cost center.", + }, + "azure_subscription": { + Type: schema.TypeString, + Computed: true, + Description: "The Azure subscription associated with the cost center.", + }, + "users": { + Type: schema.TypeSet, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The usernames assigned to this cost center.", + }, + "organizations": { + Type: schema.TypeSet, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The organization logins assigned to this cost center.", + }, + "repositories": { + Type: schema.TypeSet, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The repositories (full name) assigned to this cost center.", + }, + }, + } +} + +func dataSourceGithubEnterpriseCostCenterRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(costCenterID) + if err := d.Set("name", cc.Name); err != nil { + return diag.FromErr(err) + } + + if err := d.Set("state", cc.GetState()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("azure_subscription", cc.GetAzureSubscription()); err != nil { + return diag.FromErr(err) + } + + // Extract resources by type + users := make([]string, 0) + organizations := make([]string, 0) + repositories := make([]string, 0) + for _, resource := range cc.Resources { + if resource == nil { + continue + } + switch resource.Type { + case "User": + users = append(users, resource.Name) + case "Org": + organizations = append(organizations, resource.Name) + case "Repo": + repositories = append(repositories, resource.Name) + } + } + + if err := d.Set("users", users); err != nil { + return diag.FromErr(err) + } + if err := d.Set("organizations", organizations); err != nil { + return diag.FromErr(err) + } + if err := d.Set("repositories", repositories); err != nil { + return diag.FromErr(err) + } + + return nil +} diff --git a/github/data_source_github_enterprise_cost_center_test.go b/github/data_source_github_enterprise_cost_center_test.go new file mode 100644 index 0000000000..fb1f823527 --- /dev/null +++ b/github/data_source_github_enterprise_cost_center_test.go @@ -0,0 +1,40 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccGithubEnterpriseCostCenterDataSource(t *testing.T) { + randomID := acctest.RandString(5) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{{ + Config: fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + } + + data "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + cost_center_id = github_enterprise_cost_center.test.id + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair("data.github_enterprise_cost_center.test", "cost_center_id", "github_enterprise_cost_center.test", "id"), + resource.TestCheckResourceAttrPair("data.github_enterprise_cost_center.test", "name", "github_enterprise_cost_center.test", "name"), + resource.TestCheckResourceAttr("data.github_enterprise_cost_center.test", "state", "active"), + ), + }}, + }) +} diff --git a/github/data_source_github_enterprise_cost_centers.go b/github/data_source_github_enterprise_cost_centers.go new file mode 100644 index 0000000000..664e2d952b --- /dev/null +++ b/github/data_source_github_enterprise_cost_centers.go @@ -0,0 +1,101 @@ +package github + +import ( + "context" + + "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 dataSourceGithubEnterpriseCostCenters() *schema.Resource { + return &schema.Resource{ + Description: "Use this data source to retrieve a list of enterprise cost centers.", + ReadContext: dataSourceGithubEnterpriseCostCentersRead, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + }, + "state": { + Type: schema.TypeString, + Optional: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"active", "deleted"}, false)), + Description: "Filter cost centers by state.", + }, + "cost_centers": { + Type: schema.TypeSet, + Computed: true, + Description: "The list of cost centers.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeString, + Computed: true, + Description: "The cost center ID.", + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the cost center.", + }, + "state": { + Type: schema.TypeString, + Computed: true, + Description: "The state of the cost center.", + }, + "azure_subscription": { + Type: schema.TypeString, + Computed: true, + Description: "The Azure subscription associated with the cost center.", + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubEnterpriseCostCentersRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + var state *string + if v, ok := d.GetOk("state"); ok { + state = github.Ptr(v.(string)) + } + + result, _, err := client.Enterprise.ListCostCenters(ctx, enterpriseSlug, &github.ListCostCenterOptions{State: state}) + if err != nil { + return diag.FromErr(err) + } + + items := make([]any, 0, len(result.CostCenters)) + for _, cc := range result.CostCenters { + if cc == nil { + continue + } + items = append(items, map[string]any{ + "id": cc.ID, + "name": cc.Name, + "state": cc.GetState(), + "azure_subscription": cc.GetAzureSubscription(), + }) + } + + stateStr := "all" + if state != nil { + stateStr = *state + } + id, err := buildID(enterpriseSlug, stateStr) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + if err := d.Set("cost_centers", items); err != nil { + return diag.FromErr(err) + } + return nil +} diff --git a/github/data_source_github_enterprise_cost_centers_test.go b/github/data_source_github_enterprise_cost_centers_test.go new file mode 100644 index 0000000000..b8548c9c27 --- /dev/null +++ b/github/data_source_github_enterprise_cost_centers_test.go @@ -0,0 +1,42 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccGithubEnterpriseCostCentersDataSource(t *testing.T) { + randomID := acctest.RandString(5) + + config := fmt.Sprintf(` + data "github_enterprise" "enterprise" { + slug = "%s" + } + + resource "github_enterprise_cost_center" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + name = "%s%s" + } + + data "github_enterprise_cost_centers" "test" { + enterprise_slug = data.github_enterprise.enterprise.slug + state = "active" + depends_on = [github_enterprise_cost_center.test] + } + `, testAccConf.enterpriseSlug, testResourcePrefix, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessEnterprise(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{{ + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.github_enterprise_cost_centers.test", "state", "active"), + resource.TestCheckTypeSetElemAttrPair("data.github_enterprise_cost_centers.test", "cost_centers.*.id", "github_enterprise_cost_center.test", "id"), + ), + }}, + }) +} diff --git a/website/docs/d/enterprise_cost_center.html.markdown b/website/docs/d/enterprise_cost_center.html.markdown new file mode 100644 index 0000000000..d168b8a523 --- /dev/null +++ b/website/docs/d/enterprise_cost_center.html.markdown @@ -0,0 +1,34 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_cost_center" +description: |- + Get a GitHub enterprise cost center by ID. +--- + +# github_enterprise_cost_center + +Use this data source to retrieve a GitHub enterprise cost center by ID. + +## Example Usage + +``` +data "github_enterprise_cost_center" "example" { + enterprise_slug = "example-enterprise" + cost_center_id = "cc_123456" +} +``` + +## Argument Reference + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `cost_center_id` - (Required) The ID of the cost center. + +## Attributes Reference + +* `name` - The name of the cost center. +* `state` - The state of the cost center. +* `azure_subscription` - The Azure subscription associated with the cost center. +* `users` - The usernames currently assigned to the cost center. +* `organizations` - The organization logins currently assigned to the cost center. +* `repositories` - The repositories currently assigned to the cost center. + diff --git a/website/docs/d/enterprise_cost_centers.html.markdown b/website/docs/d/enterprise_cost_centers.html.markdown new file mode 100644 index 0000000000..f7fa7a66ef --- /dev/null +++ b/website/docs/d/enterprise_cost_centers.html.markdown @@ -0,0 +1,33 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_cost_centers" +description: |- + List GitHub enterprise cost centers. +--- + +# github_enterprise_cost_centers + +Use this data source to list GitHub enterprise cost centers. + +## Example Usage + +``` +data "github_enterprise_cost_centers" "active" { + enterprise_slug = "example-enterprise" + state = "active" +} +``` + +## Argument Reference + +* `enterprise_slug` - (Required) The slug of the enterprise. +* `state` - (Optional) Filter cost centers by state. Valid values are `active` and `deleted`. + +## Attributes Reference + +* `cost_centers` - A set of cost centers. + * `id` - The cost center ID. + * `name` - The name of the cost center. + * `state` - The state of the cost center. + * `azure_subscription` - The Azure subscription associated with the cost center. + From 70ffa0e632f6ec1fbbcbf1ade922af2c1b31a66f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:14:11 +0100 Subject: [PATCH 08/33] feat(cost-centers): register cost center resources and data sources - Register 4 resources in provider.go: - github_enterprise_cost_center - github_enterprise_cost_center_users - github_enterprise_cost_center_organizations - github_enterprise_cost_center_repositories - Register 2 data sources in provider.go: - github_enterprise_cost_center - github_enterprise_cost_centers - Add navigation links in website/github.erb T_EDITOR=true git rebase --continue t status --- github/provider.go | 6 ++++++ website/github.erb | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/github/provider.go b/github/provider.go index 2d5d6dc33a..c08f52a3b8 100644 --- a/github/provider.go +++ b/github/provider.go @@ -217,6 +217,10 @@ func Provider() *schema.Provider { "github_enterprise_actions_workflow_permissions": resourceGithubEnterpriseActionsWorkflowPermissions(), "github_actions_organization_workflow_permissions": resourceGithubActionsOrganizationWorkflowPermissions(), "github_enterprise_security_analysis_settings": resourceGithubEnterpriseSecurityAnalysisSettings(), + "github_enterprise_cost_center": resourceGithubEnterpriseCostCenter(), + "github_enterprise_cost_center_users": resourceGithubEnterpriseCostCenterUsers(), + "github_enterprise_cost_center_organizations": resourceGithubEnterpriseCostCenterOrganizations(), + "github_enterprise_cost_center_repositories": resourceGithubEnterpriseCostCenterRepositories(), "github_workflow_repository_permissions": resourceGithubWorkflowRepositoryPermissions(), }, @@ -294,6 +298,8 @@ func Provider() *schema.Provider { "github_user_external_identity": dataSourceGithubUserExternalIdentity(), "github_users": dataSourceGithubUsers(), "github_enterprise": dataSourceGithubEnterprise(), + "github_enterprise_cost_center": dataSourceGithubEnterpriseCostCenter(), + "github_enterprise_cost_centers": dataSourceGithubEnterpriseCostCenters(), "github_repository_environment_deployment_policies": dataSourceGithubRepositoryEnvironmentDeploymentPolicies(), }, } diff --git a/website/github.erb b/website/github.erb index 997536b42f..de41097597 100644 --- a/website/github.erb +++ b/website/github.erb @@ -100,6 +100,12 @@
  • github_enterprise
  • +
  • + github_enterprise_cost_center +
  • +
  • + github_enterprise_cost_centers +
  • github_external_groups
  • @@ -307,6 +313,9 @@
  • github_enterprise_actions_permissions
  • +
  • + github_enterprise_cost_center +
  • github_enterprise_organization
  • From fd7ee76286d2d2d7aeeb620a5285fa9c03c79039 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:14:21 +0100 Subject: [PATCH 09/33] docs(cost-centers): add usage example Add example Terraform configuration demonstrating how to: - Create a cost center - Assign users, organizations, and repositories - Use data sources to query cost centers --- examples/cost_centers/main.tf | 115 ++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 examples/cost_centers/main.tf diff --git a/examples/cost_centers/main.tf b/examples/cost_centers/main.tf new file mode 100644 index 0000000000..9c5d505fa7 --- /dev/null +++ b/examples/cost_centers/main.tf @@ -0,0 +1,115 @@ +terraform { + required_providers { + github = { + source = "integrations/github" + version = "~> 6.11" + } + } +} + +provider "github" { + token = var.github_token + owner = var.enterprise_slug +} + +variable "github_token" { + description = "GitHub classic personal access token (PAT) for an enterprise admin" + type = string + sensitive = true +} + +variable "enterprise_slug" { + description = "The GitHub Enterprise slug" + type = string +} + +variable "cost_center_name" { + description = "Name for the cost center" + type = string +} + +variable "users" { + description = "Usernames to assign to the cost center" + type = list(string) + default = [] +} + +variable "organizations" { + description = "Organization logins to assign to the cost center" + type = list(string) + default = [] +} + +variable "repositories" { + description = "Repositories (full name, e.g. org/repo) to assign to the cost center" + type = list(string) + default = [] +} + +# The cost center resource manages only the cost center entity itself. +resource "github_enterprise_cost_center" "example" { + enterprise_slug = var.enterprise_slug + name = var.cost_center_name +} + +# Use separate authoritative resources for assignments. +# These are optional - only create them if you have items to assign. + +resource "github_enterprise_cost_center_users" "example" { + count = length(var.users) > 0 ? 1 : 0 + + enterprise_slug = var.enterprise_slug + cost_center_id = github_enterprise_cost_center.example.id + usernames = var.users +} + +resource "github_enterprise_cost_center_organizations" "example" { + count = length(var.organizations) > 0 ? 1 : 0 + + enterprise_slug = var.enterprise_slug + cost_center_id = github_enterprise_cost_center.example.id + organization_logins = var.organizations +} + +resource "github_enterprise_cost_center_repositories" "example" { + count = length(var.repositories) > 0 ? 1 : 0 + + enterprise_slug = var.enterprise_slug + cost_center_id = github_enterprise_cost_center.example.id + repository_names = var.repositories +} + +# Data sources for reading cost center information +data "github_enterprise_cost_center" "by_id" { + enterprise_slug = var.enterprise_slug + cost_center_id = github_enterprise_cost_center.example.id +} + +data "github_enterprise_cost_centers" "active" { + enterprise_slug = var.enterprise_slug + state = "active" + + depends_on = [github_enterprise_cost_center.example] +} + +output "cost_center" { + description = "Created cost center" + value = { + id = github_enterprise_cost_center.example.id + name = github_enterprise_cost_center.example.name + state = github_enterprise_cost_center.example.state + azure_subscription = github_enterprise_cost_center.example.azure_subscription + } +} + +output "cost_center_from_data_source" { + description = "Cost center fetched by data source (includes all assignments)" + value = { + id = data.github_enterprise_cost_center.by_id.cost_center_id + name = data.github_enterprise_cost_center.by_id.name + state = data.github_enterprise_cost_center.by_id.state + users = sort(tolist(data.github_enterprise_cost_center.by_id.users)) + organizations = sort(tolist(data.github_enterprise_cost_center.by_id.organizations)) + repositories = sort(tolist(data.github_enterprise_cost_center.by_id.repositories)) + } +} From b381d9633cdd5cb9246c56c96f0530995a160df2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:16:02 +0100 Subject: [PATCH 10/33] fix(cost-centers): update go-github import to v82 Update import paths from go-github/v81 to go-github/v82 to match the current version in upstream/main. --- github/data_source_github_enterprise_cost_centers.go | 2 +- github/resource_github_enterprise_cost_center.go | 2 +- github/resource_github_enterprise_cost_center_organizations.go | 2 +- github/resource_github_enterprise_cost_center_repositories.go | 2 +- github/resource_github_enterprise_cost_center_users.go | 2 +- github/util_enterprise_cost_center.go | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/github/data_source_github_enterprise_cost_centers.go b/github/data_source_github_enterprise_cost_centers.go index 664e2d952b..f220c89755 100644 --- a/github/data_source_github_enterprise_cost_centers.go +++ b/github/data_source_github_enterprise_cost_centers.go @@ -3,7 +3,7 @@ package github import ( "context" - "github.com/google/go-github/v81/github" + "github.com/google/go-github/v82/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" diff --git a/github/resource_github_enterprise_cost_center.go b/github/resource_github_enterprise_cost_center.go index 7cae70355f..d31c8d8d7b 100644 --- a/github/resource_github_enterprise_cost_center.go +++ b/github/resource_github_enterprise_cost_center.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/google/go-github/v81/github" + "github.com/google/go-github/v82/github" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" diff --git a/github/resource_github_enterprise_cost_center_organizations.go b/github/resource_github_enterprise_cost_center_organizations.go index 2f9de126ed..a407c78962 100644 --- a/github/resource_github_enterprise_cost_center_organizations.go +++ b/github/resource_github_enterprise_cost_center_organizations.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/google/go-github/v81/github" + "github.com/google/go-github/v82/github" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" diff --git a/github/resource_github_enterprise_cost_center_repositories.go b/github/resource_github_enterprise_cost_center_repositories.go index 83fdd7d572..e4963bd8ed 100644 --- a/github/resource_github_enterprise_cost_center_repositories.go +++ b/github/resource_github_enterprise_cost_center_repositories.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/google/go-github/v81/github" + "github.com/google/go-github/v82/github" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" diff --git a/github/resource_github_enterprise_cost_center_users.go b/github/resource_github_enterprise_cost_center_users.go index 669dbf3a03..904f250645 100644 --- a/github/resource_github_enterprise_cost_center_users.go +++ b/github/resource_github_enterprise_cost_center_users.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/google/go-github/v81/github" + "github.com/google/go-github/v82/github" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" diff --git a/github/util_enterprise_cost_center.go b/github/util_enterprise_cost_center.go index 98f5b63753..569e57ad0b 100644 --- a/github/util_enterprise_cost_center.go +++ b/github/util_enterprise_cost_center.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/google/go-github/v81/github" + "github.com/google/go-github/v82/github" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" ) From 841d1e9e6cbf214af7bb6d481f084d75e9e043fb Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Mon, 9 Feb 2026 20:34:04 +0100 Subject: [PATCH 11/33] fix: removed linter config, it shouldn't be needed Co-authored-by: Timo Sand --- github/util.go | 1 - 1 file changed, 1 deletion(-) diff --git a/github/util.go b/github/util.go index dcb3dfddcd..850653e3a2 100644 --- a/github/util.go +++ b/github/util.go @@ -304,7 +304,6 @@ func expandStringSet(set *schema.Set) []string { // chunkStringSlice splits a slice into chunks of the specified max size. // -//nolint:unparam // maxSize is parameterized for reusability across different contexts func chunkStringSlice(items []string, maxSize int) [][]string { if len(items) == 0 { return nil From 78d73aea7011ac9c4bc7855c41ee1d382a430a1c Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Mon, 9 Feb 2026 20:57:54 +0100 Subject: [PATCH 12/33] fix(cost-centers): remove unnecessary expandStringSet function Remove expandStringSet from util.go and replace usages with direct expandStringList(set.List()) calls. The function was unnecessary since schema.Set from d.Get() is never nil. Resolves PR comments #1-2. --- ...source_github_enterprise_cost_center_organizations.go | 2 +- ...esource_github_enterprise_cost_center_repositories.go | 2 +- github/resource_github_enterprise_cost_center_users.go | 2 +- github/util.go | 9 --------- 4 files changed, 3 insertions(+), 12 deletions(-) diff --git a/github/resource_github_enterprise_cost_center_organizations.go b/github/resource_github_enterprise_cost_center_organizations.go index a407c78962..e53d3fecf2 100644 --- a/github/resource_github_enterprise_cost_center_organizations.go +++ b/github/resource_github_enterprise_cost_center_organizations.go @@ -157,7 +157,7 @@ func resourceGithubEnterpriseCostCenterOrganizationsDelete(ctx context.Context, costCenterID := d.Get("cost_center_id").(string) organizationsSet := d.Get("organization_logins").(*schema.Set) - organizations := expandStringSet(organizationsSet) + organizations := expandStringList(organizationsSet.List()) if len(organizations) > 0 { tflog.Info(ctx, "Removing all organizations from cost center", map[string]any{ diff --git a/github/resource_github_enterprise_cost_center_repositories.go b/github/resource_github_enterprise_cost_center_repositories.go index e4963bd8ed..cc2bd0f622 100644 --- a/github/resource_github_enterprise_cost_center_repositories.go +++ b/github/resource_github_enterprise_cost_center_repositories.go @@ -157,7 +157,7 @@ func resourceGithubEnterpriseCostCenterRepositoriesDelete(ctx context.Context, d costCenterID := d.Get("cost_center_id").(string) repositoriesSet := d.Get("repository_names").(*schema.Set) - repositories := expandStringSet(repositoriesSet) + repositories := expandStringList(repositoriesSet.List()) if len(repositories) > 0 { tflog.Info(ctx, "Removing all repositories from cost center", map[string]any{ diff --git a/github/resource_github_enterprise_cost_center_users.go b/github/resource_github_enterprise_cost_center_users.go index 904f250645..a3f6818e56 100644 --- a/github/resource_github_enterprise_cost_center_users.go +++ b/github/resource_github_enterprise_cost_center_users.go @@ -165,7 +165,7 @@ func resourceGithubEnterpriseCostCenterUsersDelete(ctx context.Context, d *schem costCenterID := d.Get("cost_center_id").(string) usernamesSet := d.Get("usernames").(*schema.Set) - usernames := expandStringSet(usernamesSet) + usernames := expandStringList(usernamesSet.List()) if len(usernames) > 0 { tflog.Info(ctx, "Removing all users from cost center", map[string]any{ diff --git a/github/util.go b/github/util.go index 850653e3a2..173b949394 100644 --- a/github/util.go +++ b/github/util.go @@ -293,15 +293,6 @@ func errIsRetryable(err error) bool { return false } -// expandStringSet converts a schema.Set to a string slice. -func expandStringSet(set *schema.Set) []string { - if set == nil { - return nil - } - list := set.List() - return expandStringList(list) -} - // chunkStringSlice splits a slice into chunks of the specified max size. // func chunkStringSlice(items []string, maxSize int) [][]string { From 4396ca431ec342750ac71429373326e05b5b173d Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Mon, 9 Feb 2026 21:01:09 +0100 Subject: [PATCH 13/33] fix(cost-centers): check deleted state in Read instead of Update Move the archived/deleted state check from Update to Read function. If the cost center is archived (deleted), it will be removed from Terraform state during Read rather than blocking updates. Resolves PR comment #3. --- github/resource_github_enterprise_cost_center.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/github/resource_github_enterprise_cost_center.go b/github/resource_github_enterprise_cost_center.go index d31c8d8d7b..add0633f5f 100644 --- a/github/resource_github_enterprise_cost_center.go +++ b/github/resource_github_enterprise_cost_center.go @@ -97,6 +97,16 @@ func resourceGithubEnterpriseCostCenterRead(ctx context.Context, d *schema.Resou return diag.FromErr(err) } + // If the cost center is archived (deleted), remove from state + if cc.GetState() == "deleted" { + tflog.Warn(ctx, "Cost center is archived, removing from state", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + }) + d.SetId("") + return nil + } + if err := d.Set("name", cc.Name); err != nil { return diag.FromErr(err) } @@ -116,12 +126,6 @@ func resourceGithubEnterpriseCostCenterUpdate(ctx context.Context, d *schema.Res enterpriseSlug := d.Get("enterprise_slug").(string) costCenterID := d.Id() - // Check current state to prevent updates on archived cost centers - currentState := d.Get("state").(string) - if currentState == "deleted" { - return diag.Errorf("cannot update cost center %q because it is archived", costCenterID) - } - if d.HasChange("name") { name := d.Get("name").(string) tflog.Info(ctx, "Updating enterprise cost center name", map[string]any{ From 7210fb584c9a8c904b756909e3b180ce12897e08 Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Mon, 9 Feb 2026 21:04:56 +0100 Subject: [PATCH 14/33] fix(cost-centers): separate Create and Update for users resource Split resourceGithubEnterpriseCostCenterUsersCreateOrUpdate into separate Create and Update functions. Create only adds users, Update handles the full diff. Both return nil instead of calling Read. Resolves PR comments #4-5. --- ...rce_github_enterprise_cost_center_users.go | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/github/resource_github_enterprise_cost_center_users.go b/github/resource_github_enterprise_cost_center_users.go index a3f6818e56..96db4c03be 100644 --- a/github/resource_github_enterprise_cost_center_users.go +++ b/github/resource_github_enterprise_cost_center_users.go @@ -13,9 +13,9 @@ import ( func resourceGithubEnterpriseCostCenterUsers() *schema.Resource { return &schema.Resource{ Description: "Manages user assignments for a GitHub enterprise cost center (authoritative).", - CreateContext: resourceGithubEnterpriseCostCenterUsersCreateOrUpdate, + CreateContext: resourceGithubEnterpriseCostCenterUsersCreate, ReadContext: resourceGithubEnterpriseCostCenterUsersRead, - UpdateContext: resourceGithubEnterpriseCostCenterUsersCreateOrUpdate, + UpdateContext: resourceGithubEnterpriseCostCenterUsersUpdate, DeleteContext: resourceGithubEnterpriseCostCenterUsersDelete, Importer: &schema.ResourceImporter{ StateContext: resourceGithubEnterpriseCostCenterUsersImport, @@ -45,20 +45,44 @@ func resourceGithubEnterpriseCostCenterUsers() *schema.Resource { } } -func resourceGithubEnterpriseCostCenterUsersCreateOrUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { +func resourceGithubEnterpriseCostCenterUsersCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client enterpriseSlug := d.Get("enterprise_slug").(string) costCenterID := d.Get("cost_center_id").(string) - // If this is Create, set the ID - if d.Id() == "" { - id, err := buildID(enterpriseSlug, costCenterID) - if err != nil { - return diag.FromErr(err) + id, err := buildID(enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + + // Get desired users from config + desiredUsersSet := d.Get("usernames").(*schema.Set) + toAdd := expandStringList(desiredUsersSet.List()) + + // Add users + if len(toAdd) > 0 { + tflog.Info(ctx, "Adding users to cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toAdd), + }) + + for _, batch := range chunkStringSlice(toAdd, maxResourcesPerRequest) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Users: batch}); diags.HasError() { + return diags + } } - d.SetId(id) } + return nil +} + +func resourceGithubEnterpriseCostCenterUsersUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + // Get current assignments from API cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) if err != nil { @@ -123,7 +147,7 @@ func resourceGithubEnterpriseCostCenterUsersCreateOrUpdate(ctx context.Context, } } - return resourceGithubEnterpriseCostCenterUsersRead(ctx, d, meta) + return nil } func resourceGithubEnterpriseCostCenterUsersRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { From e479cb4ad5b724df7e6179279f7781dd97664f4a Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Mon, 9 Feb 2026 21:05:59 +0100 Subject: [PATCH 15/33] fix(cost-centers): separate Create and Update for organizations resource Split resourceGithubEnterpriseCostCenterOrganizationsCreateOrUpdate into separate Create and Update functions. Create only adds organizations, Update handles the full diff. Both return nil instead of calling Read. Resolves PR comments #6-7. --- ...ub_enterprise_cost_center_organizations.go | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/github/resource_github_enterprise_cost_center_organizations.go b/github/resource_github_enterprise_cost_center_organizations.go index e53d3fecf2..8dd1cabc4b 100644 --- a/github/resource_github_enterprise_cost_center_organizations.go +++ b/github/resource_github_enterprise_cost_center_organizations.go @@ -13,9 +13,9 @@ import ( func resourceGithubEnterpriseCostCenterOrganizations() *schema.Resource { return &schema.Resource{ Description: "Manages organization assignments for a GitHub enterprise cost center (authoritative).", - CreateContext: resourceGithubEnterpriseCostCenterOrganizationsCreateOrUpdate, + CreateContext: resourceGithubEnterpriseCostCenterOrganizationsCreate, ReadContext: resourceGithubEnterpriseCostCenterOrganizationsRead, - UpdateContext: resourceGithubEnterpriseCostCenterOrganizationsCreateOrUpdate, + UpdateContext: resourceGithubEnterpriseCostCenterOrganizationsUpdate, DeleteContext: resourceGithubEnterpriseCostCenterOrganizationsDelete, Importer: &schema.ResourceImporter{ StateContext: resourceGithubEnterpriseCostCenterOrganizationsImport, @@ -45,19 +45,44 @@ func resourceGithubEnterpriseCostCenterOrganizations() *schema.Resource { } } -func resourceGithubEnterpriseCostCenterOrganizationsCreateOrUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { +func resourceGithubEnterpriseCostCenterOrganizationsCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client enterpriseSlug := d.Get("enterprise_slug").(string) costCenterID := d.Get("cost_center_id").(string) - if d.Id() == "" { - id, err := buildID(enterpriseSlug, costCenterID) - if err != nil { - return diag.FromErr(err) + id, err := buildID(enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + + // Get desired organizations from config + desiredOrgsSet := d.Get("organization_logins").(*schema.Set) + toAdd := expandStringList(desiredOrgsSet.List()) + + // Add organizations + if len(toAdd) > 0 { + tflog.Info(ctx, "Adding organizations to cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toAdd), + }) + + for _, batch := range chunkStringSlice(toAdd, maxResourcesPerRequest) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Organizations: batch}); diags.HasError() { + return diags + } } - d.SetId(id) } + return nil +} + +func resourceGithubEnterpriseCostCenterOrganizationsUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) if err != nil { return diag.FromErr(err) @@ -116,7 +141,7 @@ func resourceGithubEnterpriseCostCenterOrganizationsCreateOrUpdate(ctx context.C } } - return resourceGithubEnterpriseCostCenterOrganizationsRead(ctx, d, meta) + return nil } func resourceGithubEnterpriseCostCenterOrganizationsRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { From c0a11496c9fc05e1a9e56dd17bbde0354b489a6a Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Mon, 9 Feb 2026 21:06:50 +0100 Subject: [PATCH 16/33] fix(cost-centers): separate Create and Update for repositories resource Split resourceGithubEnterpriseCostCenterRepositoriesCreateOrUpdate into separate Create and Update functions. Create only adds repositories, Update handles the full diff. Both return nil instead of calling Read. Resolves PR comments #8-9. --- ...hub_enterprise_cost_center_repositories.go | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/github/resource_github_enterprise_cost_center_repositories.go b/github/resource_github_enterprise_cost_center_repositories.go index cc2bd0f622..bb856492fd 100644 --- a/github/resource_github_enterprise_cost_center_repositories.go +++ b/github/resource_github_enterprise_cost_center_repositories.go @@ -13,9 +13,9 @@ import ( func resourceGithubEnterpriseCostCenterRepositories() *schema.Resource { return &schema.Resource{ Description: "Manages repository assignments for a GitHub enterprise cost center (authoritative).", - CreateContext: resourceGithubEnterpriseCostCenterRepositoriesCreateOrUpdate, + CreateContext: resourceGithubEnterpriseCostCenterRepositoriesCreate, ReadContext: resourceGithubEnterpriseCostCenterRepositoriesRead, - UpdateContext: resourceGithubEnterpriseCostCenterRepositoriesCreateOrUpdate, + UpdateContext: resourceGithubEnterpriseCostCenterRepositoriesUpdate, DeleteContext: resourceGithubEnterpriseCostCenterRepositoriesDelete, Importer: &schema.ResourceImporter{ StateContext: resourceGithubEnterpriseCostCenterRepositoriesImport, @@ -45,19 +45,44 @@ func resourceGithubEnterpriseCostCenterRepositories() *schema.Resource { } } -func resourceGithubEnterpriseCostCenterRepositoriesCreateOrUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { +func resourceGithubEnterpriseCostCenterRepositoriesCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client enterpriseSlug := d.Get("enterprise_slug").(string) costCenterID := d.Get("cost_center_id").(string) - if d.Id() == "" { - id, err := buildID(enterpriseSlug, costCenterID) - if err != nil { - return diag.FromErr(err) + id, err := buildID(enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + + // Get desired repositories from config + desiredReposSet := d.Get("repository_names").(*schema.Set) + toAdd := expandStringList(desiredReposSet.List()) + + // Add repositories + if len(toAdd) > 0 { + tflog.Info(ctx, "Adding repositories to cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toAdd), + }) + + for _, batch := range chunkStringSlice(toAdd, maxResourcesPerRequest) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Repositories: batch}); diags.HasError() { + return diags + } } - d.SetId(id) } + return nil +} + +func resourceGithubEnterpriseCostCenterRepositoriesUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + enterpriseSlug := d.Get("enterprise_slug").(string) + costCenterID := d.Get("cost_center_id").(string) + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) if err != nil { return diag.FromErr(err) @@ -116,7 +141,7 @@ func resourceGithubEnterpriseCostCenterRepositoriesCreateOrUpdate(ctx context.Co } } - return resourceGithubEnterpriseCostCenterRepositoriesRead(ctx, d, meta) + return nil } func resourceGithubEnterpriseCostCenterRepositoriesRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { From e4d5a68a96dc993a3cb0a33a72d0cb8bd83a4fc6 Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Mon, 9 Feb 2026 21:21:33 +0100 Subject: [PATCH 17/33] fix(cost-centers): correct resource type strings in CheckDestroy functions The API returns type strings as 'User', 'Org', and 'Repo' but the tests were checking for lowercase 'user', 'organization', and 'repository'. This fix ensures CheckDestroy properly detects remaining assignments. --- ...resource_github_enterprise_cost_center_organizations_test.go | 2 +- .../resource_github_enterprise_cost_center_repositories_test.go | 2 +- github/resource_github_enterprise_cost_center_users_test.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/github/resource_github_enterprise_cost_center_organizations_test.go b/github/resource_github_enterprise_cost_center_organizations_test.go index 7c6d36652e..5fd5a37780 100644 --- a/github/resource_github_enterprise_cost_center_organizations_test.go +++ b/github/resource_github_enterprise_cost_center_organizations_test.go @@ -87,7 +87,7 @@ func testAccCheckGithubEnterpriseCostCenterOrganizationsDestroy(s *terraform.Sta // Check if organizations are still assigned for _, resource := range cc.Resources { - if resource.Type == "organization" { + if resource.Type == "Org" { return fmt.Errorf("cost center %s still has organization assignments", costCenterID) } } diff --git a/github/resource_github_enterprise_cost_center_repositories_test.go b/github/resource_github_enterprise_cost_center_repositories_test.go index c9c483ca75..3a490f447a 100644 --- a/github/resource_github_enterprise_cost_center_repositories_test.go +++ b/github/resource_github_enterprise_cost_center_repositories_test.go @@ -87,7 +87,7 @@ func testAccCheckGithubEnterpriseCostCenterRepositoriesDestroy(s *terraform.Stat // Check if repositories are still assigned for _, resource := range cc.Resources { - if resource.Type == "repository" { + if resource.Type == "Repo" { return fmt.Errorf("cost center %s still has repository assignments", costCenterID) } } diff --git a/github/resource_github_enterprise_cost_center_users_test.go b/github/resource_github_enterprise_cost_center_users_test.go index 2ab4bd5aa4..c207b0870f 100644 --- a/github/resource_github_enterprise_cost_center_users_test.go +++ b/github/resource_github_enterprise_cost_center_users_test.go @@ -82,7 +82,7 @@ func testAccCheckGithubEnterpriseCostCenterUsersDestroy(s *terraform.State) erro // Check if users are still assigned for _, resource := range cc.Resources { - if resource.Type == "user" { + if resource.Type == "User" { return fmt.Errorf("cost center %s still has user assignments", costCenterID) } } From 32fe45de41fc0012aa7646d7bc44983a0fda7e45 Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Mon, 9 Feb 2026 21:42:43 +0100 Subject: [PATCH 18/33] refactor(cost-centers): use type constants instead of magic strings Add CostCenterResourceType constants (User, Org, Repo) to avoid magic strings throughout the codebase. This prevents typos and makes the code more maintainable. Addresses review feedback from @deiga. --- github/data_source_github_enterprise_cost_center.go | 6 +++--- .../resource_github_enterprise_cost_center_organizations.go | 4 ++-- ...urce_github_enterprise_cost_center_organizations_test.go | 2 +- .../resource_github_enterprise_cost_center_repositories.go | 4 ++-- ...ource_github_enterprise_cost_center_repositories_test.go | 2 +- github/resource_github_enterprise_cost_center_users.go | 4 ++-- github/resource_github_enterprise_cost_center_users_test.go | 2 +- github/util_enterprise_cost_center.go | 5 +++++ 8 files changed, 17 insertions(+), 12 deletions(-) diff --git a/github/data_source_github_enterprise_cost_center.go b/github/data_source_github_enterprise_cost_center.go index 195e2831cd..1b94d1b558 100644 --- a/github/data_source_github_enterprise_cost_center.go +++ b/github/data_source_github_enterprise_cost_center.go @@ -91,11 +91,11 @@ func dataSourceGithubEnterpriseCostCenterRead(ctx context.Context, d *schema.Res continue } switch resource.Type { - case "User": + case CostCenterResourceTypeUser: users = append(users, resource.Name) - case "Org": + case CostCenterResourceTypeOrg: organizations = append(organizations, resource.Name) - case "Repo": + case CostCenterResourceTypeRepo: repositories = append(repositories, resource.Name) } } diff --git a/github/resource_github_enterprise_cost_center_organizations.go b/github/resource_github_enterprise_cost_center_organizations.go index 8dd1cabc4b..0a3a585333 100644 --- a/github/resource_github_enterprise_cost_center_organizations.go +++ b/github/resource_github_enterprise_cost_center_organizations.go @@ -90,7 +90,7 @@ func resourceGithubEnterpriseCostCenterOrganizationsUpdate(ctx context.Context, currentOrgs := make(map[string]bool) for _, r := range cc.Resources { - if r != nil && r.Type == "Org" { + if r != nil && r.Type == CostCenterResourceTypeOrg { currentOrgs[r.Name] = true } } @@ -164,7 +164,7 @@ func resourceGithubEnterpriseCostCenterOrganizationsRead(ctx context.Context, d var organizations []string for _, r := range cc.Resources { - if r != nil && r.Type == "Org" { + if r != nil && r.Type == CostCenterResourceTypeOrg { organizations = append(organizations, r.Name) } } diff --git a/github/resource_github_enterprise_cost_center_organizations_test.go b/github/resource_github_enterprise_cost_center_organizations_test.go index 5fd5a37780..96fd5e10f2 100644 --- a/github/resource_github_enterprise_cost_center_organizations_test.go +++ b/github/resource_github_enterprise_cost_center_organizations_test.go @@ -87,7 +87,7 @@ func testAccCheckGithubEnterpriseCostCenterOrganizationsDestroy(s *terraform.Sta // Check if organizations are still assigned for _, resource := range cc.Resources { - if resource.Type == "Org" { + if resource.Type == CostCenterResourceTypeOrg { return fmt.Errorf("cost center %s still has organization assignments", costCenterID) } } diff --git a/github/resource_github_enterprise_cost_center_repositories.go b/github/resource_github_enterprise_cost_center_repositories.go index bb856492fd..142049da6b 100644 --- a/github/resource_github_enterprise_cost_center_repositories.go +++ b/github/resource_github_enterprise_cost_center_repositories.go @@ -90,7 +90,7 @@ func resourceGithubEnterpriseCostCenterRepositoriesUpdate(ctx context.Context, d currentRepos := make(map[string]bool) for _, r := range cc.Resources { - if r != nil && r.Type == "Repo" { + if r != nil && r.Type == CostCenterResourceTypeRepo { currentRepos[r.Name] = true } } @@ -164,7 +164,7 @@ func resourceGithubEnterpriseCostCenterRepositoriesRead(ctx context.Context, d * var repositories []string for _, r := range cc.Resources { - if r != nil && r.Type == "Repo" { + if r != nil && r.Type == CostCenterResourceTypeRepo { repositories = append(repositories, r.Name) } } diff --git a/github/resource_github_enterprise_cost_center_repositories_test.go b/github/resource_github_enterprise_cost_center_repositories_test.go index 3a490f447a..1d0886f390 100644 --- a/github/resource_github_enterprise_cost_center_repositories_test.go +++ b/github/resource_github_enterprise_cost_center_repositories_test.go @@ -87,7 +87,7 @@ func testAccCheckGithubEnterpriseCostCenterRepositoriesDestroy(s *terraform.Stat // Check if repositories are still assigned for _, resource := range cc.Resources { - if resource.Type == "Repo" { + if resource.Type == CostCenterResourceTypeRepo { return fmt.Errorf("cost center %s still has repository assignments", costCenterID) } } diff --git a/github/resource_github_enterprise_cost_center_users.go b/github/resource_github_enterprise_cost_center_users.go index 96db4c03be..7eb5165bad 100644 --- a/github/resource_github_enterprise_cost_center_users.go +++ b/github/resource_github_enterprise_cost_center_users.go @@ -92,7 +92,7 @@ func resourceGithubEnterpriseCostCenterUsersUpdate(ctx context.Context, d *schem // Extract current users currentUsers := make(map[string]bool) for _, r := range cc.Resources { - if r != nil && r.Type == "User" { + if r != nil && r.Type == CostCenterResourceTypeUser { currentUsers[r.Name] = true } } @@ -171,7 +171,7 @@ func resourceGithubEnterpriseCostCenterUsersRead(ctx context.Context, d *schema. // Extract users from resources var users []string for _, r := range cc.Resources { - if r != nil && r.Type == "User" { + if r != nil && r.Type == CostCenterResourceTypeUser { users = append(users, r.Name) } } diff --git a/github/resource_github_enterprise_cost_center_users_test.go b/github/resource_github_enterprise_cost_center_users_test.go index c207b0870f..ba57fa3a04 100644 --- a/github/resource_github_enterprise_cost_center_users_test.go +++ b/github/resource_github_enterprise_cost_center_users_test.go @@ -82,7 +82,7 @@ func testAccCheckGithubEnterpriseCostCenterUsersDestroy(s *terraform.State) erro // Check if users are still assigned for _, resource := range cc.Resources { - if resource.Type == "User" { + if resource.Type == CostCenterResourceTypeUser { return fmt.Errorf("cost center %s still has user assignments", costCenterID) } } diff --git a/github/util_enterprise_cost_center.go b/github/util_enterprise_cost_center.go index 569e57ad0b..e4604e24c3 100644 --- a/github/util_enterprise_cost_center.go +++ b/github/util_enterprise_cost_center.go @@ -13,6 +13,11 @@ import ( const ( maxResourcesPerRequest = 50 costCenterResourcesRetryTimeout = 5 * time.Minute + + // CostCenterResourceType constants match the API response values. + CostCenterResourceTypeUser = "User" + CostCenterResourceTypeOrg = "Org" + CostCenterResourceTypeRepo = "Repo" ) // retryCostCenterRemoveResources removes resources from a cost center with retry logic. From fda96bf9f4f2f42f351d8fb3360537309e07a2fb Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Sat, 14 Feb 2026 11:16:51 +0100 Subject: [PATCH 19/33] fix(cost-centers): use terraform-plugin-testing imports in test files Replace terraform-plugin-sdk/v2 test imports with terraform-plugin-testing to fix flag redefinition conflict ('sweep' flag registered twice). This aligns cost center tests with the rest of the codebase. --- github/data_source_github_enterprise_cost_center_test.go | 4 ++-- github/data_source_github_enterprise_cost_centers_test.go | 4 ++-- ...urce_github_enterprise_cost_center_organizations_test.go | 6 +++--- ...ource_github_enterprise_cost_center_repositories_test.go | 6 +++--- github/resource_github_enterprise_cost_center_test.go | 6 +++--- github/resource_github_enterprise_cost_center_users_test.go | 6 +++--- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/github/data_source_github_enterprise_cost_center_test.go b/github/data_source_github_enterprise_cost_center_test.go index fb1f823527..34f2a78063 100644 --- a/github/data_source_github_enterprise_cost_center_test.go +++ b/github/data_source_github_enterprise_cost_center_test.go @@ -4,8 +4,8 @@ import ( "fmt" "testing" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) func TestAccGithubEnterpriseCostCenterDataSource(t *testing.T) { diff --git a/github/data_source_github_enterprise_cost_centers_test.go b/github/data_source_github_enterprise_cost_centers_test.go index b8548c9c27..e2ea0e7fca 100644 --- a/github/data_source_github_enterprise_cost_centers_test.go +++ b/github/data_source_github_enterprise_cost_centers_test.go @@ -4,8 +4,8 @@ import ( "fmt" "testing" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) func TestAccGithubEnterpriseCostCentersDataSource(t *testing.T) { diff --git a/github/resource_github_enterprise_cost_center_organizations_test.go b/github/resource_github_enterprise_cost_center_organizations_test.go index 96fd5e10f2..640024f80f 100644 --- a/github/resource_github_enterprise_cost_center_organizations_test.go +++ b/github/resource_github_enterprise_cost_center_organizations_test.go @@ -6,9 +6,9 @@ import ( "os" "testing" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" - "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" ) func TestAccGithubEnterpriseCostCenterOrganizations(t *testing.T) { diff --git a/github/resource_github_enterprise_cost_center_repositories_test.go b/github/resource_github_enterprise_cost_center_repositories_test.go index 1d0886f390..b5a26bd425 100644 --- a/github/resource_github_enterprise_cost_center_repositories_test.go +++ b/github/resource_github_enterprise_cost_center_repositories_test.go @@ -6,9 +6,9 @@ import ( "os" "testing" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" - "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" ) func TestAccGithubEnterpriseCostCenterRepositories(t *testing.T) { diff --git a/github/resource_github_enterprise_cost_center_test.go b/github/resource_github_enterprise_cost_center_test.go index b03bb889ed..118b1a1233 100644 --- a/github/resource_github_enterprise_cost_center_test.go +++ b/github/resource_github_enterprise_cost_center_test.go @@ -4,9 +4,9 @@ import ( "fmt" "testing" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" - "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" ) func TestAccGithubEnterpriseCostCenter(t *testing.T) { diff --git a/github/resource_github_enterprise_cost_center_users_test.go b/github/resource_github_enterprise_cost_center_users_test.go index ba57fa3a04..85e31be246 100644 --- a/github/resource_github_enterprise_cost_center_users_test.go +++ b/github/resource_github_enterprise_cost_center_users_test.go @@ -5,9 +5,9 @@ import ( "fmt" "testing" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" - "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" ) func TestAccGithubEnterpriseCostCenterUsers(t *testing.T) { From 196e4382a77fd26f06856b2fabec1a5735017c03 Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Tue, 17 Feb 2026 14:05:50 +0100 Subject: [PATCH 20/33] fix(cost-centers): document generic chunk helper lint exception Keep chunkStringSlice(items, maxSize) generic in util.go to avoid coupling with cost-center-specific constants. Add nolint:unparam with explicit rationale because current call sites pass the same value, while preserving future reuse for other resources. --- github/util.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/github/util.go b/github/util.go index 173b949394..ebc25152bc 100644 --- a/github/util.go +++ b/github/util.go @@ -294,7 +294,7 @@ func errIsRetryable(err error) bool { } // chunkStringSlice splits a slice into chunks of the specified max size. -// +// nolint:unparam // Keep maxSize for generic reuse across resources beyond current cost-center callers. func chunkStringSlice(items []string, maxSize int) [][]string { if len(items) == 0 { return nil From c38514d5ecf2343550f6891084814b52a2f118be Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Wed, 18 Feb 2026 09:06:25 +0100 Subject: [PATCH 21/33] fix: address review - rename single-char variables and remove unnecessary comments --- ...ta_source_github_enterprise_cost_center.go | 1 - .../resource_github_enterprise_cost_center.go | 1 - ...ub_enterprise_cost_center_organizations.go | 18 ++++++------- ...hub_enterprise_cost_center_repositories.go | 14 +++++------ ...rce_github_enterprise_cost_center_users.go | 25 ++++++------------- 5 files changed, 22 insertions(+), 37 deletions(-) diff --git a/github/data_source_github_enterprise_cost_center.go b/github/data_source_github_enterprise_cost_center.go index 1b94d1b558..0ec1c00a11 100644 --- a/github/data_source_github_enterprise_cost_center.go +++ b/github/data_source_github_enterprise_cost_center.go @@ -82,7 +82,6 @@ func dataSourceGithubEnterpriseCostCenterRead(ctx context.Context, d *schema.Res return diag.FromErr(err) } - // Extract resources by type users := make([]string, 0) organizations := make([]string, 0) repositories := make([]string, 0) diff --git a/github/resource_github_enterprise_cost_center.go b/github/resource_github_enterprise_cost_center.go index add0633f5f..fc6d86517b 100644 --- a/github/resource_github_enterprise_cost_center.go +++ b/github/resource_github_enterprise_cost_center.go @@ -68,7 +68,6 @@ func resourceGithubEnterpriseCostCenterCreate(ctx context.Context, d *schema.Res d.SetId(cc.ID) - // Set computed fields from the API response if err := d.Set("state", cc.GetState()); err != nil { return diag.FromErr(err) } diff --git a/github/resource_github_enterprise_cost_center_organizations.go b/github/resource_github_enterprise_cost_center_organizations.go index 0a3a585333..1ff31a25a3 100644 --- a/github/resource_github_enterprise_cost_center_organizations.go +++ b/github/resource_github_enterprise_cost_center_organizations.go @@ -56,11 +56,9 @@ func resourceGithubEnterpriseCostCenterOrganizationsCreate(ctx context.Context, } d.SetId(id) - // Get desired organizations from config desiredOrgsSet := d.Get("organization_logins").(*schema.Set) toAdd := expandStringList(desiredOrgsSet.List()) - // Add organizations if len(toAdd) > 0 { tflog.Info(ctx, "Adding organizations to cost center", map[string]any{ "enterprise_slug": enterpriseSlug, @@ -89,16 +87,16 @@ func resourceGithubEnterpriseCostCenterOrganizationsUpdate(ctx context.Context, } currentOrgs := make(map[string]bool) - for _, r := range cc.Resources { - if r != nil && r.Type == CostCenterResourceTypeOrg { - currentOrgs[r.Name] = true + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeOrg { + currentOrgs[ccResource.Name] = true } } desiredOrgsSet := d.Get("organization_logins").(*schema.Set) desiredOrgs := make(map[string]bool) - for _, o := range desiredOrgsSet.List() { - desiredOrgs[o.(string)] = true + for _, org := range desiredOrgsSet.List() { + desiredOrgs[org.(string)] = true } var toAdd, toRemove []string @@ -163,9 +161,9 @@ func resourceGithubEnterpriseCostCenterOrganizationsRead(ctx context.Context, d } var organizations []string - for _, r := range cc.Resources { - if r != nil && r.Type == CostCenterResourceTypeOrg { - organizations = append(organizations, r.Name) + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeOrg { + organizations = append(organizations, ccResource.Name) } } diff --git a/github/resource_github_enterprise_cost_center_repositories.go b/github/resource_github_enterprise_cost_center_repositories.go index 142049da6b..1734ea546a 100644 --- a/github/resource_github_enterprise_cost_center_repositories.go +++ b/github/resource_github_enterprise_cost_center_repositories.go @@ -56,11 +56,9 @@ func resourceGithubEnterpriseCostCenterRepositoriesCreate(ctx context.Context, d } d.SetId(id) - // Get desired repositories from config desiredReposSet := d.Get("repository_names").(*schema.Set) toAdd := expandStringList(desiredReposSet.List()) - // Add repositories if len(toAdd) > 0 { tflog.Info(ctx, "Adding repositories to cost center", map[string]any{ "enterprise_slug": enterpriseSlug, @@ -89,9 +87,9 @@ func resourceGithubEnterpriseCostCenterRepositoriesUpdate(ctx context.Context, d } currentRepos := make(map[string]bool) - for _, r := range cc.Resources { - if r != nil && r.Type == CostCenterResourceTypeRepo { - currentRepos[r.Name] = true + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeRepo { + currentRepos[ccResource.Name] = true } } @@ -163,9 +161,9 @@ func resourceGithubEnterpriseCostCenterRepositoriesRead(ctx context.Context, d * } var repositories []string - for _, r := range cc.Resources { - if r != nil && r.Type == CostCenterResourceTypeRepo { - repositories = append(repositories, r.Name) + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeRepo { + repositories = append(repositories, ccResource.Name) } } diff --git a/github/resource_github_enterprise_cost_center_users.go b/github/resource_github_enterprise_cost_center_users.go index 7eb5165bad..7dfc0ab421 100644 --- a/github/resource_github_enterprise_cost_center_users.go +++ b/github/resource_github_enterprise_cost_center_users.go @@ -56,11 +56,9 @@ func resourceGithubEnterpriseCostCenterUsersCreate(ctx context.Context, d *schem } d.SetId(id) - // Get desired users from config desiredUsersSet := d.Get("usernames").(*schema.Set) toAdd := expandStringList(desiredUsersSet.List()) - // Add users if len(toAdd) > 0 { tflog.Info(ctx, "Adding users to cost center", map[string]any{ "enterprise_slug": enterpriseSlug, @@ -83,28 +81,24 @@ func resourceGithubEnterpriseCostCenterUsersUpdate(ctx context.Context, d *schem enterpriseSlug := d.Get("enterprise_slug").(string) costCenterID := d.Get("cost_center_id").(string) - // Get current assignments from API cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) if err != nil { return diag.FromErr(err) } - // Extract current users currentUsers := make(map[string]bool) - for _, r := range cc.Resources { - if r != nil && r.Type == CostCenterResourceTypeUser { - currentUsers[r.Name] = true + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeUser { + currentUsers[ccResource.Name] = true } } - // Get desired users from config desiredUsersSet := d.Get("usernames").(*schema.Set) desiredUsers := make(map[string]bool) - for _, u := range desiredUsersSet.List() { - desiredUsers[u.(string)] = true + for _, username := range desiredUsersSet.List() { + desiredUsers[username.(string)] = true } - // Calculate additions and removals var toAdd, toRemove []string for user := range desiredUsers { if !currentUsers[user] { @@ -117,7 +111,6 @@ func resourceGithubEnterpriseCostCenterUsersUpdate(ctx context.Context, d *schem } } - // Remove users no longer desired if len(toRemove) > 0 { tflog.Info(ctx, "Removing users from cost center", map[string]any{ "enterprise_slug": enterpriseSlug, @@ -132,7 +125,6 @@ func resourceGithubEnterpriseCostCenterUsersUpdate(ctx context.Context, d *schem } } - // Add new users if len(toAdd) > 0 { tflog.Info(ctx, "Adding users to cost center", map[string]any{ "enterprise_slug": enterpriseSlug, @@ -168,11 +160,10 @@ func resourceGithubEnterpriseCostCenterUsersRead(ctx context.Context, d *schema. return diag.FromErr(err) } - // Extract users from resources var users []string - for _, r := range cc.Resources { - if r != nil && r.Type == CostCenterResourceTypeUser { - users = append(users, r.Name) + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeUser { + users = append(users, ccResource.Name) } } From 25f43d055e9a30bb94a631cfd4e3e77e4b3dfefd Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Wed, 18 Feb 2026 09:14:43 +0100 Subject: [PATCH 22/33] fix: address review - rename maxResourcesPerRequest to maxCostCenterResourcesPerRequest --- ...esource_github_enterprise_cost_center_organizations.go | 8 ++++---- ...resource_github_enterprise_cost_center_repositories.go | 8 ++++---- github/resource_github_enterprise_cost_center_users.go | 8 ++++---- github/util_enterprise_cost_center.go | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/github/resource_github_enterprise_cost_center_organizations.go b/github/resource_github_enterprise_cost_center_organizations.go index 1ff31a25a3..3f4a80f76d 100644 --- a/github/resource_github_enterprise_cost_center_organizations.go +++ b/github/resource_github_enterprise_cost_center_organizations.go @@ -66,7 +66,7 @@ func resourceGithubEnterpriseCostCenterOrganizationsCreate(ctx context.Context, "count": len(toAdd), }) - for _, batch := range chunkStringSlice(toAdd, maxResourcesPerRequest) { + for _, batch := range chunkStringSlice(toAdd, maxCostCenterResourcesPerRequest) { if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Organizations: batch}); diags.HasError() { return diags } @@ -118,7 +118,7 @@ func resourceGithubEnterpriseCostCenterOrganizationsUpdate(ctx context.Context, "count": len(toRemove), }) - for _, batch := range chunkStringSlice(toRemove, maxResourcesPerRequest) { + for _, batch := range chunkStringSlice(toRemove, maxCostCenterResourcesPerRequest) { if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Organizations: batch}); diags.HasError() { return diags } @@ -132,7 +132,7 @@ func resourceGithubEnterpriseCostCenterOrganizationsUpdate(ctx context.Context, "count": len(toAdd), }) - for _, batch := range chunkStringSlice(toAdd, maxResourcesPerRequest) { + for _, batch := range chunkStringSlice(toAdd, maxCostCenterResourcesPerRequest) { if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Organizations: batch}); diags.HasError() { return diags } @@ -189,7 +189,7 @@ func resourceGithubEnterpriseCostCenterOrganizationsDelete(ctx context.Context, "count": len(organizations), }) - for _, batch := range chunkStringSlice(organizations, maxResourcesPerRequest) { + for _, batch := range chunkStringSlice(organizations, maxCostCenterResourcesPerRequest) { if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Organizations: batch}); diags.HasError() { return diags } diff --git a/github/resource_github_enterprise_cost_center_repositories.go b/github/resource_github_enterprise_cost_center_repositories.go index 1734ea546a..b5cbd8ee4f 100644 --- a/github/resource_github_enterprise_cost_center_repositories.go +++ b/github/resource_github_enterprise_cost_center_repositories.go @@ -66,7 +66,7 @@ func resourceGithubEnterpriseCostCenterRepositoriesCreate(ctx context.Context, d "count": len(toAdd), }) - for _, batch := range chunkStringSlice(toAdd, maxResourcesPerRequest) { + for _, batch := range chunkStringSlice(toAdd, maxCostCenterResourcesPerRequest) { if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Repositories: batch}); diags.HasError() { return diags } @@ -118,7 +118,7 @@ func resourceGithubEnterpriseCostCenterRepositoriesUpdate(ctx context.Context, d "count": len(toRemove), }) - for _, batch := range chunkStringSlice(toRemove, maxResourcesPerRequest) { + for _, batch := range chunkStringSlice(toRemove, maxCostCenterResourcesPerRequest) { if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Repositories: batch}); diags.HasError() { return diags } @@ -132,7 +132,7 @@ func resourceGithubEnterpriseCostCenterRepositoriesUpdate(ctx context.Context, d "count": len(toAdd), }) - for _, batch := range chunkStringSlice(toAdd, maxResourcesPerRequest) { + for _, batch := range chunkStringSlice(toAdd, maxCostCenterResourcesPerRequest) { if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Repositories: batch}); diags.HasError() { return diags } @@ -189,7 +189,7 @@ func resourceGithubEnterpriseCostCenterRepositoriesDelete(ctx context.Context, d "count": len(repositories), }) - for _, batch := range chunkStringSlice(repositories, maxResourcesPerRequest) { + for _, batch := range chunkStringSlice(repositories, maxCostCenterResourcesPerRequest) { if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Repositories: batch}); diags.HasError() { return diags } diff --git a/github/resource_github_enterprise_cost_center_users.go b/github/resource_github_enterprise_cost_center_users.go index 7dfc0ab421..e61d2ffaaa 100644 --- a/github/resource_github_enterprise_cost_center_users.go +++ b/github/resource_github_enterprise_cost_center_users.go @@ -66,7 +66,7 @@ func resourceGithubEnterpriseCostCenterUsersCreate(ctx context.Context, d *schem "count": len(toAdd), }) - for _, batch := range chunkStringSlice(toAdd, maxResourcesPerRequest) { + for _, batch := range chunkStringSlice(toAdd, maxCostCenterResourcesPerRequest) { if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Users: batch}); diags.HasError() { return diags } @@ -118,7 +118,7 @@ func resourceGithubEnterpriseCostCenterUsersUpdate(ctx context.Context, d *schem "count": len(toRemove), }) - for _, batch := range chunkStringSlice(toRemove, maxResourcesPerRequest) { + for _, batch := range chunkStringSlice(toRemove, maxCostCenterResourcesPerRequest) { if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Users: batch}); diags.HasError() { return diags } @@ -132,7 +132,7 @@ func resourceGithubEnterpriseCostCenterUsersUpdate(ctx context.Context, d *schem "count": len(toAdd), }) - for _, batch := range chunkStringSlice(toAdd, maxResourcesPerRequest) { + for _, batch := range chunkStringSlice(toAdd, maxCostCenterResourcesPerRequest) { if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Users: batch}); diags.HasError() { return diags } @@ -189,7 +189,7 @@ func resourceGithubEnterpriseCostCenterUsersDelete(ctx context.Context, d *schem "count": len(usernames), }) - for _, batch := range chunkStringSlice(usernames, maxResourcesPerRequest) { + for _, batch := range chunkStringSlice(usernames, maxCostCenterResourcesPerRequest) { if diags := retryCostCenterRemoveResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Users: batch}); diags.HasError() { return diags } diff --git a/github/util_enterprise_cost_center.go b/github/util_enterprise_cost_center.go index e4604e24c3..9f89339533 100644 --- a/github/util_enterprise_cost_center.go +++ b/github/util_enterprise_cost_center.go @@ -11,7 +11,7 @@ import ( // Cost center resource management constants and retry functions. const ( - maxResourcesPerRequest = 50 + maxCostCenterResourcesPerRequest = 50 costCenterResourcesRetryTimeout = 5 * time.Minute // CostCenterResourceType constants match the API response values. From c0d721918f17697dd2427931b2293f897805e0ca Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Wed, 18 Feb 2026 09:24:50 +0100 Subject: [PATCH 23/33] fix: address review - simplify import test with ImportStateIdPrefix --- ...urce_github_enterprise_cost_center_test.go | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/github/resource_github_enterprise_cost_center_test.go b/github/resource_github_enterprise_cost_center_test.go index 118b1a1233..263a0f0761 100644 --- a/github/resource_github_enterprise_cost_center_test.go +++ b/github/resource_github_enterprise_cost_center_test.go @@ -6,7 +6,6 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" - "github.com/hashicorp/terraform-plugin-testing/terraform" ) func TestAccGithubEnterpriseCostCenter(t *testing.T) { @@ -99,20 +98,10 @@ func TestAccGithubEnterpriseCostCenter(t *testing.T) { `, testAccConf.enterpriseSlug, testResourcePrefix, randomID), }, { - ResourceName: "github_enterprise_cost_center.test", - ImportState: true, - ImportStateVerify: true, - ImportStateIdFunc: func(s *terraform.State) (string, error) { - rs, ok := s.RootModule().Resources["github_enterprise_cost_center.test"] - if !ok { - return "", fmt.Errorf("resource not found in state") - } - id, err := buildID(testAccConf.enterpriseSlug, rs.Primary.ID) - if err != nil { - return "", err - } - return id, nil - }, + ResourceName: "github_enterprise_cost_center.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdPrefix: testAccConf.enterpriseSlug + ":", }, }, }) From c0ce0cfa09933a3bbfb569bdfe4718f7dbeb181d Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Wed, 18 Feb 2026 09:37:21 +0100 Subject: [PATCH 24/33] fix: address review - add unit tests for errIs404, errIsRetryable, chunkStringSlice --- ...urce_github_enterprise_cost_center_test.go | 8 +- github/util.go | 1 - github/util_test.go | 194 ++++++++++++++++++ 3 files changed, 198 insertions(+), 5 deletions(-) diff --git a/github/resource_github_enterprise_cost_center_test.go b/github/resource_github_enterprise_cost_center_test.go index 263a0f0761..0094339e0a 100644 --- a/github/resource_github_enterprise_cost_center_test.go +++ b/github/resource_github_enterprise_cost_center_test.go @@ -98,10 +98,10 @@ func TestAccGithubEnterpriseCostCenter(t *testing.T) { `, testAccConf.enterpriseSlug, testResourcePrefix, randomID), }, { - ResourceName: "github_enterprise_cost_center.test", - ImportState: true, - ImportStateVerify: true, - ImportStateIdPrefix: testAccConf.enterpriseSlug + ":", + ResourceName: "github_enterprise_cost_center.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdPrefix: testAccConf.enterpriseSlug + ":", }, }, }) diff --git a/github/util.go b/github/util.go index ebc25152bc..fa71832f32 100644 --- a/github/util.go +++ b/github/util.go @@ -294,7 +294,6 @@ func errIsRetryable(err error) bool { } // chunkStringSlice splits a slice into chunks of the specified max size. -// nolint:unparam // Keep maxSize for generic reuse across resources beyond current cost-center callers. func chunkStringSlice(items []string, maxSize int) [][]string { if len(items) == 0 { return nil diff --git a/github/util_test.go b/github/util_test.go index 38db46353d..8589ddee2d 100644 --- a/github/util_test.go +++ b/github/util_test.go @@ -1,9 +1,12 @@ package github import ( + "fmt" + "net/http" "testing" "unicode" + "github.com/google/go-github/v82/github" "github.com/hashicorp/go-cty/cty" ) @@ -515,3 +518,194 @@ func TestGithubUtilValidateSecretName(t *testing.T) { } } } + +func ghErrorResponse(statusCode int) *github.ErrorResponse { + return &github.ErrorResponse{ + Response: &http.Response{StatusCode: statusCode}, + } +} + +func Test_errIs404(t *testing.T) { + t.Parallel() + + for _, d := range []struct { + testName string + err error + expected bool + }{ + { + testName: "nil_error", + err: nil, + expected: false, + }, + { + testName: "plain_error", + err: fmt.Errorf("some error"), + expected: false, + }, + { + testName: "github_404", + err: ghErrorResponse(http.StatusNotFound), + expected: true, + }, + { + testName: "github_403", + err: ghErrorResponse(http.StatusForbidden), + expected: false, + }, + { + testName: "github_500", + err: ghErrorResponse(http.StatusInternalServerError), + expected: false, + }, + } { + t.Run(d.testName, func(t *testing.T) { + t.Parallel() + + got := errIs404(d.err) + if got != d.expected { + t.Fatalf("expected errIs404 %v but got %v", d.expected, got) + } + }) + } +} + +func Test_errIsRetryable(t *testing.T) { + t.Parallel() + + for _, d := range []struct { + testName string + err error + expected bool + }{ + { + testName: "nil_error", + err: nil, + expected: false, + }, + { + testName: "plain_error", + err: fmt.Errorf("some error"), + expected: false, + }, + { + testName: "github_404_not_retryable", + err: ghErrorResponse(http.StatusNotFound), + expected: false, + }, + { + testName: "github_409_conflict", + err: ghErrorResponse(http.StatusConflict), + expected: true, + }, + { + testName: "github_500_internal_server_error", + err: ghErrorResponse(http.StatusInternalServerError), + expected: true, + }, + { + testName: "github_502_bad_gateway", + err: ghErrorResponse(http.StatusBadGateway), + expected: true, + }, + { + testName: "github_503_service_unavailable", + err: ghErrorResponse(http.StatusServiceUnavailable), + expected: true, + }, + { + testName: "github_504_gateway_timeout", + err: ghErrorResponse(http.StatusGatewayTimeout), + expected: true, + }, + { + testName: "github_400_bad_request", + err: ghErrorResponse(http.StatusBadRequest), + expected: false, + }, + } { + t.Run(d.testName, func(t *testing.T) { + t.Parallel() + + got := errIsRetryable(d.err) + if got != d.expected { + t.Fatalf("expected errIsRetryable %v but got %v", d.expected, got) + } + }) + } +} + +func Test_chunkStringSlice(t *testing.T) { + t.Parallel() + + for _, d := range []struct { + testName string + items []string + maxSize int + expected [][]string + }{ + { + testName: "nil_slice", + items: nil, + maxSize: 3, + expected: nil, + }, + { + testName: "empty_slice", + items: []string{}, + maxSize: 3, + expected: nil, + }, + { + testName: "single_item", + items: []string{"a"}, + maxSize: 3, + expected: [][]string{{"a"}}, + }, + { + testName: "exact_fit", + items: []string{"a", "b", "c"}, + maxSize: 3, + expected: [][]string{{"a", "b", "c"}}, + }, + { + testName: "with_remainder", + items: []string{"a", "b", "c", "d", "e"}, + maxSize: 2, + expected: [][]string{{"a", "b"}, {"c", "d"}, {"e"}}, + }, + { + testName: "chunk_size_one", + items: []string{"a", "b", "c"}, + maxSize: 1, + expected: [][]string{{"a"}, {"b"}, {"c"}}, + }, + { + testName: "chunk_size_larger_than_slice", + items: []string{"a", "b"}, + maxSize: 10, + expected: [][]string{{"a", "b"}}, + }, + } { + t.Run(d.testName, func(t *testing.T) { + t.Parallel() + + got := chunkStringSlice(d.items, d.maxSize) + + if len(got) != len(d.expected) { + t.Fatalf("expected %d chunks but got %d", len(d.expected), len(got)) + } + + for i := range got { + if len(got[i]) != len(d.expected[i]) { + t.Fatalf("expected chunk[%d] to have %d items but got %d", i, len(d.expected[i]), len(got[i])) + } + for j := range got[i] { + if got[i][j] != d.expected[i][j] { + t.Fatalf("expected chunk[%d][%d] %q but got %q", i, j, d.expected[i][j], got[i][j]) + } + } + } + }) + } +} From 371f37cf7d47813785cfcc6ca89615738b869259 Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Wed, 18 Feb 2026 10:25:14 +0100 Subject: [PATCH 25/33] fix: address review - migrate cost center tests to ConfigStateChecks --- ...urce_github_enterprise_cost_center_test.go | 14 +++++++---- ...rce_github_enterprise_cost_centers_test.go | 12 ++++++--- ...terprise_cost_center_organizations_test.go | 13 ++++++---- ...nterprise_cost_center_repositories_test.go | 13 ++++++---- ...urce_github_enterprise_cost_center_test.go | 25 +++++++++++-------- ...ithub_enterprise_cost_center_users_test.go | 13 ++++++---- 6 files changed, 55 insertions(+), 35 deletions(-) diff --git a/github/data_source_github_enterprise_cost_center_test.go b/github/data_source_github_enterprise_cost_center_test.go index 34f2a78063..690fe7a69e 100644 --- a/github/data_source_github_enterprise_cost_center_test.go +++ b/github/data_source_github_enterprise_cost_center_test.go @@ -4,8 +4,12 @@ import ( "fmt" "testing" + "github.com/hashicorp/terraform-plugin-testing/compare" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" ) func TestAccGithubEnterpriseCostCenterDataSource(t *testing.T) { @@ -30,11 +34,11 @@ func TestAccGithubEnterpriseCostCenterDataSource(t *testing.T) { cost_center_id = github_enterprise_cost_center.test.id } `, testAccConf.enterpriseSlug, testResourcePrefix, randomID), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttrPair("data.github_enterprise_cost_center.test", "cost_center_id", "github_enterprise_cost_center.test", "id"), - resource.TestCheckResourceAttrPair("data.github_enterprise_cost_center.test", "name", "github_enterprise_cost_center.test", "name"), - resource.TestCheckResourceAttr("data.github_enterprise_cost_center.test", "state", "active"), - ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.CompareValuePairs("data.github_enterprise_cost_center.test", tfjsonpath.New("cost_center_id"), "github_enterprise_cost_center.test", tfjsonpath.New("id"), compare.ValuesSame()), + statecheck.CompareValuePairs("data.github_enterprise_cost_center.test", tfjsonpath.New("name"), "github_enterprise_cost_center.test", tfjsonpath.New("name"), compare.ValuesSame()), + statecheck.ExpectKnownValue("data.github_enterprise_cost_center.test", tfjsonpath.New("state"), knownvalue.StringExact("active")), + }, }}, }) } diff --git a/github/data_source_github_enterprise_cost_centers_test.go b/github/data_source_github_enterprise_cost_centers_test.go index e2ea0e7fca..ac9c27b906 100644 --- a/github/data_source_github_enterprise_cost_centers_test.go +++ b/github/data_source_github_enterprise_cost_centers_test.go @@ -4,8 +4,12 @@ import ( "fmt" "testing" + "github.com/hashicorp/terraform-plugin-testing/compare" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" ) func TestAccGithubEnterpriseCostCentersDataSource(t *testing.T) { @@ -33,10 +37,10 @@ func TestAccGithubEnterpriseCostCentersDataSource(t *testing.T) { ProviderFactories: providerFactories, Steps: []resource.TestStep{{ Config: config, - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("data.github_enterprise_cost_centers.test", "state", "active"), - resource.TestCheckTypeSetElemAttrPair("data.github_enterprise_cost_centers.test", "cost_centers.*.id", "github_enterprise_cost_center.test", "id"), - ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.github_enterprise_cost_centers.test", tfjsonpath.New("state"), knownvalue.StringExact("active")), + statecheck.CompareValueCollection("data.github_enterprise_cost_centers.test", []tfjsonpath.Path{tfjsonpath.New("cost_centers"), tfjsonpath.New("id")}, "github_enterprise_cost_center.test", tfjsonpath.New("id"), compare.ValuesSame()), + }, }}, }) } diff --git a/github/resource_github_enterprise_cost_center_organizations_test.go b/github/resource_github_enterprise_cost_center_organizations_test.go index 640024f80f..3bb5beae58 100644 --- a/github/resource_github_enterprise_cost_center_organizations_test.go +++ b/github/resource_github_enterprise_cost_center_organizations_test.go @@ -8,7 +8,10 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" ) func TestAccGithubEnterpriseCostCenterOrganizations(t *testing.T) { @@ -44,11 +47,11 @@ func TestAccGithubEnterpriseCostCenterOrganizations(t *testing.T) { Steps: []resource.TestStep{ { Config: config, - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_enterprise_cost_center_organizations.test", "enterprise_slug", testAccConf.enterpriseSlug), - resource.TestCheckResourceAttr("github_enterprise_cost_center_organizations.test", "organization_logins.#", "1"), - resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center_organizations.test", "organization_logins.*", orgLogin), - ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_cost_center_organizations.test", tfjsonpath.New("enterprise_slug"), knownvalue.StringExact(testAccConf.enterpriseSlug)), + statecheck.ExpectKnownValue("github_enterprise_cost_center_organizations.test", tfjsonpath.New("organization_logins"), knownvalue.SetSizeExact(1)), + statecheck.ExpectKnownValue("github_enterprise_cost_center_organizations.test", tfjsonpath.New("organization_logins"), knownvalue.SetPartial([]knownvalue.Check{knownvalue.StringExact(orgLogin)})), + }, }, { ResourceName: "github_enterprise_cost_center_organizations.test", diff --git a/github/resource_github_enterprise_cost_center_repositories_test.go b/github/resource_github_enterprise_cost_center_repositories_test.go index b5a26bd425..4d8e15960d 100644 --- a/github/resource_github_enterprise_cost_center_repositories_test.go +++ b/github/resource_github_enterprise_cost_center_repositories_test.go @@ -8,7 +8,10 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" ) func TestAccGithubEnterpriseCostCenterRepositories(t *testing.T) { @@ -44,11 +47,11 @@ func TestAccGithubEnterpriseCostCenterRepositories(t *testing.T) { Steps: []resource.TestStep{ { Config: config, - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_enterprise_cost_center_repositories.test", "enterprise_slug", testAccConf.enterpriseSlug), - resource.TestCheckResourceAttr("github_enterprise_cost_center_repositories.test", "repository_names.#", "1"), - resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center_repositories.test", "repository_names.*", repoName), - ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_cost_center_repositories.test", tfjsonpath.New("enterprise_slug"), knownvalue.StringExact(testAccConf.enterpriseSlug)), + statecheck.ExpectKnownValue("github_enterprise_cost_center_repositories.test", tfjsonpath.New("repository_names"), knownvalue.SetSizeExact(1)), + statecheck.ExpectKnownValue("github_enterprise_cost_center_repositories.test", tfjsonpath.New("repository_names"), knownvalue.SetPartial([]knownvalue.Check{knownvalue.StringExact(repoName)})), + }, }, { ResourceName: "github_enterprise_cost_center_repositories.test", diff --git a/github/resource_github_enterprise_cost_center_test.go b/github/resource_github_enterprise_cost_center_test.go index 0094339e0a..84171ffdab 100644 --- a/github/resource_github_enterprise_cost_center_test.go +++ b/github/resource_github_enterprise_cost_center_test.go @@ -6,6 +6,9 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" ) func TestAccGithubEnterpriseCostCenter(t *testing.T) { @@ -27,11 +30,11 @@ func TestAccGithubEnterpriseCostCenter(t *testing.T) { name = "%s%s" } `, testAccConf.enterpriseSlug, testResourcePrefix, randomID), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "enterprise_slug", testAccConf.enterpriseSlug), - resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "name", testResourcePrefix+randomID), - resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "state", "active"), - ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_cost_center.test", tfjsonpath.New("enterprise_slug"), knownvalue.StringExact(testAccConf.enterpriseSlug)), + statecheck.ExpectKnownValue("github_enterprise_cost_center.test", tfjsonpath.New("name"), knownvalue.StringExact(testResourcePrefix+randomID)), + statecheck.ExpectKnownValue("github_enterprise_cost_center.test", tfjsonpath.New("state"), knownvalue.StringExact("active")), + }, }, }, }) @@ -55,9 +58,9 @@ func TestAccGithubEnterpriseCostCenter(t *testing.T) { name = "%s%s" } `, testAccConf.enterpriseSlug, testResourcePrefix, randomID), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "name", testResourcePrefix+randomID), - ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_cost_center.test", tfjsonpath.New("name"), knownvalue.StringExact(testResourcePrefix+randomID)), + }, }, { Config: fmt.Sprintf(` @@ -70,9 +73,9 @@ func TestAccGithubEnterpriseCostCenter(t *testing.T) { name = "%supdated-%s" } `, testAccConf.enterpriseSlug, testResourcePrefix, randomID), - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_enterprise_cost_center.test", "name", testResourcePrefix+"updated-"+randomID), - ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_cost_center.test", tfjsonpath.New("name"), knownvalue.StringExact(testResourcePrefix+"updated-"+randomID)), + }, }, }, }) diff --git a/github/resource_github_enterprise_cost_center_users_test.go b/github/resource_github_enterprise_cost_center_users_test.go index 85e31be246..efe275cabb 100644 --- a/github/resource_github_enterprise_cost_center_users_test.go +++ b/github/resource_github_enterprise_cost_center_users_test.go @@ -7,7 +7,10 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" ) func TestAccGithubEnterpriseCostCenterUsers(t *testing.T) { @@ -39,11 +42,11 @@ func TestAccGithubEnterpriseCostCenterUsers(t *testing.T) { Steps: []resource.TestStep{ { Config: config, - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_enterprise_cost_center_users.test", "enterprise_slug", testAccConf.enterpriseSlug), - resource.TestCheckResourceAttr("github_enterprise_cost_center_users.test", "usernames.#", "1"), - resource.TestCheckTypeSetElemAttr("github_enterprise_cost_center_users.test", "usernames.*", user), - ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_cost_center_users.test", tfjsonpath.New("enterprise_slug"), knownvalue.StringExact(testAccConf.enterpriseSlug)), + statecheck.ExpectKnownValue("github_enterprise_cost_center_users.test", tfjsonpath.New("usernames"), knownvalue.SetSizeExact(1)), + statecheck.ExpectKnownValue("github_enterprise_cost_center_users.test", tfjsonpath.New("usernames"), knownvalue.SetPartial([]knownvalue.Check{knownvalue.StringExact(user)})), + }, }, { ResourceName: "github_enterprise_cost_center_users.test", From 35e0092a2a9281105c03049004c565a271d02b16 Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Mon, 23 Feb 2026 17:35:07 +0100 Subject: [PATCH 26/33] fix(cost-centers): update go-github import from v82 to v83 --- github/resource_github_enterprise_cost_center.go | 2 +- .../resource_github_enterprise_cost_center_organizations.go | 2 +- .../resource_github_enterprise_cost_center_repositories.go | 2 +- github/resource_github_enterprise_cost_center_users.go | 2 +- github/util_enterprise_cost_center.go | 6 +++--- github/util_test.go | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/github/resource_github_enterprise_cost_center.go b/github/resource_github_enterprise_cost_center.go index fc6d86517b..ed2459f8e6 100644 --- a/github/resource_github_enterprise_cost_center.go +++ b/github/resource_github_enterprise_cost_center.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v83/github" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" diff --git a/github/resource_github_enterprise_cost_center_organizations.go b/github/resource_github_enterprise_cost_center_organizations.go index 3f4a80f76d..b3f3a25822 100644 --- a/github/resource_github_enterprise_cost_center_organizations.go +++ b/github/resource_github_enterprise_cost_center_organizations.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v83/github" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" diff --git a/github/resource_github_enterprise_cost_center_repositories.go b/github/resource_github_enterprise_cost_center_repositories.go index b5cbd8ee4f..5f726f0696 100644 --- a/github/resource_github_enterprise_cost_center_repositories.go +++ b/github/resource_github_enterprise_cost_center_repositories.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v83/github" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" diff --git a/github/resource_github_enterprise_cost_center_users.go b/github/resource_github_enterprise_cost_center_users.go index e61d2ffaaa..b535a1b521 100644 --- a/github/resource_github_enterprise_cost_center_users.go +++ b/github/resource_github_enterprise_cost_center_users.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v83/github" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" diff --git a/github/util_enterprise_cost_center.go b/github/util_enterprise_cost_center.go index 9f89339533..7ddfe9b201 100644 --- a/github/util_enterprise_cost_center.go +++ b/github/util_enterprise_cost_center.go @@ -4,15 +4,15 @@ import ( "context" "time" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v83/github" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" ) // Cost center resource management constants and retry functions. const ( - maxCostCenterResourcesPerRequest = 50 - costCenterResourcesRetryTimeout = 5 * time.Minute + maxCostCenterResourcesPerRequest = 50 + costCenterResourcesRetryTimeout = 5 * time.Minute // CostCenterResourceType constants match the API response values. CostCenterResourceTypeUser = "User" diff --git a/github/util_test.go b/github/util_test.go index 8589ddee2d..1a720de36c 100644 --- a/github/util_test.go +++ b/github/util_test.go @@ -6,7 +6,7 @@ import ( "testing" "unicode" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v83/github" "github.com/hashicorp/go-cty/cty" ) From 8395f25aae7dddde5b23f4eecea135aa19b27846 Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Mon, 23 Feb 2026 17:35:25 +0100 Subject: [PATCH 27/33] fix(cost-centers): add default 'all' value to state field in cost centers data source --- ...a_source_github_enterprise_cost_centers.go | 23 +++++++++---------- .../d/enterprise_cost_centers.html.markdown | 2 +- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/github/data_source_github_enterprise_cost_centers.go b/github/data_source_github_enterprise_cost_centers.go index f220c89755..1c3deddb1a 100644 --- a/github/data_source_github_enterprise_cost_centers.go +++ b/github/data_source_github_enterprise_cost_centers.go @@ -3,7 +3,7 @@ package github import ( "context" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v83/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" @@ -23,8 +23,9 @@ func dataSourceGithubEnterpriseCostCenters() *schema.Resource { "state": { Type: schema.TypeString, Optional: true, - ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"active", "deleted"}, false)), - Description: "Filter cost centers by state.", + Default: "all", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"all", "active", "deleted"}, false)), + Description: "Filter cost centers by state. Valid values are 'all', 'active', and 'deleted'.", }, "cost_centers": { Type: schema.TypeSet, @@ -62,12 +63,14 @@ func dataSourceGithubEnterpriseCostCenters() *schema.Resource { func dataSourceGithubEnterpriseCostCentersRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client enterpriseSlug := d.Get("enterprise_slug").(string) - var state *string - if v, ok := d.GetOk("state"); ok { - state = github.Ptr(v.(string)) + stateFilter := d.Get("state").(string) + + var opts github.ListCostCenterOptions + if stateFilter != "all" { + opts.State = github.Ptr(stateFilter) } - result, _, err := client.Enterprise.ListCostCenters(ctx, enterpriseSlug, &github.ListCostCenterOptions{State: state}) + result, _, err := client.Enterprise.ListCostCenters(ctx, enterpriseSlug, &opts) if err != nil { return diag.FromErr(err) } @@ -85,11 +88,7 @@ func dataSourceGithubEnterpriseCostCentersRead(ctx context.Context, d *schema.Re }) } - stateStr := "all" - if state != nil { - stateStr = *state - } - id, err := buildID(enterpriseSlug, stateStr) + id, err := buildID(enterpriseSlug, stateFilter) if err != nil { return diag.FromErr(err) } diff --git a/website/docs/d/enterprise_cost_centers.html.markdown b/website/docs/d/enterprise_cost_centers.html.markdown index f7fa7a66ef..1f400c9215 100644 --- a/website/docs/d/enterprise_cost_centers.html.markdown +++ b/website/docs/d/enterprise_cost_centers.html.markdown @@ -21,7 +21,7 @@ data "github_enterprise_cost_centers" "active" { ## Argument Reference * `enterprise_slug` - (Required) The slug of the enterprise. -* `state` - (Optional) Filter cost centers by state. Valid values are `active` and `deleted`. +* `state` - (Optional) Filter cost centers by state. Valid values are `all`, `active`, and `deleted`. Defaults to `all`. ## Attributes Reference From 757c071c18a250eed6ca85e10f4677c8282e8a8d Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Tue, 24 Feb 2026 08:51:13 +0100 Subject: [PATCH 28/33] fix(cost-center): populate name on import via API lookup The import function now calls GetCostCenter to populate the 'name' field from the API response. Without this, the Required 'name' field would be empty after import, causing recreate churn on the next plan. Addresses review comment from @stevehipwell. --- github/resource_github_enterprise_cost_center.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/github/resource_github_enterprise_cost_center.go b/github/resource_github_enterprise_cost_center.go index ed2459f8e6..c05142be77 100644 --- a/github/resource_github_enterprise_cost_center.go +++ b/github/resource_github_enterprise_cost_center.go @@ -168,10 +168,19 @@ func resourceGithubEnterpriseCostCenterImport(ctx context.Context, d *schema.Res return nil, fmt.Errorf("invalid import ID %q: expected format :", d.Id()) } + client := meta.(*Owner).v3client + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + return nil, fmt.Errorf("error reading cost center %q in enterprise %q: %w", costCenterID, enterpriseSlug, err) + } + d.SetId(costCenterID) if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { return nil, err } + if err := d.Set("name", cc.Name); err != nil { + return nil, err + } return []*schema.ResourceData{d}, nil } From c27c343ae6b5277252130649c98161b00489c1ff Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Tue, 24 Feb 2026 08:54:28 +0100 Subject: [PATCH 29/33] fix(cost-center): align sub-resource IDs with main resource Sub-resources (_users, _organizations, _repositories) now use a simple cost_center_id as their Terraform ID, consistent with the main github_enterprise_cost_center resource. Also moves d.SetId() to after the API call succeeds, preventing corrupt state if the add-resources call fails. Tests updated to use ImportStateIdPrefix and read attributes from state instead of parsing the now-simple ID. Addresses review comments from @stevehipwell. --- ...ub_enterprise_cost_center_organizations.go | 26 +++++++------------ ...terprise_cost_center_organizations_test.go | 13 +++++----- ...hub_enterprise_cost_center_repositories.go | 26 +++++++------------ ...nterprise_cost_center_repositories_test.go | 13 +++++----- ...rce_github_enterprise_cost_center_users.go | 26 +++++++------------ ...ithub_enterprise_cost_center_users_test.go | 13 +++++----- 6 files changed, 48 insertions(+), 69 deletions(-) diff --git a/github/resource_github_enterprise_cost_center_organizations.go b/github/resource_github_enterprise_cost_center_organizations.go index b3f3a25822..96fde9495f 100644 --- a/github/resource_github_enterprise_cost_center_organizations.go +++ b/github/resource_github_enterprise_cost_center_organizations.go @@ -50,29 +50,22 @@ func resourceGithubEnterpriseCostCenterOrganizationsCreate(ctx context.Context, enterpriseSlug := d.Get("enterprise_slug").(string) costCenterID := d.Get("cost_center_id").(string) - id, err := buildID(enterpriseSlug, costCenterID) - if err != nil { - return diag.FromErr(err) - } - d.SetId(id) - desiredOrgsSet := d.Get("organization_logins").(*schema.Set) toAdd := expandStringList(desiredOrgsSet.List()) - if len(toAdd) > 0 { - tflog.Info(ctx, "Adding organizations to cost center", map[string]any{ - "enterprise_slug": enterpriseSlug, - "cost_center_id": costCenterID, - "count": len(toAdd), - }) + tflog.Info(ctx, "Adding organizations to cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toAdd), + }) - for _, batch := range chunkStringSlice(toAdd, maxCostCenterResourcesPerRequest) { - if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Organizations: batch}); diags.HasError() { - return diags - } + for _, batch := range chunkStringSlice(toAdd, maxCostCenterResourcesPerRequest) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Organizations: batch}); diags.HasError() { + return diags } } + d.SetId(costCenterID) return nil } @@ -205,6 +198,7 @@ func resourceGithubEnterpriseCostCenterOrganizationsImport(ctx context.Context, return nil, fmt.Errorf("invalid import ID %q: expected format :", d.Id()) } + d.SetId(costCenterID) if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { return nil, err } diff --git a/github/resource_github_enterprise_cost_center_organizations_test.go b/github/resource_github_enterprise_cost_center_organizations_test.go index 3bb5beae58..ab77b42633 100644 --- a/github/resource_github_enterprise_cost_center_organizations_test.go +++ b/github/resource_github_enterprise_cost_center_organizations_test.go @@ -54,9 +54,10 @@ func TestAccGithubEnterpriseCostCenterOrganizations(t *testing.T) { }, }, { - ResourceName: "github_enterprise_cost_center_organizations.test", - ImportState: true, - ImportStateVerify: true, + ResourceName: "github_enterprise_cost_center_organizations.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdPrefix: testAccConf.enterpriseSlug + ":", }, }, }) @@ -75,10 +76,8 @@ func testAccCheckGithubEnterpriseCostCenterOrganizationsDestroy(s *terraform.Sta continue } - enterpriseSlug, costCenterID, err := parseID2(rs.Primary.ID) - if err != nil { - return err - } + enterpriseSlug := rs.Primary.Attributes["enterprise_slug"] + costCenterID := rs.Primary.Attributes["cost_center_id"] cc, _, err := client.Enterprise.GetCostCenter(context.Background(), enterpriseSlug, costCenterID) if errIs404(err) { diff --git a/github/resource_github_enterprise_cost_center_repositories.go b/github/resource_github_enterprise_cost_center_repositories.go index 5f726f0696..571abf9d8d 100644 --- a/github/resource_github_enterprise_cost_center_repositories.go +++ b/github/resource_github_enterprise_cost_center_repositories.go @@ -50,29 +50,22 @@ func resourceGithubEnterpriseCostCenterRepositoriesCreate(ctx context.Context, d enterpriseSlug := d.Get("enterprise_slug").(string) costCenterID := d.Get("cost_center_id").(string) - id, err := buildID(enterpriseSlug, costCenterID) - if err != nil { - return diag.FromErr(err) - } - d.SetId(id) - desiredReposSet := d.Get("repository_names").(*schema.Set) toAdd := expandStringList(desiredReposSet.List()) - if len(toAdd) > 0 { - tflog.Info(ctx, "Adding repositories to cost center", map[string]any{ - "enterprise_slug": enterpriseSlug, - "cost_center_id": costCenterID, - "count": len(toAdd), - }) + tflog.Info(ctx, "Adding repositories to cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toAdd), + }) - for _, batch := range chunkStringSlice(toAdd, maxCostCenterResourcesPerRequest) { - if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Repositories: batch}); diags.HasError() { - return diags - } + for _, batch := range chunkStringSlice(toAdd, maxCostCenterResourcesPerRequest) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Repositories: batch}); diags.HasError() { + return diags } } + d.SetId(costCenterID) return nil } @@ -205,6 +198,7 @@ func resourceGithubEnterpriseCostCenterRepositoriesImport(ctx context.Context, d return nil, fmt.Errorf("invalid import ID %q: expected format :", d.Id()) } + d.SetId(costCenterID) if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { return nil, err } diff --git a/github/resource_github_enterprise_cost_center_repositories_test.go b/github/resource_github_enterprise_cost_center_repositories_test.go index 4d8e15960d..cbac3871b4 100644 --- a/github/resource_github_enterprise_cost_center_repositories_test.go +++ b/github/resource_github_enterprise_cost_center_repositories_test.go @@ -54,9 +54,10 @@ func TestAccGithubEnterpriseCostCenterRepositories(t *testing.T) { }, }, { - ResourceName: "github_enterprise_cost_center_repositories.test", - ImportState: true, - ImportStateVerify: true, + ResourceName: "github_enterprise_cost_center_repositories.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdPrefix: testAccConf.enterpriseSlug + ":", }, }, }) @@ -75,10 +76,8 @@ func testAccCheckGithubEnterpriseCostCenterRepositoriesDestroy(s *terraform.Stat continue } - enterpriseSlug, costCenterID, err := parseID2(rs.Primary.ID) - if err != nil { - return err - } + enterpriseSlug := rs.Primary.Attributes["enterprise_slug"] + costCenterID := rs.Primary.Attributes["cost_center_id"] cc, _, err := client.Enterprise.GetCostCenter(context.Background(), enterpriseSlug, costCenterID) if errIs404(err) { diff --git a/github/resource_github_enterprise_cost_center_users.go b/github/resource_github_enterprise_cost_center_users.go index b535a1b521..f0ea9dd7b0 100644 --- a/github/resource_github_enterprise_cost_center_users.go +++ b/github/resource_github_enterprise_cost_center_users.go @@ -50,29 +50,22 @@ func resourceGithubEnterpriseCostCenterUsersCreate(ctx context.Context, d *schem enterpriseSlug := d.Get("enterprise_slug").(string) costCenterID := d.Get("cost_center_id").(string) - id, err := buildID(enterpriseSlug, costCenterID) - if err != nil { - return diag.FromErr(err) - } - d.SetId(id) - desiredUsersSet := d.Get("usernames").(*schema.Set) toAdd := expandStringList(desiredUsersSet.List()) - if len(toAdd) > 0 { - tflog.Info(ctx, "Adding users to cost center", map[string]any{ - "enterprise_slug": enterpriseSlug, - "cost_center_id": costCenterID, - "count": len(toAdd), - }) + tflog.Info(ctx, "Adding users to cost center", map[string]any{ + "enterprise_slug": enterpriseSlug, + "cost_center_id": costCenterID, + "count": len(toAdd), + }) - for _, batch := range chunkStringSlice(toAdd, maxCostCenterResourcesPerRequest) { - if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Users: batch}); diags.HasError() { - return diags - } + for _, batch := range chunkStringSlice(toAdd, maxCostCenterResourcesPerRequest) { + if diags := retryCostCenterAddResources(ctx, client, enterpriseSlug, costCenterID, github.CostCenterResourceRequest{Users: batch}); diags.HasError() { + return diags } } + d.SetId(costCenterID) return nil } @@ -205,6 +198,7 @@ func resourceGithubEnterpriseCostCenterUsersImport(ctx context.Context, d *schem return nil, fmt.Errorf("invalid import ID %q: expected format :", d.Id()) } + d.SetId(costCenterID) if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { return nil, err } diff --git a/github/resource_github_enterprise_cost_center_users_test.go b/github/resource_github_enterprise_cost_center_users_test.go index efe275cabb..48ee0a35a8 100644 --- a/github/resource_github_enterprise_cost_center_users_test.go +++ b/github/resource_github_enterprise_cost_center_users_test.go @@ -49,9 +49,10 @@ func TestAccGithubEnterpriseCostCenterUsers(t *testing.T) { }, }, { - ResourceName: "github_enterprise_cost_center_users.test", - ImportState: true, - ImportStateVerify: true, + ResourceName: "github_enterprise_cost_center_users.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdPrefix: testAccConf.enterpriseSlug + ":", }, }, }) @@ -70,10 +71,8 @@ func testAccCheckGithubEnterpriseCostCenterUsersDestroy(s *terraform.State) erro continue } - enterpriseSlug, costCenterID, err := parseID2(rs.Primary.ID) - if err != nil { - return err - } + enterpriseSlug := rs.Primary.Attributes["enterprise_slug"] + costCenterID := rs.Primary.Attributes["cost_center_id"] cc, _, err := client.Enterprise.GetCostCenter(context.Background(), enterpriseSlug, costCenterID) if errIs404(err) { From bd519094d04480f2d9450c84cb67689f7872f9ed Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Tue, 24 Feb 2026 08:56:47 +0100 Subject: [PATCH 30/33] fix(cost-center): validate no existing assignments on Create Before adding resources to a cost center, check via API whether the cost center already has resources of the managed type assigned. If so, return an error asking the user to import or remove them manually. This prevents silently clobbering pre-existing assignments. --- ...urce_github_enterprise_cost_center_organizations.go | 10 ++++++++++ ...ource_github_enterprise_cost_center_repositories.go | 10 ++++++++++ github/resource_github_enterprise_cost_center_users.go | 10 ++++++++++ 3 files changed, 30 insertions(+) diff --git a/github/resource_github_enterprise_cost_center_organizations.go b/github/resource_github_enterprise_cost_center_organizations.go index 96fde9495f..6c5f66428d 100644 --- a/github/resource_github_enterprise_cost_center_organizations.go +++ b/github/resource_github_enterprise_cost_center_organizations.go @@ -50,6 +50,16 @@ func resourceGithubEnterpriseCostCenterOrganizationsCreate(ctx context.Context, enterpriseSlug := d.Get("enterprise_slug").(string) costCenterID := d.Get("cost_center_id").(string) + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeOrg { + return diag.Errorf("cost center %q already has organizations assigned; import the existing assignments first or remove them manually", costCenterID) + } + } + desiredOrgsSet := d.Get("organization_logins").(*schema.Set) toAdd := expandStringList(desiredOrgsSet.List()) diff --git a/github/resource_github_enterprise_cost_center_repositories.go b/github/resource_github_enterprise_cost_center_repositories.go index 571abf9d8d..8f5b09076d 100644 --- a/github/resource_github_enterprise_cost_center_repositories.go +++ b/github/resource_github_enterprise_cost_center_repositories.go @@ -50,6 +50,16 @@ func resourceGithubEnterpriseCostCenterRepositoriesCreate(ctx context.Context, d enterpriseSlug := d.Get("enterprise_slug").(string) costCenterID := d.Get("cost_center_id").(string) + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeRepo { + return diag.Errorf("cost center %q already has repositories assigned; import the existing assignments first or remove them manually", costCenterID) + } + } + desiredReposSet := d.Get("repository_names").(*schema.Set) toAdd := expandStringList(desiredReposSet.List()) diff --git a/github/resource_github_enterprise_cost_center_users.go b/github/resource_github_enterprise_cost_center_users.go index f0ea9dd7b0..fb4af6625a 100644 --- a/github/resource_github_enterprise_cost_center_users.go +++ b/github/resource_github_enterprise_cost_center_users.go @@ -50,6 +50,16 @@ func resourceGithubEnterpriseCostCenterUsersCreate(ctx context.Context, d *schem enterpriseSlug := d.Get("enterprise_slug").(string) costCenterID := d.Get("cost_center_id").(string) + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + return diag.FromErr(err) + } + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeUser { + return diag.Errorf("cost center %q already has users assigned; import the existing assignments first or remove them manually", costCenterID) + } + } + desiredUsersSet := d.Get("usernames").(*schema.Set) toAdd := expandStringList(desiredUsersSet.List()) From 9cc1b71acb2c586a725c75f62f09e04a3f10a0b9 Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Tue, 24 Feb 2026 08:58:53 +0100 Subject: [PATCH 31/33] refactor(cost-center): simplify Update diff with map pattern Replace the two-map diff (currentX + desiredX) with a single map where false=remove and true=keep. Desired items not in the map are added. This is shorter and avoids constructing a second map. --- ...ub_enterprise_cost_center_organizations.go | 29 +++++++++---------- ...hub_enterprise_cost_center_repositories.go | 29 +++++++++---------- ...rce_github_enterprise_cost_center_users.go | 29 +++++++++---------- 3 files changed, 42 insertions(+), 45 deletions(-) diff --git a/github/resource_github_enterprise_cost_center_organizations.go b/github/resource_github_enterprise_cost_center_organizations.go index 6c5f66428d..ebf5935b49 100644 --- a/github/resource_github_enterprise_cost_center_organizations.go +++ b/github/resource_github_enterprise_cost_center_organizations.go @@ -89,28 +89,27 @@ func resourceGithubEnterpriseCostCenterOrganizationsUpdate(ctx context.Context, return diag.FromErr(err) } - currentOrgs := make(map[string]bool) + diff := make(map[string]bool) for _, ccResource := range cc.Resources { if ccResource != nil && ccResource.Type == CostCenterResourceTypeOrg { - currentOrgs[ccResource.Name] = true + diff[ccResource.Name] = false } } - desiredOrgsSet := d.Get("organization_logins").(*schema.Set) - desiredOrgs := make(map[string]bool) - for _, org := range desiredOrgsSet.List() { - desiredOrgs[org.(string)] = true - } - - var toAdd, toRemove []string - for org := range desiredOrgs { - if !currentOrgs[org] { - toAdd = append(toAdd, org) + var toAdd []string + for _, org := range d.Get("organization_logins").(*schema.Set).List() { + name := org.(string) + if _, exists := diff[name]; exists { + diff[name] = true + } else { + toAdd = append(toAdd, name) } } - for org := range currentOrgs { - if !desiredOrgs[org] { - toRemove = append(toRemove, org) + + var toRemove []string + for name, keep := range diff { + if !keep { + toRemove = append(toRemove, name) } } diff --git a/github/resource_github_enterprise_cost_center_repositories.go b/github/resource_github_enterprise_cost_center_repositories.go index 8f5b09076d..4679fef837 100644 --- a/github/resource_github_enterprise_cost_center_repositories.go +++ b/github/resource_github_enterprise_cost_center_repositories.go @@ -89,28 +89,27 @@ func resourceGithubEnterpriseCostCenterRepositoriesUpdate(ctx context.Context, d return diag.FromErr(err) } - currentRepos := make(map[string]bool) + diff := make(map[string]bool) for _, ccResource := range cc.Resources { if ccResource != nil && ccResource.Type == CostCenterResourceTypeRepo { - currentRepos[ccResource.Name] = true + diff[ccResource.Name] = false } } - desiredReposSet := d.Get("repository_names").(*schema.Set) - desiredRepos := make(map[string]bool) - for _, repo := range desiredReposSet.List() { - desiredRepos[repo.(string)] = true - } - - var toAdd, toRemove []string - for repo := range desiredRepos { - if !currentRepos[repo] { - toAdd = append(toAdd, repo) + var toAdd []string + for _, repo := range d.Get("repository_names").(*schema.Set).List() { + name := repo.(string) + if _, exists := diff[name]; exists { + diff[name] = true + } else { + toAdd = append(toAdd, name) } } - for repo := range currentRepos { - if !desiredRepos[repo] { - toRemove = append(toRemove, repo) + + var toRemove []string + for name, keep := range diff { + if !keep { + toRemove = append(toRemove, name) } } diff --git a/github/resource_github_enterprise_cost_center_users.go b/github/resource_github_enterprise_cost_center_users.go index fb4af6625a..7df751ddc0 100644 --- a/github/resource_github_enterprise_cost_center_users.go +++ b/github/resource_github_enterprise_cost_center_users.go @@ -89,28 +89,27 @@ func resourceGithubEnterpriseCostCenterUsersUpdate(ctx context.Context, d *schem return diag.FromErr(err) } - currentUsers := make(map[string]bool) + diff := make(map[string]bool) for _, ccResource := range cc.Resources { if ccResource != nil && ccResource.Type == CostCenterResourceTypeUser { - currentUsers[ccResource.Name] = true + diff[ccResource.Name] = false } } - desiredUsersSet := d.Get("usernames").(*schema.Set) - desiredUsers := make(map[string]bool) - for _, username := range desiredUsersSet.List() { - desiredUsers[username.(string)] = true - } - - var toAdd, toRemove []string - for user := range desiredUsers { - if !currentUsers[user] { - toAdd = append(toAdd, user) + var toAdd []string + for _, user := range d.Get("usernames").(*schema.Set).List() { + name := user.(string) + if _, exists := diff[name]; exists { + diff[name] = true + } else { + toAdd = append(toAdd, name) } } - for user := range currentUsers { - if !desiredUsers[user] { - toRemove = append(toRemove, user) + + var toRemove []string + for name, keep := range diff { + if !keep { + toRemove = append(toRemove, name) } } From 34031c6e1862b49c2d6826030707ea6d046b7520 Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Tue, 24 Feb 2026 09:00:33 +0100 Subject: [PATCH 32/33] fix(cost-center): Delete removes all linked resources from API Instead of reading resource names from Terraform state (which may be stale or incomplete), Delete now calls GetCostCenter to fetch the current list of resources of the managed type from the API and removes them all. Also handles 404 gracefully (cost center already gone). --- ...ithub_enterprise_cost_center_organizations.go | 16 ++++++++++++++-- ...github_enterprise_cost_center_repositories.go | 16 ++++++++++++++-- ...source_github_enterprise_cost_center_users.go | 16 ++++++++++++++-- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/github/resource_github_enterprise_cost_center_organizations.go b/github/resource_github_enterprise_cost_center_organizations.go index ebf5935b49..94e49fe730 100644 --- a/github/resource_github_enterprise_cost_center_organizations.go +++ b/github/resource_github_enterprise_cost_center_organizations.go @@ -181,8 +181,20 @@ func resourceGithubEnterpriseCostCenterOrganizationsDelete(ctx context.Context, enterpriseSlug := d.Get("enterprise_slug").(string) costCenterID := d.Get("cost_center_id").(string) - organizationsSet := d.Get("organization_logins").(*schema.Set) - organizations := expandStringList(organizationsSet.List()) + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + if errIs404(err) { + return nil + } + return diag.FromErr(err) + } + + var organizations []string + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeOrg { + organizations = append(organizations, ccResource.Name) + } + } if len(organizations) > 0 { tflog.Info(ctx, "Removing all organizations from cost center", map[string]any{ diff --git a/github/resource_github_enterprise_cost_center_repositories.go b/github/resource_github_enterprise_cost_center_repositories.go index 4679fef837..14fdc672f1 100644 --- a/github/resource_github_enterprise_cost_center_repositories.go +++ b/github/resource_github_enterprise_cost_center_repositories.go @@ -181,8 +181,20 @@ func resourceGithubEnterpriseCostCenterRepositoriesDelete(ctx context.Context, d enterpriseSlug := d.Get("enterprise_slug").(string) costCenterID := d.Get("cost_center_id").(string) - repositoriesSet := d.Get("repository_names").(*schema.Set) - repositories := expandStringList(repositoriesSet.List()) + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + if errIs404(err) { + return nil + } + return diag.FromErr(err) + } + + var repositories []string + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeRepo { + repositories = append(repositories, ccResource.Name) + } + } if len(repositories) > 0 { tflog.Info(ctx, "Removing all repositories from cost center", map[string]any{ diff --git a/github/resource_github_enterprise_cost_center_users.go b/github/resource_github_enterprise_cost_center_users.go index 7df751ddc0..ba05017e95 100644 --- a/github/resource_github_enterprise_cost_center_users.go +++ b/github/resource_github_enterprise_cost_center_users.go @@ -181,8 +181,20 @@ func resourceGithubEnterpriseCostCenterUsersDelete(ctx context.Context, d *schem enterpriseSlug := d.Get("enterprise_slug").(string) costCenterID := d.Get("cost_center_id").(string) - usernamesSet := d.Get("usernames").(*schema.Set) - usernames := expandStringList(usernamesSet.List()) + cc, _, err := client.Enterprise.GetCostCenter(ctx, enterpriseSlug, costCenterID) + if err != nil { + if errIs404(err) { + return nil + } + return diag.FromErr(err) + } + + var usernames []string + for _, ccResource := range cc.Resources { + if ccResource != nil && ccResource.Type == CostCenterResourceTypeUser { + usernames = append(usernames, ccResource.Name) + } + } if len(usernames) > 0 { tflog.Info(ctx, "Removing all users from cost center", map[string]any{ From e7db627fd7b0f69377152e4fc4d1edab14feb21d Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Tue, 24 Feb 2026 09:06:22 +0100 Subject: [PATCH 33/33] fix(cost-center): add missing sub-resources to website sidebar The cost center sub-resources (organizations, repositories, users) were registered in provider.go and had docs, but were accidentally omitted from the website sidebar navigation. --- website/github.erb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/website/github.erb b/website/github.erb index de41097597..2c5dd1d4b4 100644 --- a/website/github.erb +++ b/website/github.erb @@ -316,6 +316,15 @@
  • github_enterprise_cost_center
  • +
  • + github_enterprise_cost_center_organizations +
  • +
  • + github_enterprise_cost_center_repositories +
  • +
  • + github_enterprise_cost_center_users +
  • github_enterprise_organization