From 5968b5e1261cc088eccc33485913a6ae1120f656 Mon Sep 17 00:00:00 2001 From: John Miller Date: Tue, 7 Apr 2026 13:13:10 -0600 Subject: [PATCH 1/3] feat: Add custom_properties to github_repository and github_enterprise_custom_property resource (#3230, #3304) Allow custom properties to be set on repositories at creation time, fixing 422 errors when an organization enforces required custom properties. Adds a new github_enterprise_custom_property resource and data source for managing custom property definitions at the enterprise level. Uses context-aware CRUD functions, proper 404 handling, and ConfigStateChecks in acceptance tests per maintainer guidelines. Closes #3230, #3304 --- ...ource_github_enterprise_custom_property.go | 114 ++++++++ github/provider.go | 2 + ...rce_github_enterprise_custom_properties.go | 244 ++++++++++++++++++ ...ithub_enterprise_custom_properties_test.go | 199 ++++++++++++++ github/resource_github_repository.go | 17 ++ .../enterprise_custom_property.html.markdown | 59 +++++ .../enterprise_custom_property.html.markdown | 99 +++++++ website/docs/r/repository.html.markdown | 16 ++ 8 files changed, 750 insertions(+) create mode 100644 github/data_source_github_enterprise_custom_property.go create mode 100644 github/resource_github_enterprise_custom_properties.go create mode 100644 github/resource_github_enterprise_custom_properties_test.go create mode 100644 website/docs/d/enterprise_custom_property.html.markdown create mode 100644 website/docs/r/enterprise_custom_property.html.markdown diff --git a/github/data_source_github_enterprise_custom_property.go b/github/data_source_github_enterprise_custom_property.go new file mode 100644 index 0000000000..392819d1f1 --- /dev/null +++ b/github/data_source_github_enterprise_custom_property.go @@ -0,0 +1,114 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v84/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubEnterpriseCustomProperty() *schema.Resource { + return &schema.Resource{ + Description: "Use this data source to retrieve information about a custom property definition for a GitHub enterprise.", + + ReadContext: dataSourceGithubEnterpriseCustomPropertyRead, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + }, + "property_name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the custom property.", + }, + "value_type": { + Type: schema.TypeString, + Computed: true, + Description: "The type of the value for the property.", + }, + "required": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether the custom property is required.", + }, + "default_values": { + Type: schema.TypeList, + Computed: true, + Description: "The default value(s) of the custom property.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "description": { + Type: schema.TypeString, + Computed: true, + Description: "A short description of the custom property.", + }, + "allowed_values": { + Type: schema.TypeList, + Computed: true, + Description: "An ordered list of allowed values for the property.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "values_editable_by": { + Type: schema.TypeString, + Computed: true, + Description: "Who can edit the values of the property. Can be one of 'org_actors' or 'org_and_repo_actors'.", + }, + }, + } +} + +func dataSourceGithubEnterpriseCustomPropertyRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterpriseSlug := d.Get("enterprise_slug").(string) + propertyName := d.Get("property_name").(string) + + property, _, err := client.Enterprise.GetCustomProperty(ctx, enterpriseSlug, propertyName) + if err != nil { + return diag.Errorf("error reading enterprise custom property %s/%s: %v", enterpriseSlug, propertyName, err) + } + + var defaultValues []string + if property.ValueType == github.PropertyValueTypeMultiSelect { + if vals, ok := property.DefaultValueStrings(); ok { + defaultValues = vals + } + } else { + if val, ok := property.DefaultValueString(); ok { + defaultValues = []string{val} + } + } + + d.SetId(buildTwoPartID(enterpriseSlug, propertyName)) + + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + if err := d.Set("property_name", property.GetPropertyName()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("value_type", string(property.ValueType)); err != nil { + return diag.FromErr(err) + } + if err := d.Set("required", property.GetRequired()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("default_values", defaultValues); err != nil { + return diag.FromErr(err) + } + if err := d.Set("description", property.GetDescription()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("allowed_values", property.AllowedValues); err != nil { + return diag.FromErr(err) + } + if err := d.Set("values_editable_by", property.GetValuesEditableBy()); err != nil { + return diag.FromErr(err) + } + + return nil +} diff --git a/github/provider.go b/github/provider.go index 1baf0263ad..5eae9e842e 100644 --- a/github/provider.go +++ b/github/provider.go @@ -219,6 +219,7 @@ func Provider() *schema.Provider { "github_enterprise_actions_workflow_permissions": resourceGithubEnterpriseActionsWorkflowPermissions(), "github_actions_organization_workflow_permissions": resourceGithubActionsOrganizationWorkflowPermissions(), "github_enterprise_security_analysis_settings": resourceGithubEnterpriseSecurityAnalysisSettings(), + "github_enterprise_custom_property": resourceGithubEnterpriseCustomProperties(), "github_workflow_repository_permissions": resourceGithubWorkflowRepositoryPermissions(), }, @@ -296,6 +297,7 @@ func Provider() *schema.Provider { "github_user_external_identity": dataSourceGithubUserExternalIdentity(), "github_users": dataSourceGithubUsers(), "github_enterprise": dataSourceGithubEnterprise(), + "github_enterprise_custom_property": dataSourceGithubEnterpriseCustomProperty(), "github_repository_environment_deployment_policies": dataSourceGithubRepositoryEnvironmentDeploymentPolicies(), }, } diff --git a/github/resource_github_enterprise_custom_properties.go b/github/resource_github_enterprise_custom_properties.go new file mode 100644 index 0000000000..81dbe27b26 --- /dev/null +++ b/github/resource_github_enterprise_custom_properties.go @@ -0,0 +1,244 @@ +package github + +import ( + "context" + "log" + "net/http" + + "github.com/google/go-github/v84/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 resourceGithubEnterpriseCustomProperties() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceGithubEnterpriseCustomPropertiesCreate, + ReadContext: resourceGithubEnterpriseCustomPropertiesRead, + UpdateContext: resourceGithubEnterpriseCustomPropertiesUpdate, + DeleteContext: resourceGithubEnterpriseCustomPropertiesDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug of the enterprise.", + }, + "property_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The name of the custom property.", + }, + "value_type": { + Type: schema.TypeString, + Required: true, + Description: "The type of the value for the property. Can be one of: 'string', 'single_select', 'multi_select', 'true_false', 'url'.", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{string(github.PropertyValueTypeString), string(github.PropertyValueTypeSingleSelect), string(github.PropertyValueTypeMultiSelect), string(github.PropertyValueTypeTrueFalse), string(github.PropertyValueTypeURL)}, false)), + }, + "required": { + Type: schema.TypeBool, + Optional: true, + Description: "Whether the custom property is required.", + }, + "default_values": { + Type: schema.TypeList, + Optional: true, + Computed: true, + Description: "The default value(s) of the custom property. For 'multi_select' properties, multiple values may be specified. For all other types, provide a single value.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "description": { + Type: schema.TypeString, + Optional: true, + Description: "A short description of the custom property.", + }, + "allowed_values": { + Type: schema.TypeList, + Optional: true, + Computed: true, + Description: "An ordered list of allowed values for the property. Only applicable to 'single_select' and 'multi_select' types.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "values_editable_by": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "Who can edit the values of the property. Can be one of: 'org_actors', 'org_and_repo_actors'. Defaults to 'org_actors'.", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"org_actors", "org_and_repo_actors"}, false)), + }, + }, + } +} + +func resourceGithubEnterpriseCustomPropertiesCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterpriseSlug := d.Get("enterprise_slug").(string) + propertyName := d.Get("property_name").(string) + + property := buildEnterpriseCustomProperty(d) + + _, _, err := client.Enterprise.CreateOrUpdateCustomProperty(ctx, enterpriseSlug, propertyName, property) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(buildTwoPartID(enterpriseSlug, propertyName)) + return resourceGithubEnterpriseCustomPropertiesRead(ctx, d, meta) +} + +func resourceGithubEnterpriseCustomPropertiesRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterpriseSlug, propertyName, err := parseTwoPartID(d.Id(), "enterprise_slug", "property_name") + if err != nil { + return diag.FromErr(err) + } + + property, resp, err := client.Enterprise.GetCustomProperty(ctx, enterpriseSlug, propertyName) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + log.Printf("[INFO] Removing enterprise custom property %s/%s from state because it no longer exists", enterpriseSlug, propertyName) + d.SetId("") + return nil + } + return diag.FromErr(err) + } + + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + if err := d.Set("property_name", property.GetPropertyName()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("value_type", string(property.ValueType)); err != nil { + return diag.FromErr(err) + } + if err := d.Set("required", property.GetRequired()); err != nil { + return diag.FromErr(err) + } + + var defaultValues []string + if property.ValueType == github.PropertyValueTypeMultiSelect { + if vals, ok := property.DefaultValueStrings(); ok { + defaultValues = vals + } + } else { + if val, ok := property.DefaultValueString(); ok { + defaultValues = []string{val} + } + } + if err := d.Set("default_values", defaultValues); err != nil { + return diag.FromErr(err) + } + if err := d.Set("description", property.GetDescription()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("allowed_values", property.AllowedValues); err != nil { + return diag.FromErr(err) + } + if err := d.Set("values_editable_by", property.GetValuesEditableBy()); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseCustomPropertiesUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterpriseSlug, propertyName, err := parseTwoPartID(d.Id(), "enterprise_slug", "property_name") + if err != nil { + return diag.FromErr(err) + } + + property := buildEnterpriseCustomProperty(d) + + _, _, err = client.Enterprise.CreateOrUpdateCustomProperty(ctx, enterpriseSlug, propertyName, property) + if err != nil { + return diag.FromErr(err) + } + + return resourceGithubEnterpriseCustomPropertiesRead(ctx, d, meta) +} + +func resourceGithubEnterpriseCustomPropertiesDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterpriseSlug, propertyName, err := parseTwoPartID(d.Id(), "enterprise_slug", "property_name") + if err != nil { + return diag.FromErr(err) + } + + resp, err := client.Enterprise.RemoveCustomProperty(ctx, enterpriseSlug, propertyName) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil + } + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseCustomPropertiesImport(d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + enterpriseSlug, propertyName, err := parseTwoPartID(d.Id(), "enterprise_slug", "property_name") + if err != nil { + return nil, err + } + + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return nil, err + } + if err := d.Set("property_name", propertyName); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} + +func buildEnterpriseCustomProperty(d *schema.ResourceData) *github.CustomProperty { + propertyName := d.Get("property_name").(string) + valueType := github.PropertyValueType(d.Get("value_type").(string)) + required := d.Get("required").(bool) + description := d.Get("description").(string) + + rawAllowedValues := d.Get("allowed_values").([]any) + allowedValues := make([]string, 0, len(rawAllowedValues)) + for _, v := range rawAllowedValues { + allowedValues = append(allowedValues, v.(string)) + } + + property := &github.CustomProperty{ + PropertyName: &propertyName, + ValueType: valueType, + Required: &required, + Description: &description, + AllowedValues: allowedValues, + } + + rawDefaultValues := d.Get("default_values").([]any) + defaultValues := make([]string, 0, len(rawDefaultValues)) + for _, v := range rawDefaultValues { + defaultValues = append(defaultValues, v.(string)) + } + if len(defaultValues) > 0 { + if valueType == github.PropertyValueTypeMultiSelect { + property.DefaultValue = defaultValues + } else { + property.DefaultValue = defaultValues[0] + } + } + + if val, ok := d.GetOk("values_editable_by"); ok { + str := val.(string) + property.ValuesEditableBy = &str + } + + return property +} diff --git a/github/resource_github_enterprise_custom_properties_test.go b/github/resource_github_enterprise_custom_properties_test.go new file mode 100644 index 0000000000..42224444fa --- /dev/null +++ b/github/resource_github_enterprise_custom_properties_test.go @@ -0,0 +1,199 @@ +package github + +import ( + "fmt" + "regexp" + "testing" + + "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 TestAccGithubEnterpriseCustomPropertiesValidation(t *testing.T) { + t.Run("rejects invalid values_editable_by value", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_enterprise_custom_property" "test" { + enterprise_slug = "%s" + property_name = "%senterprise-prop-invalid-editable-by" + value_type = "string" + values_editable_by = "invalid_value" + }`, testAccConf.enterpriseSlug, testResourcePrefix) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("invalid_value"), + }, + }, + }) + }) + + t.Run("rejects invalid value_type", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_enterprise_custom_property" "test" { + enterprise_slug = "%s" + property_name = "%senterprise-prop-invalid-type" + value_type = "invalid_type" + }`, testAccConf.enterpriseSlug, testResourcePrefix) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("invalid_type"), + }, + }, + }) + }) +} + +func TestAccGithubEnterpriseCustomProperties(t *testing.T) { + t.Run("creates custom property without error", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_enterprise_custom_property" "test" { + enterprise_slug = "%s" + allowed_values = ["Test"] + description = "Test Description" + default_value = "Test" + property_name = "%senterprise-prop-create" + required = true + value_type = "single_select" + }`, testAccConf.enterpriseSlug, testResourcePrefix) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_custom_property.test", tfjsonpath.New("value_type"), knownvalue.StringExact("single_select")), + statecheck.ExpectKnownValue("github_enterprise_custom_property.test", tfjsonpath.New("required"), knownvalue.Bool(true)), + }, + }, + }, + }) + }) + + t.Run("creates and updates a custom property", func(t *testing.T) { + configBefore := fmt.Sprintf(` + resource "github_enterprise_custom_property" "test" { + enterprise_slug = "%s" + allowed_values = ["one"] + description = "Test Description" + property_name = "%senterprise-prop-update" + value_type = "single_select" + }`, testAccConf.enterpriseSlug, testResourcePrefix) + + configAfter := fmt.Sprintf(` + resource "github_enterprise_custom_property" "test" { + enterprise_slug = "%s" + allowed_values = ["one", "two"] + description = "Test Description Updated" + property_name = "%senterprise-prop-update" + value_type = "single_select" + }`, testAccConf.enterpriseSlug, testResourcePrefix) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: configBefore, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_custom_property.test", tfjsonpath.New("allowed_values"), knownvalue.ListSizeExact(1)), + }, + }, + { + Config: configAfter, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_custom_property.test", tfjsonpath.New("allowed_values"), knownvalue.ListSizeExact(2)), + statecheck.ExpectKnownValue("github_enterprise_custom_property.test", tfjsonpath.New("description"), knownvalue.StringExact("Test Description Updated")), + }, + }, + }, + }) + }) + + t.Run("imports enterprise custom property without error", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_enterprise_custom_property" "test" { + enterprise_slug = "%s" + description = "Test Description Import" + property_name = "%senterprise-prop-import" + value_type = "string" + }`, testAccConf.enterpriseSlug, testResourcePrefix) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_custom_property.test", tfjsonpath.New("description"), knownvalue.StringExact("Test Description Import")), + }, + }, + { + ResourceName: "github_enterprise_custom_property.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) + + t.Run("creates custom property with values_editable_by", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_enterprise_custom_property" "test" { + enterprise_slug = "%s" + property_name = "%senterprise-prop-editable-by" + value_type = "string" + description = "Test property for values_editable_by" + values_editable_by = "org_and_repo_actors" + }`, testAccConf.enterpriseSlug, testResourcePrefix) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_custom_property.test", tfjsonpath.New("values_editable_by"), knownvalue.StringExact("org_and_repo_actors")), + }, + }, + }, + }) + }) + + t.Run("defaults values_editable_by to org_actors", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_enterprise_custom_property" "test" { + enterprise_slug = "%s" + property_name = "%senterprise-prop-default-editable-by" + value_type = "string" + description = "Test property without values_editable_by" + }`, testAccConf.enterpriseSlug, testResourcePrefix) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, enterprise) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_custom_property.test", tfjsonpath.New("values_editable_by"), knownvalue.StringExact("org_actors")), + }, + }, + }, + }) + }) +} diff --git a/github/resource_github_repository.go b/github/resource_github_repository.go index e3c2ff339e..e1e585b1ab 100644 --- a/github/resource_github_repository.go +++ b/github/resource_github_repository.go @@ -408,6 +408,13 @@ func resourceGithubRepository() *schema.Resource { Default: false, Deprecated: "This is ignored as the provider now handles lack of permissions automatically. This field will be removed in a future version.", }, + "custom_properties": { + Type: schema.TypeMap, + Optional: true, + Computed: true, + Description: "Custom properties for the repository. Key/value pairs where values must be strings. Use this to satisfy organization-required custom properties at repository creation time.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, "full_name": { Type: schema.TypeString, Computed: true, @@ -647,6 +654,10 @@ func resourceGithubRepositoryObject(d *schema.ResourceData) *github.Repository { } } + if v, ok := d.GetOk("custom_properties"); ok { + repository.CustomProperties = v.(map[string]any) + } + return repository } @@ -914,6 +925,12 @@ func resourceGithubRepositoryRead(ctx context.Context, d *schema.ResourceData, m } } + if len(repo.CustomProperties) > 0 { + if err := d.Set("custom_properties", repo.CustomProperties); err != nil { + return diag.Errorf("error setting custom_properties: %s", err.Error()) + } + } + return nil } diff --git a/website/docs/d/enterprise_custom_property.html.markdown b/website/docs/d/enterprise_custom_property.html.markdown new file mode 100644 index 0000000000..a63dc54bc6 --- /dev/null +++ b/website/docs/d/enterprise_custom_property.html.markdown @@ -0,0 +1,59 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_custom_property" +description: |- + Get information about a GitHub enterprise custom property definition +--- + +# github_enterprise_custom_property + +Use this data source to retrieve information about a custom property definition for a GitHub enterprise. + +## Example Usage + +```hcl +data "github_enterprise_custom_property" "security_tier" { + enterprise_slug = "my-enterprise" + property_name = "securityTier" +} +``` + +## Example Usage - Reference in a Repository + +```hcl +data "github_enterprise_custom_property" "security_tier" { + enterprise_slug = "my-enterprise" + property_name = "securityTier" +} + +resource "github_repository" "example" { + name = "example" + visibility = "private" + + custom_properties = { + (data.github_enterprise_custom_property.security_tier.property_name) = "tier1" + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `enterprise_slug` - (Required) The slug of the enterprise. + +* `property_name` - (Required) The name of the custom property to retrieve. + +## Attributes Reference + +* `value_type` - The type of the value for the property. Can be one of `string`, `single_select`, `multi_select`, `true_false`, or `url`. + +* `required` - Whether the custom property is required on repositories. + +* `description` - A short description of the custom property. + +* `default_values` - The default value(s) of the custom property. For `multi_select` properties this is a list of values; for all other types it is a single-element list. + +* `allowed_values` - An ordered list of allowed values for the property. Only populated when `value_type` is `single_select` or `multi_select`. + +* `values_editable_by` - Who can edit the values of the property. Can be one of `org_actors` or `org_and_repo_actors`. diff --git a/website/docs/r/enterprise_custom_property.html.markdown b/website/docs/r/enterprise_custom_property.html.markdown new file mode 100644 index 0000000000..5aca24f39a --- /dev/null +++ b/website/docs/r/enterprise_custom_property.html.markdown @@ -0,0 +1,99 @@ +--- +layout: "github" +page_title: "GitHub: github_enterprise_custom_property" +description: |- + Creates and manages custom property definitions for a GitHub enterprise +--- + +# github_enterprise_custom_property + +This resource allows you to create and manage custom property definitions for a GitHub enterprise. + +Custom properties enable you to add metadata to repositories across your enterprise. Properties defined at the enterprise level are available to all organizations within the enterprise. You can use them to add context about repositories, such as security classification, compliance requirements, or team ownership. + +~> **Note** You must be an enterprise owner to manage enterprise custom properties. + +## Example Usage + +```hcl +resource "github_enterprise_custom_property" "security_tier" { + enterprise_slug = "my-enterprise" + property_name = "securityTier" + value_type = "single_select" + required = true + description = "Security classification tier for the repository" + allowed_values = ["tier1", "tier2", "tier3"] +} +``` + +## Example Usage - String Property + +```hcl +resource "github_enterprise_custom_property" "owner" { + enterprise_slug = "my-enterprise" + property_name = "owningTeam" + value_type = "string" + required = true + description = "The team responsible for this repository" +} +``` + +## Example Usage - Boolean Property + +```hcl +resource "github_enterprise_custom_property" "contains_pii" { + enterprise_slug = "my-enterprise" + property_name = "containsPII" + value_type = "true_false" + required = false + description = "Whether this repository contains personally identifiable information" + default_values = ["false"] +} +``` + +## Example Usage - Allow Repository Actors to Edit + +```hcl +resource "github_enterprise_custom_property" "team_contact" { + enterprise_slug = "my-enterprise" + property_name = "teamContact" + value_type = "string" + required = false + description = "Contact information for the team managing this repository" + values_editable_by = "org_and_repo_actors" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `enterprise_slug` - (Required, Forces new resource) The slug of the enterprise. + +* `property_name` - (Required, Forces new resource) The name of the custom property. + +* `value_type` - (Required) The type of value for the property. Can be one of `string`, `single_select`, `multi_select`, `true_false`, or `url`. + +* `required` - (Optional) Whether the custom property is required on repositories. Defaults to `false`. + +* `description` - (Optional) A short description of the custom property. + +* `default_values` - (Optional) The default value(s) of the custom property. For `multi_select` properties, multiple values may be specified (e.g. `["b1", "b2"]`). For all other types, provide a single value in a list (e.g. `["value"]`). + +* `allowed_values` - (Optional) An ordered list of allowed values for the property. Only applicable when `value_type` is `single_select` or `multi_select`. Can have up to 200 values. + +* `values_editable_by` - (Optional) Who can edit the values of the property on repositories. Can be one of `org_actors` or `org_and_repo_actors`. When set to `org_actors` (the default), only organization owners can edit property values. When set to `org_and_repo_actors`, repository administrators with the custom properties permission can also edit values. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `property_name` - The name of the custom property. + +## Import + +Enterprise custom properties can be imported using `:`: + +```shell +terraform import github_enterprise_custom_property.security_tier my-enterprise:securityTier +``` diff --git a/website/docs/r/repository.html.markdown b/website/docs/r/repository.html.markdown index 182e98125d..59cf57a5ba 100644 --- a/website/docs/r/repository.html.markdown +++ b/website/docs/r/repository.html.markdown @@ -47,6 +47,20 @@ resource "github_repository" "example" { } ``` +## Example Usage with Required Custom Properties + +```hcl +resource "github_repository" "example" { + name = "example" + visibility = "private" + + custom_properties = { + securityTier = "tier1" + owningTeam = "platform" + } +} +``` + ## Example Usage with Repository Forking ```hcl @@ -146,6 +160,8 @@ initial repository creation and create the target branch inside of the repositor - `allow_update_branch` (Optional) - Set to `true` to always suggest updating pull request branches. +* `custom_properties` - (Optional) A map of custom property key/value pairs to set on the repository. Custom properties must first be defined at the organization or enterprise level. Use this to satisfy required custom properties at repository creation time — if your organization enforces required custom properties, they must be provided here or the creation request will be rejected. Values must be strings; for `single_select` and `true_false` types pass the value as a string (e.g. `"true"`). This attribute is also `Computed`, so GitHub-managed defaults will be reflected in state. + ### GitHub Pages Configuration The `pages` block supports the following: From 288cd77de74099af03aacea17c18a9aaab62d98e Mon Sep 17 00:00:00 2001 From: John Miller Date: Thu, 16 Apr 2026 13:53:31 -0600 Subject: [PATCH 2/3] fix: correct custom_properties lifecycle in github_repository - include custom_properties in create request body to satisfy org-required property enforcement at creation time - read custom_properties via dedicated GetAllCustomPropertyValues endpoint instead of repo.CustomProperties (which GET /repos never populates) - handle partial and full removal in update via CreateOrUpdateCustomProperties with null values; removing the block entirely leaves existing values intact (Optional+Computed behavior) --- github/resource_github_repository.go | 47 ++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/github/resource_github_repository.go b/github/resource_github_repository.go index e1e585b1ab..caf5d7fe55 100644 --- a/github/resource_github_repository.go +++ b/github/resource_github_repository.go @@ -925,8 +925,15 @@ func resourceGithubRepositoryRead(ctx context.Context, d *schema.ResourceData, m } } - if len(repo.CustomProperties) > 0 { - if err := d.Set("custom_properties", repo.CustomProperties); err != nil { + customPropertyValues, _, err := client.Repositories.GetAllCustomPropertyValues(ctx, owner, repoName) + if err == nil { + customProps := make(map[string]string, len(customPropertyValues)) + for _, v := range customPropertyValues { + if strVal, ok := v.Value.(string); ok { + customProps[v.PropertyName] = strVal + } + } + if err := d.Set("custom_properties", customProps); err != nil { return diag.Errorf("error setting custom_properties: %s", err.Error()) } } @@ -979,6 +986,30 @@ func resourceGithubRepositoryUpdate(ctx context.Context, d *schema.ResourceData, } d.SetId(repo.GetName()) // It's possible that `repo.GetName()` is different from `repoName` if the repository is renamed + if d.HasChange("custom_properties") { + newProps := d.Get("custom_properties").(map[string]interface{}) + if len(newProps) > 0 { + if err := setRepositoryCustomProperties(ctx, client, owner, repoName, newProps); err != nil { + return diag.FromErr(err) + } + } else { + // custom_properties was removed from config — clear any previously set values + oldRaw, _ := d.GetChange("custom_properties") + if oldProps, ok := oldRaw.(map[string]interface{}); ok && len(oldProps) > 0 { + clearValues := make([]*github.CustomPropertyValue, 0, len(oldProps)) + for k := range oldProps { + clearValues = append(clearValues, &github.CustomPropertyValue{ + PropertyName: k, + Value: nil, + }) + } + if _, err := client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repoName, clearValues); err != nil { + return diag.FromErr(err) + } + } + } + } + if d.HasChange("pages") && !d.IsNewResource() { opts := expandPagesUpdate(d.Get("pages").([]any)) if opts != nil { @@ -1139,6 +1170,18 @@ func expandPagesUpdate(input []any) *github.PagesUpdate { return update } +func setRepositoryCustomProperties(ctx context.Context, client *github.Client, owner, repoName string, props map[string]any) error { + customPropertyValues := make([]*github.CustomPropertyValue, 0, len(props)) + for k, v := range props { + customPropertyValues = append(customPropertyValues, &github.CustomPropertyValue{ + PropertyName: k, + Value: v, + }) + } + _, err := client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repoName, customPropertyValues) + return err +} + func flattenPages(pages *github.Pages) []any { if pages == nil { return []any{} From 3bb27d2a85a53fd4ec24863e10d4730f6f9199e0 Mon Sep 17 00:00:00 2001 From: John Miller Date: Thu, 16 Apr 2026 13:55:43 -0600 Subject: [PATCH 3/3] test: add acceptance tests for github_repository custom_properties Covers all documented config semantics: setting at creation time, updating values, removing individual map keys, clearing via empty map, and the Optional+Computed no-op when the block is removed entirely. --- ...ithub_repository_custom_properties_test.go | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 github/resource_github_repository_custom_properties_test.go diff --git a/github/resource_github_repository_custom_properties_test.go b/github/resource_github_repository_custom_properties_test.go new file mode 100644 index 0000000000..63c25f3c09 --- /dev/null +++ b/github/resource_github_repository_custom_properties_test.go @@ -0,0 +1,273 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccGithubRepositoryCustomProperties(t *testing.T) { + t.Run("sets custom_properties at creation time", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + propertyName := fmt.Sprintf("tfacc%s", randomID) + repoName := fmt.Sprintf("%scustomprops-%s", testResourcePrefix, randomID) + + config := fmt.Sprintf(` + resource "github_organization_custom_properties" "test" { + allowed_values = ["alpha", "beta"] + property_name = "%[1]s" + value_type = "single_select" + } + + resource "github_repository" "test" { + name = "%[2]s" + auto_init = true + + custom_properties = { + (github_organization_custom_properties.test.property_name) = "alpha" + } + } + `, propertyName, repoName) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_repository.test", "custom_properties.%", "1"), + resource.TestCheckResourceAttr( + "github_repository.test", + fmt.Sprintf("custom_properties.%s", propertyName), + "alpha", + ), + ) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + }) + + t.Run("updates a custom property value", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + propertyName := fmt.Sprintf("tfacc%s", randomID) + repoName := fmt.Sprintf("%scustomprops-%s", testResourcePrefix, randomID) + + configTemplate := ` + resource "github_organization_custom_properties" "test" { + allowed_values = ["alpha", "beta"] + property_name = "%[1]s" + value_type = "single_select" + } + + resource "github_repository" "test" { + name = "%[2]s" + auto_init = true + + custom_properties = { + (github_organization_custom_properties.test.property_name) = "%[3]s" + } + } + ` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(configTemplate, propertyName, repoName, "alpha"), + Check: resource.TestCheckResourceAttr( + "github_repository.test", + fmt.Sprintf("custom_properties.%s", propertyName), + "alpha", + ), + }, + { + Config: fmt.Sprintf(configTemplate, propertyName, repoName, "beta"), + Check: resource.TestCheckResourceAttr( + "github_repository.test", + fmt.Sprintf("custom_properties.%s", propertyName), + "beta", + ), + }, + }, + }) + }) + + t.Run("removes a specific property key", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + propertyA := fmt.Sprintf("tfacca%s", randomID) + propertyB := fmt.Sprintf("tfaccb%s", randomID) + repoName := fmt.Sprintf("%scustomprops-%s", testResourcePrefix, randomID) + + propertiesConfig := fmt.Sprintf(` + resource "github_organization_custom_properties" "a" { + allowed_values = ["one"] + property_name = "%[1]s" + value_type = "single_select" + } + + resource "github_organization_custom_properties" "b" { + allowed_values = ["two"] + property_name = "%[2]s" + value_type = "single_select" + } + `, propertyA, propertyB) + + configBoth := propertiesConfig + fmt.Sprintf(` + resource "github_repository" "test" { + name = "%[1]s" + auto_init = true + + custom_properties = { + (github_organization_custom_properties.a.property_name) = "one" + (github_organization_custom_properties.b.property_name) = "two" + } + } + `, repoName) + + configOnlyA := propertiesConfig + fmt.Sprintf(` + resource "github_repository" "test" { + name = "%[1]s" + auto_init = true + + custom_properties = { + (github_organization_custom_properties.a.property_name) = "one" + } + } + `, repoName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: configBoth, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_repository.test", "custom_properties.%", "2"), + ), + }, + { + Config: configOnlyA, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_repository.test", "custom_properties.%", "1"), + resource.TestCheckResourceAttr( + "github_repository.test", + fmt.Sprintf("custom_properties.%s", propertyA), + "one", + ), + resource.TestCheckNoResourceAttr( + "github_repository.test", + fmt.Sprintf("custom_properties.%s", propertyB), + ), + ), + }, + }, + }) + }) + + t.Run("clears all properties with empty map", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + propertyName := fmt.Sprintf("tfacc%s", randomID) + repoName := fmt.Sprintf("%scustomprops-%s", testResourcePrefix, randomID) + + propertiesConfig := fmt.Sprintf(` + resource "github_organization_custom_properties" "test" { + allowed_values = ["alpha"] + property_name = "%[1]s" + value_type = "single_select" + } + `, propertyName) + + configWithValue := propertiesConfig + fmt.Sprintf(` + resource "github_repository" "test" { + name = "%[1]s" + auto_init = true + + custom_properties = { + (github_organization_custom_properties.test.property_name) = "alpha" + } + } + `, repoName) + + configEmpty := propertiesConfig + fmt.Sprintf(` + resource "github_repository" "test" { + name = "%[1]s" + auto_init = true + + custom_properties = {} + } + `, repoName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: configWithValue, + Check: resource.TestCheckResourceAttr("github_repository.test", "custom_properties.%", "1"), + }, + { + Config: configEmpty, + Check: resource.TestCheckResourceAttr("github_repository.test", "custom_properties.%", "0"), + }, + }, + }) + }) + + t.Run("removing the block leaves values untouched", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + propertyName := fmt.Sprintf("tfacc%s", randomID) + repoName := fmt.Sprintf("%scustomprops-%s", testResourcePrefix, randomID) + + propertiesConfig := fmt.Sprintf(` + resource "github_organization_custom_properties" "test" { + allowed_values = ["alpha"] + property_name = "%[1]s" + value_type = "single_select" + } + `, propertyName) + + configWithValue := propertiesConfig + fmt.Sprintf(` + resource "github_repository" "test" { + name = "%[1]s" + auto_init = true + + custom_properties = { + (github_organization_custom_properties.test.property_name) = "alpha" + } + } + `, repoName) + + configNoBlock := propertiesConfig + fmt.Sprintf(` + resource "github_repository" "test" { + name = "%[1]s" + auto_init = true + } + `, repoName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: configWithValue, + Check: resource.TestCheckResourceAttr( + "github_repository.test", + fmt.Sprintf("custom_properties.%s", propertyName), + "alpha", + ), + }, + { + Config: configNoBlock, + PlanOnly: true, + ExpectNonEmptyPlan: false, + }, + }, + }) + }) +}