-
Notifications
You must be signed in to change notification settings - Fork 961
feat: add github_repository_custom_properties resource for batch property management #3237
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 4 commits
6892990
26bf410
e6829dc
45fe96e
18c09b5
c1fcd97
03d7c07
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,334 @@ | ||
| package github | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
|
|
||
| "github.com/google/go-github/v84/github" | ||
| "github.com/hashicorp/terraform-plugin-log/tflog" | ||
| "github.com/hashicorp/terraform-plugin-sdk/v2/diag" | ||
| "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" | ||
| "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" | ||
| ) | ||
|
|
||
| func resourceGithubRepositoryCustomProperties() *schema.Resource { | ||
| return &schema.Resource{ | ||
| Description: "Manages custom properties for a GitHub repository. This resource allows you to set multiple custom property values on a single repository in a single resource block, with in-place updates when values change.", | ||
|
|
||
| CreateContext: resourceGithubRepositoryCustomPropertiesCreate, | ||
| ReadContext: resourceGithubRepositoryCustomPropertiesRead, | ||
| UpdateContext: resourceGithubRepositoryCustomPropertiesUpdate, | ||
| DeleteContext: resourceGithubRepositoryCustomPropertiesDelete, | ||
| Importer: &schema.ResourceImporter{ | ||
| StateContext: resourceGithubRepositoryCustomPropertiesImport, | ||
| }, | ||
|
|
||
| CustomizeDiff: customdiff.All( | ||
| diffRepository, | ||
| ), | ||
|
|
||
| Schema: map[string]*schema.Schema{ | ||
| "repository": { | ||
| Type: schema.TypeString, | ||
| Required: true, | ||
| Description: "Name of the repository.", | ||
| }, | ||
| "repository_id": { | ||
| Type: schema.TypeInt, | ||
| Computed: true, | ||
| Description: "The ID of the GitHub repository.", | ||
| }, | ||
| "property": { | ||
| Type: schema.TypeSet, | ||
| Required: true, | ||
| MinItems: 1, | ||
| Description: "Set of custom property values for this repository.", | ||
| Elem: &schema.Resource{ | ||
| Schema: map[string]*schema.Schema{ | ||
| "name": { | ||
| Type: schema.TypeString, | ||
| Required: true, | ||
| Description: "Name of the custom property (must be defined at the organization level).", | ||
| }, | ||
| "value": { | ||
| Type: schema.TypeSet, | ||
| Required: true, | ||
| MinItems: 1, | ||
| Description: "Value(s) of the custom property. For multi_select properties, multiple values can be specified.", | ||
| Elem: &schema.Schema{ | ||
| Type: schema.TypeString, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| Set: resourceGithubRepositoryCustomPropertiesHash, | ||
| }, | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| // resourceGithubRepositoryCustomPropertiesHash creates a hash for a property block | ||
| // using only the property name, so that value changes are detected as in-place | ||
| // updates rather than remove+add within the set. | ||
| func resourceGithubRepositoryCustomPropertiesHash(v any) int { | ||
| raw := v.(map[string]any) | ||
| name := raw["name"].(string) | ||
| return schema.HashString(name) | ||
| } | ||
|
|
||
| func resourceGithubRepositoryCustomPropertiesApply(ctx context.Context, d *schema.ResourceData, meta any) error { | ||
| client := meta.(*Owner).v3client | ||
| owner := meta.(*Owner).name | ||
| repoName := d.Get("repository").(string) | ||
| properties := d.Get("property").(*schema.Set).List() | ||
|
|
||
| // Get all organization custom property definitions to determine types | ||
| orgProperties, _, err := client.Organizations.GetAllCustomProperties(ctx, owner) | ||
| if err != nil { | ||
| return fmt.Errorf("error reading organization custom property definitions: %w", err) | ||
| } | ||
|
|
||
| // Create a map of property names to their types | ||
| propertyTypes := make(map[string]github.PropertyValueType) | ||
| for _, prop := range orgProperties { | ||
| if prop.PropertyName != nil { | ||
| propertyTypes[*prop.PropertyName] = prop.ValueType | ||
| } | ||
| } | ||
|
|
||
| // Build custom property values for this repository | ||
| customProperties := make([]*github.CustomPropertyValue, 0, len(properties)) | ||
|
|
||
| for _, propBlock := range properties { | ||
| propMap := propBlock.(map[string]any) | ||
| propertyName := propMap["name"].(string) | ||
| propertyValues := expandStringList(propMap["value"].(*schema.Set).List()) | ||
|
|
||
| propertyType, ok := propertyTypes[propertyName] | ||
| if !ok { | ||
| return fmt.Errorf("custom property %q is not defined at the organization level", propertyName) | ||
| } | ||
|
|
||
| customProperty := &github.CustomPropertyValue{ | ||
| PropertyName: propertyName, | ||
| } | ||
|
|
||
| switch propertyType { | ||
| case github.PropertyValueTypeMultiSelect: | ||
| customProperty.Value = propertyValues | ||
| case github.PropertyValueTypeString, github.PropertyValueTypeSingleSelect, | ||
| github.PropertyValueTypeTrueFalse, github.PropertyValueTypeURL: | ||
| if len(propertyValues) > 0 { | ||
| customProperty.Value = propertyValues[0] | ||
| } | ||
| default: | ||
| return fmt.Errorf("unsupported property type %q for property %q", propertyType, propertyName) | ||
| } | ||
|
|
||
| customProperties = append(customProperties, customProperty) | ||
| } | ||
|
|
||
| _, err = client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repoName, customProperties) | ||
| if err != nil { | ||
| return fmt.Errorf("error setting custom properties for repository %s/%s: %w", owner, repoName, err) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func resourceGithubRepositoryCustomPropertiesCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { | ||
| err := checkOrganization(meta) | ||
| if err != nil { | ||
| return diag.FromErr(err) | ||
| } | ||
|
|
||
| owner := meta.(*Owner).name | ||
| client := meta.(*Owner).v3client | ||
| repoName := d.Get("repository").(string) | ||
|
|
||
| if err := resourceGithubRepositoryCustomPropertiesApply(ctx, d, meta); err != nil { | ||
| return diag.FromErr(err) | ||
| } | ||
|
|
||
| id, err := buildID(owner, repoName) | ||
| if err != nil { | ||
| return diag.FromErr(err) | ||
| } | ||
| d.SetId(id) | ||
|
|
||
| repo, _, err := client.Repositories.Get(ctx, owner, repoName) | ||
| if err != nil { | ||
| return diag.FromErr(err) | ||
| } | ||
|
|
||
| if err := d.Set("repository_id", int(repo.GetID())); err != nil { | ||
| return diag.FromErr(err) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func resourceGithubRepositoryCustomPropertiesUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { | ||
| err := checkOrganization(meta) | ||
| if err != nil { | ||
| return diag.FromErr(err) | ||
| } | ||
|
|
||
| if err := resourceGithubRepositoryCustomPropertiesApply(ctx, d, meta); err != nil { | ||
| return diag.FromErr(err) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func resourceGithubRepositoryCustomPropertiesRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { | ||
| err := checkOrganization(meta) | ||
| if err != nil { | ||
| return diag.FromErr(err) | ||
| } | ||
|
|
||
| ctx = tflog.SetField(ctx, "id", d.Id()) | ||
|
|
||
| client := meta.(*Owner).v3client | ||
| owner := meta.(*Owner).name | ||
|
|
||
| _, repoName, err := parseID2(d.Id()) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Instead of parsing the repo name from Id it's recommended to use d.Get the field since it exists |
||
| if err != nil { | ||
| return diag.FromErr(err) | ||
| } | ||
|
|
||
| // Get current properties from state to know which ones we're managing. | ||
| // On import this will be empty, which is handled below. | ||
| propertiesFromState := d.Get("property").(*schema.Set).List() | ||
| managedPropertyNames := make(map[string]bool) | ||
| for _, propBlock := range propertiesFromState { | ||
| propMap := propBlock.(map[string]any) | ||
| managedPropertyNames[propMap["name"].(string)] = true | ||
| } | ||
|
|
||
| isImport := len(managedPropertyNames) == 0 | ||
|
|
||
| // Read actual properties from GitHub | ||
| allCustomProperties, _, err := client.Repositories.GetAllCustomPropertyValues(ctx, owner, repoName) | ||
|
deiga marked this conversation as resolved.
|
||
| if err != nil { | ||
| return diag.FromErr(fmt.Errorf("error reading custom properties for repository %s/%s: %w", owner, repoName, err)) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can use diag.Errorf |
||
| } | ||
|
|
||
| managedProperties, err := filterManagedCustomProperties(allCustomProperties, managedPropertyNames, isImport) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue: this makes the resource non-authoritative which can encourage patterns that cause constant drift and confusion.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The resource is intentionally non-authoritative: it only manages the properties explicitly declared in the Why non-authoritative:
Regarding drift: only the declared properties are tracked, so there's no "phantom drift" from externally managed properties. If someone changes a managed property outside Terraform, the next plan correctly detects and fixes it. I've updated the documentation and the resource description to explicitly call out the non-authoritative behavior so it's clear to users. See this commit. |
||
| if err != nil { | ||
| return diag.FromErr(fmt.Errorf("error processing custom properties for repository %s/%s: %w", owner, repoName, err)) | ||
| } | ||
|
|
||
| // If no properties exist, remove resource from state | ||
| if len(managedProperties) == 0 { | ||
| tflog.Warn(ctx, "No custom properties found, removing from state", map[string]any{"owner": owner, "repository": repoName}) | ||
| d.SetId("") | ||
| return nil | ||
| } | ||
|
Comment on lines
+226
to
+231
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: I'm wondering if this makes sense as it will cause the resource to be recreated unnecessarily?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This guard handles the edge case where all managed properties have been removed externally (e.g., the org-level property definitions were deleted). Since the resource has nothing left to manage, removing it from state makes Terraform surface the discrepancy on the next plan — the user sees the resource will be recreated, which prompts them to either fix the org definitions or remove the resource from their config. Without this, the Read would silently succeed with an empty property set, which would conflict with the That said, I'm open to alternative approaches — e.g., returning a warning diagnostic instead of removing from state, if you think that would be less surprising. |
||
|
|
||
| if err := d.Set("repository", repoName); err != nil { | ||
| return diag.FromErr(err) | ||
| } | ||
| if err := d.Set("property", managedProperties); err != nil { | ||
| return diag.FromErr(err) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| // filterManagedCustomProperties builds the property set from GitHub API results, | ||
| // filtering to only managed properties (or all properties during import). | ||
| func filterManagedCustomProperties(allProps []*github.CustomPropertyValue, managed map[string]bool, isImport bool) ([]any, error) { | ||
| result := make([]any, 0) | ||
| for _, prop := range allProps { | ||
| if !isImport && !managed[prop.PropertyName] { | ||
| continue | ||
| } | ||
|
|
||
| if prop.Value == nil { | ||
| continue | ||
| } | ||
|
|
||
| propertyValue, err := parseRepositoryCustomPropertyValueToStringSlice(prop) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("error parsing property %q: %w", prop.PropertyName, err) | ||
| } | ||
|
|
||
| if len(propertyValue) == 0 { | ||
| continue | ||
| } | ||
|
|
||
| result = append(result, map[string]any{ | ||
| "name": prop.PropertyName, | ||
| "value": propertyValue, | ||
| }) | ||
| } | ||
| return result, nil | ||
| } | ||
|
|
||
| func resourceGithubRepositoryCustomPropertiesDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { | ||
| err := checkOrganization(meta) | ||
| if err != nil { | ||
| return diag.FromErr(err) | ||
| } | ||
|
|
||
| client := meta.(*Owner).v3client | ||
| owner := meta.(*Owner).name | ||
|
|
||
| _, repoName, err := parseID2(d.Id()) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: it's advisable to use state values instead of parsing the Id |
||
| if err != nil { | ||
| return diag.FromErr(err) | ||
| } | ||
|
|
||
| properties := d.Get("property").(*schema.Set).List() | ||
| if len(properties) == 0 { | ||
| return nil | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: What use case does this guard against? The Schema defines |
||
|
|
||
| // Set all managed properties to nil (removes them) | ||
| customProperties := make([]*github.CustomPropertyValue, 0, len(properties)) | ||
| for _, propBlock := range properties { | ||
| propMap := propBlock.(map[string]any) | ||
| customProperties = append(customProperties, &github.CustomPropertyValue{ | ||
| PropertyName: propMap["name"].(string), | ||
| Value: nil, | ||
| }) | ||
| } | ||
|
|
||
| _, err = client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repoName, customProperties) | ||
| if err != nil { | ||
| return diag.FromErr(fmt.Errorf("error deleting custom properties for repository %s/%s: %w", owner, repoName, err)) | ||
| } | ||
|
Comment on lines
+299
to
+305
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue: This also needs to check if the repo is deleted or archived, in which case the error is not an error |
||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func resourceGithubRepositoryCustomPropertiesImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { | ||
| // Import ID format: <repository> — owner is inferred from the provider config. | ||
| // On import, Read will detect empty state and import ALL properties. | ||
| repoName := d.Id() | ||
|
|
||
| owner := meta.(*Owner).name | ||
| client := meta.(*Owner).v3client | ||
|
|
||
| id, err := buildID(owner, repoName) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| d.SetId(id) | ||
|
|
||
| if err := d.Set("repository", repoName); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| repo, _, err := client.Repositories.Get(ctx, owner, repoName) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to retrieve repository %s: %w", repoName, err) | ||
| } | ||
|
|
||
| if err := d.Set("repository_id", int(repo.GetID())); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return []*schema.ResourceData{d}, nil | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.