[FEAT] Add support for Enterprise Cost Centers#3000
[FEAT] Add support for Enterprise Cost Centers#3000vmvarela wants to merge 33 commits intointegrations:mainfrom
Conversation
|
👋 Hi! Thank you for this contribution! Just to let you know, our GitHub SDK team does a round of issue and PR reviews twice a week, every Monday and Friday! We have a process in place for prioritizing and responding to your input. Because you are a part of this community please feel free to comment, add to, or pick up any issues/PRs that are labeled with |
|
We're trying to move towards using Context-aware provider functions, could you update all schemas to use those? So ReadContext etc |
13dcd40 to
1ab2c0f
Compare
There was a problem hiding this comment.
Pull request overview
This PR adds comprehensive support for GitHub Enterprise Cost Centers, enabling Terraform management of cost center resources and their assignments. The implementation addresses issue #2739 by leveraging the newly public GitHub Enterprise billing API.
Key changes:
- Introduces
github_enterprise_cost_centerresource for creating, updating, and archiving cost centers with authoritative management of user, organization, and repository assignments - Adds
github_enterprise_cost_centerandgithub_enterprise_cost_centersdata sources for querying cost center information - Implements robust resource assignment synchronization with batching (50 resources per request) and retry logic for transient failures
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| website/github.erb | Adds navigation links for the new data sources and resource |
| website/docs/r/enterprise_cost_center.html.markdown | Documents the cost center resource with examples, arguments, attributes, and import instructions |
| website/docs/d/enterprise_cost_center.html.markdown | Documents the single cost center data source for retrieving by ID |
| website/docs/d/enterprise_cost_centers.html.markdown | Documents the data source for listing cost centers with optional state filtering |
| github/util_cost_centers.go | Provides utility functions for API interactions including CRUD operations and resource assignment management |
| github/resource_github_enterprise_cost_center.go | Implements the cost center resource with full lifecycle management and authoritative assignment synchronization |
| github/provider.go | Registers the new resource and data sources with the provider |
| github/resource_github_enterprise_cost_center_test.go | Provides acceptance tests covering create, update, assignment changes, and import scenarios |
| github/data_source_github_enterprise_cost_centers_test.go | Tests the list data source with state filtering |
| github/data_source_github_enterprise_cost_centers.go | Implements the data source for listing cost centers |
| github/data_source_github_enterprise_cost_center_test.go | Tests the single cost center data source retrieval |
| github/data_source_github_enterprise_cost_center.go | Implements the data source for retrieving a specific cost center by ID |
| examples/cost_centers/main.tf | Provides a comprehensive example demonstrating resource and data source usage |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
I built and tested your branch @vmvarela and it works great! I was able to read, import, and create cost centers and resources using your examples and a few of my own. Thank you for the work. |
a3e128c to
0bdad0f
Compare
3314826 to
1d13386
Compare
deiga
left a comment
There was a problem hiding this comment.
Half-way through. Please consider if my comments could apply to other places in your changes as well, I wasn't able to comment on every duplicate thing
vmvarela
left a comment
There was a problem hiding this comment.
Thank you for the thorough review @deiga! I've addressed all the feedback. Here's a summary of the changes:
Implemented:
- ✅ Added top-level
Descriptionattribute to all resources and data sources - ✅ Removed
ctx = context.WithValue(ctx, ctxId, ...)pattern from all files - ✅ Replaced
log.Printfwithtflog.Infoand addedtflog.Warnfor 404 removals - ✅ Used
diag.Errorfinstead ofdiag.FromErr(fmt.Errorf(...)) - ✅ Create and Update no longer call Read - computed fields are set directly
- ✅ Extracted anonymous retry functions to named functions (
retryCostCenterAddResources,retryCostCenterRemoveResources) - ✅ Changed import ID separator from
/to:and usingparseTwoPartID - ✅ Replaced
stringSliceToAnySlicewith existingflattenStringList - ✅ Used
http.StatusNotFoundand other constants instead of magic numbers - ✅ Moved
is404toutil.go - ✅ Updated tests to use
providerFactoriesandskipUnlessEnterprise(t) - ✅ Added
owner = var.enterprise_slugto example provider config - ✅ Used
buildTwoPartIDin data source - ✅ Inlined check variables in tests
Regarding questions:
- The
resourcesattribute provides the raw API view whileusers,organizations,repositoriesare filtered convenience attributes. I kept both for flexibility and debugging purposes. diffStringSlicesdiffers fromsetChanges- it compares string slices directly whilesetChangesworks with Terraform state changes. They serve different purposes.- In Create, we only fetch from API after assignments are configured to populate computed fields, since we're not calling Read anymore.
All tests pass and the code compiles without errors.
b66a9c0 to
2763ff4
Compare
deiga
left a comment
There was a problem hiding this comment.
Please pay attention to what I'm asking form you and make sure to make similar changes in other places where they might apply.
Consider these also for your other PRs
52a3d8a to
5e58a8c
Compare
5e58a8c to
0143ec1
Compare
- 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
Add example Terraform configuration demonstrating how to: - Create a cost center - Assign users, organizations, and repositories - Use data sources to query cost centers
Update import paths from go-github/v81 to go-github/v82 to match the current version in upstream/main.
Co-authored-by: Timo Sand <timo.sand@iki.fi>
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.
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.
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.
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.
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 integrations#8-9.
…tions 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.
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.
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.
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.
…esourcesPerRequest
5cf15cb to
8395f25
Compare
stevehipwell
left a comment
There was a problem hiding this comment.
I'm assuming that you've used a consistent pattern so I've reviewed up to the first assignment resource.
| } | ||
|
|
||
| func resourceGithubEnterpriseCostCenterImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { | ||
| enterpriseSlug, costCenterID, err := parseID2(d.Id()) |
There was a problem hiding this comment.
You need the name here, either in the ID or you'll need to lookup as it must be set or it'll cause a recreate churn. You should have a test to catch this?
There was a problem hiding this comment.
Fixed — Import now calls GetCostCenter to populate name from the API before returning. See commit 757c071.
| id, err := buildID(enterpriseSlug, costCenterID) | ||
| if err != nil { | ||
| return diag.FromErr(err) | ||
| } | ||
| d.SetId(id) |
There was a problem hiding this comment.
The ID here is inconsistent with the cost center resource, they should match. Also the ID shouldn't be set until the API call has been made successfully.
There was a problem hiding this comment.
Fixed — Sub-resources now use the plain costCenterID as ID (consistent with the main resource), and SetId is called after the API call succeeds. See commit c27c343.
| desiredOrgsSet := d.Get("organization_logins").(*schema.Set) | ||
| toAdd := expandStringList(desiredOrgsSet.List()) | ||
|
|
||
| if len(toAdd) > 0 { |
There was a problem hiding this comment.
This shouldn't pass validation, so is unnecessary. That said I'd expect this resource to error if there are already organizations assigned. It would also cause churn due to how the update function is implemented.
There was a problem hiding this comment.
Agreed — Create now calls GetCostCenter first and returns an error if the cost center already has resources of the managed type assigned, asking the user to import or remove them manually. See commit bd51909.
I also took the opportunity to refactor Update to use a single-map diff pattern (9cc1b71) and rewrite Delete to fetch current assignments from the API instead of relying on state (34031c6).
| currentOrgs := make(map[string]bool) | ||
| 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 _, org := range desiredOrgsSet.List() { | ||
| desiredOrgs[org.(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) | ||
| } | ||
| } |
There was a problem hiding this comment.
| currentOrgs := make(map[string]bool) | |
| 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 _, org := range desiredOrgsSet.List() { | |
| desiredOrgs[org.(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) | |
| } | |
| } | |
| toAdd := make([]string) | |
| toRemove := make([]string) | |
| current := make(map[string]bool) | |
| for _, ccResource := range cc.Resources { | |
| if ccResource != nil && ccResource.Type == CostCenterResourceTypeOrg { | |
| current[ccResource.Name] = false | |
| } | |
| } | |
| desiredOrgsSet := d.Get("organization_logins").(*schema.Set) | |
| for _, o:= range desiredOrgsSet.List() { | |
| org := o.(string) | |
| if _, ok := current[org]; ok { | |
| current[org] = true | |
| } else { | |
| toAdd = append(toAdd, org) | |
| } | |
| } | |
| for org, keep := range current { | |
| if !keep { | |
| toRemove = append(toRemove, org) | |
| } | |
| } |
I think a pattern like this is easier to read.
There was a problem hiding this comment.
Adopted — refactored to this exact pattern in all 3 sub-resources. See commit 9cc1b71.
| costCenterID := d.Get("cost_center_id").(string) | ||
|
|
||
| organizationsSet := d.Get("organization_logins").(*schema.Set) | ||
| organizations := expandStringList(organizationsSet.List()) |
There was a problem hiding this comment.
I think this logic is incorrect. As this is an authoritative resource, all linked organizations should be removed.
There was a problem hiding this comment.
Addressed in 34031c6 — Delete now fetches all linked organizations from the API (not from state) before removing them. Same pattern applied to users and repositories sub-resources.
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.
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.
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.
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.
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).
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.
|
Hey @deiga @stevehipwell — quick naming question before we finalize. Would you think it's appropriate to add The rename would be: Resources:
Data sources:
The change is straightforward — just a rename across files, provider registration, and docs. Happy to keep the current names if you prefer; just wanted to get your take before tagging a release. |
Resolves #2739
Before the change?
After the change?
NOTE: This API (billing) has no support for APP or fine-grained tokens.
Pull request checklist
Does this introduce a breaking change?
Please see our docs on breaking changes to help!