Skip to content
1 change: 1 addition & 0 deletions github/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ func Provider() *schema.Provider {
"github_repository_collaborator": resourceGithubRepositoryCollaborator(),
"github_repository_collaborators": resourceGithubRepositoryCollaborators(),
"github_repository_custom_property": resourceGithubRepositoryCustomProperty(),
"github_repository_custom_properties": resourceGithubRepositoryCustomProperties(),
"github_repository_deploy_key": resourceGithubRepositoryDeployKey(),
"github_repository_deployment_branch_policy": resourceGithubRepositoryDeploymentBranchPolicy(),
"github_repository_environment": resourceGithubRepositoryEnvironment(),
Expand Down
334 changes: 334 additions & 0 deletions github/resource_github_repository_custom_properties.go
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)
}
Comment thread
deiga marked this conversation as resolved.

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())
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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)
Comment thread
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))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use diag.Errorf

}

managedProperties, err := filterManagedCustomProperties(allCustomProperties, managedPropertyNames, isImport)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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.
What do you think about this?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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 property {} blocks and ignores everything else on the repository.

Why non-authoritative:

  • Organizations often have custom properties managed by multiple teams, tools, or projects. Authoritative behavior would force all properties into a single resource block, which is impractical and fragile.
  • An authoritative resource that deletes unmanaged properties on apply can be destructive, especially during gradual adoption.
  • This matches how the existing singular github_repository_custom_property already works — it doesn't touch other properties either.
  • Many mature providers (e.g. GCP IAM bindings, AWS) offer non-authoritative variants for exactly this reason.

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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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?
If this was an authorative resource I could fathom the purpose.
Could you elaborate on the use-case here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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 MinItems: 1 constraint and could cause confusing plan output.

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())
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: What use case does this guard against? The Schema defines property as min 1 item, so we should never have 0 properties, right?


// 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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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
}
Loading