diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f3684faa32..4af99015d9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -580,6 +580,7 @@ The following resources are deprecated and will be removed in future versions: | `github_project_card` | None (Classic Projects API removed) | | `github_project_column` | None (Classic Projects API removed) | | `github_repository_project` | None (Classic Projects API removed) | +| `github_organization_custom_properties` | `github_organization_repository_custom_property` | ### Deprecated Data Sources @@ -587,6 +588,7 @@ The following resources are deprecated and will be removed in future versions: | ---------------------------------------------- | --------------------------------------------------- | | `github_organization_custom_role` | `github_organization_repository_role` | | `github_organization_security_managers` | `github_organization_role_teams` | +| `github_organization_custom_properties` | `github_organization_repository_custom_property` | | `github_repository_deployment_branch_policies` | `github_repository_environment_deployment_policies` | ### Known Limitations diff --git a/RESOURCES.md b/RESOURCES.md index b1b1c3e2b0..15696c6a1a 100644 --- a/RESOURCES.md +++ b/RESOURCES.md @@ -75,12 +75,13 @@ The overall status of each resource or data source is captured in this document | `github_membership` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | | `github_organization` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | | `github_organization_app_installations` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | -| `github_organization_custom_properties` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | +| `github_organization_custom_properties` (🚫) | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | | `github_organization_custom_role` (🚫) | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | | `github_organization_external_identities` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | | `github_organization_ip_allow_list` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | | `github_organization_members` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `github_organization_repositories` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `github_organization_repository_custom_property` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `github_organization_repository_role` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `github_organization_repository_roles` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `github_organization_role` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | @@ -168,9 +169,10 @@ The overall status of each resource or data source is captured in this document | `github_issue_labels` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_membership` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_organization_block` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | -| `github_organization_custom_properties` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | +| `github_organization_custom_properties` (🚫) | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_organization_custom_role` (🚫) | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_organization_project` (🚫) | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | +| `github_organization_repository_custom_property` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `github_organization_repository_role` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_organization_role` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_organization_role_team` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | diff --git a/docs/data-sources/organization_custom_properties.md b/docs/data-sources/organization_custom_properties.md index 1564509fd7..0c7324fa5d 100644 --- a/docs/data-sources/organization_custom_properties.md +++ b/docs/data-sources/organization_custom_properties.md @@ -6,6 +6,8 @@ description: |- # github_organization_custom_properties (Data Source) +~> **Deprecated:** Use the singular [`github_organization_repository_custom_property`](organization_repository_custom_property) data source instead. This data source will be removed in a future major release. + Use this data source to retrieve information about a GitHub organization custom property. ## Example Usage diff --git a/docs/data-sources/organization_repository_custom_property.md b/docs/data-sources/organization_repository_custom_property.md new file mode 100644 index 0000000000..d1a2494c89 --- /dev/null +++ b/docs/data-sources/organization_repository_custom_property.md @@ -0,0 +1,35 @@ +--- +page_title: "github_organization_repository_custom_property (Data Source) - GitHub" +subcategory: "" +description: |- + Looks up a single GitHub organization custom property definition by name. +--- + +# github_organization_repository_custom_property (Data Source) + +Looks up a single GitHub organization custom property definition by name. + +## Example Usage + +```terraform +data "github_organization_repository_custom_property" "environment" { + property_name = "environment" +} +``` + + +## Schema + +### Required + +- `property_name` (String) Name of the custom property to look up. + +### Read-Only + +- `allowed_values` (List of String) Allowed values when `value_type` is `single_select` or `multi_select`. +- `default_value` (List of String) Default value applied to repositories that do not explicitly set the property. Holds multiple elements only when `value_type` is `multi_select`. +- `description` (String) Short description of the custom property. +- `id` (String) The ID of this resource. +- `required` (Boolean) Whether the custom property must be set on every repository. +- `value_type` (String) Type of the custom property. +- `values_editable_by` (String) Who can edit values of this property on repositories. diff --git a/docs/resources/organization_custom_properties.md b/docs/resources/organization_custom_properties.md index f27d8af532..39b1ff5505 100644 --- a/docs/resources/organization_custom_properties.md +++ b/docs/resources/organization_custom_properties.md @@ -6,6 +6,8 @@ description: |- # github_organization_custom_properties (Resource) +~> **Deprecated:** Use the singular [`github_organization_repository_custom_property`](organization_repository_custom_property) resource instead. This resource will be removed in a future major release. + This resource allows you to create and manage custom properties for a GitHub organization. Custom properties enable you to add metadata to repositories within your organization. You can use custom properties to add context about repositories, such as who owns them, when they expire, or compliance requirements. diff --git a/docs/resources/organization_repository_custom_property.md b/docs/resources/organization_repository_custom_property.md new file mode 100644 index 0000000000..70611c19be --- /dev/null +++ b/docs/resources/organization_repository_custom_property.md @@ -0,0 +1,107 @@ +--- +page_title: "github_organization_repository_custom_property (Resource) - GitHub" +subcategory: "" +description: |- + Manages a GitHub organization custom property definition. Custom properties defined here can later be assigned values on individual repositories. +--- + +# github_organization_repository_custom_property (Resource) + +Manages a GitHub organization custom property definition. Custom properties defined here can later be assigned values on individual repositories. + +## Example Usage + +```terraform +# This resource defines the property itself at the organization level. To set a +# value for it on an individual repository, use `github_repository_custom_property`. +# See https://docs.github.com/rest/orgs/custom-properties for the underlying API. + +# single_select property with a default value +resource "github_organization_repository_custom_property" "environment" { + property_name = "environment" + value_type = "single_select" + required = true + description = "The deployment environment for this repository" + default_value = ["development"] + allowed_values = [ + "development", + "staging", + "production", + ] +} + +# string property that repository actors (not just org owners) can edit +resource "github_organization_repository_custom_property" "team_contact" { + property_name = "team_contact" + value_type = "string" + description = "Contact information for the team managing this repository" + values_editable_by = "org_and_repo_actors" +} + +# true_false property +resource "github_organization_repository_custom_property" "archived" { + property_name = "archived" + value_type = "true_false" + description = "Whether this repository is archived" + default_value = ["false"] +} + +# multi_select property; only this type accepts more than one default value +resource "github_organization_repository_custom_property" "compliance" { + property_name = "compliance" + value_type = "multi_select" + description = "Compliance regimes this repository is in scope for" + allowed_values = ["pci", "sox", "hipaa"] + default_value = ["pci", "sox"] +} +``` + + +## Schema + +### Required + +- `property_name` (String) Name of the custom property. +- `value_type` (String) Type of the custom property. One of: [string single_select multi_select true_false url]. + +### Optional + +- `allowed_values` (List of String) Allowed values for `single_select` and `multi_select` property types. Must be omitted for other types. +- `default_value` (List of String) Default value applied to repositories that do not explicitly set the property. Exactly one element for the `string`, `single_select`, `true_false` and `url` types; one or more for `multi_select`. Once set, a default cannot be removed via the API, only changed. +- `description` (String) Short description of the custom property. +- `required` (Boolean) Whether the custom property must be set on every repository. GitHub may reject `required = true` unless a `default_value` is also provided. +- `timeouts` (Block, Optional) (see [below for nested schema](#nestedblock--timeouts)) +- `values_editable_by` (String) Who can edit values of this property on repositories. One of: [org_actors org_and_repo_actors]. Defaults to `org_actors` server-side. + +### Read-Only + +- `id` (String) The ID of this resource. + + +### Nested Schema for `timeouts` + +Optional: + +- `create` (String) +- `delete` (String) +- `read` (String) +- `update` (String) + +## Import + +Import is supported using the following syntax: + +In Terraform v1.5.0 and later, the [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used with the `id` attribute, for example: + +```terraform +import { + to = github_organization_repository_custom_property.environment + id = "environment" +} +``` + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: + +```shell +terraform import github_organization_repository_custom_property.environment environment +``` diff --git a/examples/data-sources/github_organization_repository_custom_property/data-source_1.tf b/examples/data-sources/github_organization_repository_custom_property/data-source_1.tf new file mode 100644 index 0000000000..cc5c5b3456 --- /dev/null +++ b/examples/data-sources/github_organization_repository_custom_property/data-source_1.tf @@ -0,0 +1,3 @@ +data "github_organization_repository_custom_property" "environment" { + property_name = "environment" +} diff --git a/examples/resources/github_organization_repository_custom_property/import-by-string-id.tf b/examples/resources/github_organization_repository_custom_property/import-by-string-id.tf new file mode 100644 index 0000000000..06c001dd08 --- /dev/null +++ b/examples/resources/github_organization_repository_custom_property/import-by-string-id.tf @@ -0,0 +1,4 @@ +import { + to = github_organization_repository_custom_property.environment + id = "environment" +} diff --git a/examples/resources/github_organization_repository_custom_property/import.sh b/examples/resources/github_organization_repository_custom_property/import.sh new file mode 100644 index 0000000000..0f602621a8 --- /dev/null +++ b/examples/resources/github_organization_repository_custom_property/import.sh @@ -0,0 +1 @@ +terraform import github_organization_repository_custom_property.environment environment diff --git a/examples/resources/github_organization_repository_custom_property/resource_1.tf b/examples/resources/github_organization_repository_custom_property/resource_1.tf new file mode 100644 index 0000000000..da38bc68ba --- /dev/null +++ b/examples/resources/github_organization_repository_custom_property/resource_1.tf @@ -0,0 +1,42 @@ +# This resource defines the property itself at the organization level. To set a +# value for it on an individual repository, use `github_repository_custom_property`. +# See https://docs.github.com/rest/orgs/custom-properties for the underlying API. + +# single_select property with a default value +resource "github_organization_repository_custom_property" "environment" { + property_name = "environment" + value_type = "single_select" + required = true + description = "The deployment environment for this repository" + default_value = ["development"] + allowed_values = [ + "development", + "staging", + "production", + ] +} + +# string property that repository actors (not just org owners) can edit +resource "github_organization_repository_custom_property" "team_contact" { + property_name = "team_contact" + value_type = "string" + description = "Contact information for the team managing this repository" + values_editable_by = "org_and_repo_actors" +} + +# true_false property +resource "github_organization_repository_custom_property" "archived" { + property_name = "archived" + value_type = "true_false" + description = "Whether this repository is archived" + default_value = ["false"] +} + +# multi_select property; only this type accepts more than one default value +resource "github_organization_repository_custom_property" "compliance" { + property_name = "compliance" + value_type = "multi_select" + description = "Compliance regimes this repository is in scope for" + allowed_values = ["pci", "sox", "hipaa"] + default_value = ["pci", "sox"] +} diff --git a/github/data_source_github_organization_custom_properties.go b/github/data_source_github_organization_custom_properties.go index 8e52187276..cbc76a8df2 100644 --- a/github/data_source_github_organization_custom_properties.go +++ b/github/data_source_github_organization_custom_properties.go @@ -9,7 +9,8 @@ import ( func dataSourceGithubOrganizationCustomProperties() *schema.Resource { return &schema.Resource{ - ReadContext: dataSourceGithubOrganizationCustomPropertiesRead, + DeprecationMessage: "This data source is deprecated and will be removed in a future release. Use github_organization_repository_custom_property (singular) instead.", + ReadContext: dataSourceGithubOrganizationCustomPropertiesRead, Schema: map[string]*schema.Schema{ "property_name": { diff --git a/github/data_source_github_organization_repository_custom_property.go b/github/data_source_github_organization_repository_custom_property.go new file mode 100644 index 0000000000..defc05d6e3 --- /dev/null +++ b/github/data_source_github_organization_repository_custom_property.go @@ -0,0 +1,121 @@ +package github + +import ( + "context" + "errors" + + "github.com/google/go-github/v89/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" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func dataSourceGithubOrganizationRepositoryCustomProperty() *schema.Resource { + return &schema.Resource{ + Description: "Looks up a single GitHub organization custom property definition by name.", + ReadContext: dataSourceGithubOrganizationRepositoryCustomPropertyRead, + + Schema: map[string]*schema.Schema{ + "property_name": { + Type: schema.TypeString, + Required: true, + Description: "Name of the custom property to look up.", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringIsNotEmpty), + }, + "value_type": { + Type: schema.TypeString, + Computed: true, + Description: "Type of the custom property.", + }, + "required": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether the custom property must be set on every repository.", + }, + "default_value": { + Type: schema.TypeList, + Computed: true, + Description: "Default value applied to repositories that do not explicitly set the property. Holds multiple elements only when `value_type` is `multi_select`.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "description": { + Type: schema.TypeString, + Computed: true, + Description: "Short description of the custom property.", + }, + "allowed_values": { + Type: schema.TypeList, + Computed: true, + Description: "Allowed values when `value_type` is `single_select` or `multi_select`.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "values_editable_by": { + Type: schema.TypeString, + Computed: true, + Description: "Who can edit values of this property on repositories.", + }, + }, + } +} + +func dataSourceGithubOrganizationRepositoryCustomPropertyRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + if ok, diags := checkOrganizationOK(meta); !ok { + return diags + } + + client := meta.v3client + owner := meta.name + propertyName := d.Get("property_name").(string) + + tflog.Debug(ctx, "Reading organization custom property", map[string]any{"org": owner, "property": propertyName}) + + cp, _, err := client.Organizations.GetCustomProperty(ctx, owner, propertyName) + if err != nil { + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == 404 { + return diag.Errorf("organization custom property %q not found in %q", propertyName, owner) + } + return diag.Errorf("error reading organization custom property %q: %v", propertyName, err) + } + + if cp.GetPropertyName() == "" { + return diag.Errorf("organization %q returned a custom property with an empty name when reading %q", owner, propertyName) + } + + switch cp.ValueType { + case github.PropertyValueTypeSingleSelect, github.PropertyValueTypeMultiSelect: + default: + cp.AllowedValues = nil + } + + defaultValue, err := flattenOrganizationRepositoryCustomPropertyDefaultValue(cp) + if err != nil { + return diag.Errorf("error reading organization custom property %q: %v", propertyName, err) + } + + d.SetId(cp.GetPropertyName()) + if err := d.Set("property_name", cp.GetPropertyName()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("value_type", string(cp.ValueType)); err != nil { + return diag.FromErr(err) + } + if err := d.Set("required", cp.GetRequired()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("default_value", defaultValue); err != nil { + return diag.FromErr(err) + } + if err := d.Set("description", cp.GetDescription()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("allowed_values", cp.AllowedValues); err != nil { + return diag.FromErr(err) + } + if err := d.Set("values_editable_by", cp.GetValuesEditableBy()); err != nil { + return diag.FromErr(err) + } + + return nil +} diff --git a/github/data_source_github_organization_repository_custom_property_test.go b/github/data_source_github_organization_repository_custom_property_test.go new file mode 100644 index 0000000000..9783015149 --- /dev/null +++ b/github/data_source_github_organization_repository_custom_property_test.go @@ -0,0 +1,48 @@ +package github + +import ( + "fmt" + "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 TestAccGithubOrganizationRepositoryCustomPropertyDataSource(t *testing.T) { + const dataAddr = "data.github_organization_repository_custom_property.test" + t.Parallel() + + t.Run("reads a property created by the fixture", func(t *testing.T) { + t.Parallel() + + property := mustCreateTestOrganizationRepositoryCustomProperty(t, "single_select", []string{"a", "b"}) + config := fmt.Sprintf(` +data "github_organization_repository_custom_property" "test" { + property_name = %q +} +`, property.GetPropertyName()) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(dataAddr, tfjsonpath.New("property_name"), knownvalue.StringExact(property.GetPropertyName())), + statecheck.ExpectKnownValue(dataAddr, tfjsonpath.New("value_type"), knownvalue.StringExact("single_select")), + statecheck.ExpectKnownValue(dataAddr, tfjsonpath.New("allowed_values"), knownvalue.ListExact([]knownvalue.Check{ + knownvalue.StringExact("a"), + knownvalue.StringExact("b"), + })), + // The fixture sets no default, so the list must come back empty + // rather than as a phantom single empty string. + statecheck.ExpectKnownValue(dataAddr, tfjsonpath.New("default_value"), knownvalue.ListSizeExact(0)), + }, + }, + }, + }) + }) +} diff --git a/github/data_source_github_repository_custom_properties.go b/github/data_source_github_repository_custom_properties.go index 42c69403ca..e0a5d2e15d 100644 --- a/github/data_source_github_repository_custom_properties.go +++ b/github/data_source_github_repository_custom_properties.go @@ -2,7 +2,6 @@ package github import ( "context" - "fmt" "github.com/google/go-github/v89/github" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -88,14 +87,3 @@ func flattenRepositoryCustomProperties(customProperties []*github.CustomProperty return results, nil } - -func parseRepositoryCustomPropertyValueToStringSlice(prop *github.CustomPropertyValue) ([]string, error) { - switch value := prop.Value.(type) { - case string: - return []string{value}, nil - case []string: - return value, nil - default: - return nil, fmt.Errorf("custom property value couldn't be parsed as a string or a list of strings: %s", value) - } -} diff --git a/github/provider.go b/github/provider.go index 73e93dc5e5..93427eb3c4 100644 --- a/github/provider.go +++ b/github/provider.go @@ -213,6 +213,7 @@ func NewProvider(version, commit string) func() *schema.Provider { "github_organization_custom_role": resourceGithubOrganizationCustomRole(), "github_organization_custom_properties": resourceGithubOrganizationCustomProperties(), "github_organization_project": resourceGithubOrganizationProject(), + "github_organization_repository_custom_property": resourceGithubOrganizationRepositoryCustomProperty(), "github_organization_repository_role": resourceGithubOrganizationRepositoryRole(), "github_organization_role": resourceGithubOrganizationRole(), "github_organization_role_team": resourceGithubOrganizationRoleTeam(), @@ -302,6 +303,7 @@ func NewProvider(version, commit string) func() *schema.Provider { "github_organization_ip_allow_list": dataSourceGithubOrganizationIpAllowList(), "github_organization_members": dataSourceGithubOrganizationMembers(), "github_organization_repositories": dataSourceGithubOrganizationRepositories(), + "github_organization_repository_custom_property": dataSourceGithubOrganizationRepositoryCustomProperty(), "github_organization_repository_role": dataSourceGithubOrganizationRepositoryRole(), "github_organization_repository_roles": dataSourceGithubOrganizationRepositoryRoles(), "github_organization_role": dataSourceGithubOrganizationRole(), diff --git a/github/resource_github_organization_custom_properties.go b/github/resource_github_organization_custom_properties.go index 84916ab879..27ff2e7605 100644 --- a/github/resource_github_organization_custom_properties.go +++ b/github/resource_github_organization_custom_properties.go @@ -11,10 +11,11 @@ import ( func resourceGithubOrganizationCustomProperties() *schema.Resource { return &schema.Resource{ - Create: resourceGithubCustomPropertiesCreate, - Read: resourceGithubCustomPropertiesRead, - Update: resourceGithubCustomPropertiesUpdate, - Delete: resourceGithubCustomPropertiesDelete, + DeprecationMessage: "This resource is deprecated and will be removed in a future release. Use github_organization_repository_custom_property instead.", + Create: resourceGithubCustomPropertiesCreate, + Read: resourceGithubCustomPropertiesRead, + Update: resourceGithubCustomPropertiesUpdate, + Delete: resourceGithubCustomPropertiesDelete, Importer: &schema.ResourceImporter{ State: resourceGithubCustomPropertiesImport, }, diff --git a/github/resource_github_organization_repository_custom_property.go b/github/resource_github_organization_repository_custom_property.go new file mode 100644 index 0000000000..5594838d2a --- /dev/null +++ b/github/resource_github_organization_repository_custom_property.go @@ -0,0 +1,388 @@ +package github + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/go-github/v89/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" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +var organizationCustomPropertyValueTypes = []string{ + string(github.PropertyValueTypeString), + string(github.PropertyValueTypeSingleSelect), + string(github.PropertyValueTypeMultiSelect), + string(github.PropertyValueTypeTrueFalse), + string(github.PropertyValueTypeURL), +} + +var organizationCustomPropertyValuesEditableBy = []string{"org_actors", "org_and_repo_actors"} + +func resourceGithubOrganizationRepositoryCustomProperty() *schema.Resource { + return &schema.Resource{ + Description: "Manages a GitHub organization custom property definition. Custom properties defined here can later be assigned values on individual repositories.", + + CreateContext: resourceGithubOrganizationRepositoryCustomPropertyCreate, + ReadContext: resourceGithubOrganizationRepositoryCustomPropertyRead, + UpdateContext: resourceGithubOrganizationRepositoryCustomPropertyUpdate, + DeleteContext: resourceGithubOrganizationRepositoryCustomPropertyDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubOrganizationRepositoryCustomPropertyImport, + }, + + CustomizeDiff: customdiff.All(resourceGithubOrganizationRepositoryCustomPropertyDiff), + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(5 * time.Minute), + Read: schema.DefaultTimeout(5 * time.Minute), + Update: schema.DefaultTimeout(5 * time.Minute), + Delete: schema.DefaultTimeout(5 * time.Minute), + }, + + Schema: map[string]*schema.Schema{ + "property_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the custom property.", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringIsNotEmpty), + }, + "value_type": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: fmt.Sprintf("Type of the custom property. One of: %v.", organizationCustomPropertyValueTypes), + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice(organizationCustomPropertyValueTypes, false)), + }, + "required": { + Type: schema.TypeBool, + Optional: true, + Description: "Whether the custom property must be set on every repository. GitHub may reject `required = true` unless a `default_value` is also provided.", + }, + "default_value": { + Type: schema.TypeList, + Optional: true, + Computed: true, + Description: "Default value applied to repositories that do not explicitly set the property. Exactly one element for the `string`, `single_select`, `true_false` and `url` types; one or more for `multi_select`. Once set, a default cannot be removed via the API, only changed.", + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringIsNotEmpty), + }, + }, + "description": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "Short description of the custom property.", + }, + // Deliberately not Computed: an omitted Optional+Computed list is + // unknown at plan time, which would make the cross-field validation + // in CustomizeDiff silently skip itself. Nothing needs to be read + // back here either -- select types always set it in config, and Read + // clears it for the other types. + "allowed_values": { + Type: schema.TypeList, + Optional: true, + Description: "Allowed values for `single_select` and `multi_select` property types. Must be omitted for other types.", + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringIsNotEmpty), + }, + }, + "values_editable_by": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: fmt.Sprintf("Who can edit values of this property on repositories. One of: %v. Defaults to `org_actors` server-side.", organizationCustomPropertyValuesEditableBy), + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice(organizationCustomPropertyValuesEditableBy, false)), + }, + }, + } +} + +func resourceGithubOrganizationRepositoryCustomPropertyDiff(ctx context.Context, d *schema.ResourceDiff, _ any) error { + if !d.NewValueKnown("value_type") { + return nil + } + + valueType := github.PropertyValueType(d.Get("value_type").(string)) + selectType := valueType == github.PropertyValueTypeSingleSelect || valueType == github.PropertyValueTypeMultiSelect + + if d.NewValueKnown("allowed_values") { + allowedValues, _ := d.Get("allowed_values").([]any) + + if selectType && len(allowedValues) == 0 { + return fmt.Errorf("allowed_values is required when value_type is %q", valueType) + } + if !selectType && len(allowedValues) > 0 { + return fmt.Errorf("allowed_values must not be set when value_type is %q", valueType) + } + } + + if d.NewValueKnown("default_value") { + defaultValue, _ := d.Get("default_value").([]any) + + // Only multi_select accepts a list-valued default; every other type is scalar. + if valueType != github.PropertyValueTypeMultiSelect && len(defaultValue) > 1 { + return fmt.Errorf("default_value must contain at most one element when value_type is %q, got %d", valueType, len(defaultValue)) + } + + // GitHub stores true_false defaults as the strings "true"/"false". Reject + // anything else here: strconv.ParseBool would accept "True" or "1" and the + // read path would then normalise it to a different string than the config, + // failing the apply with an inconsistent-result error. + if valueType == github.PropertyValueTypeTrueFalse { + for _, v := range defaultValue { + if s, _ := v.(string); s != "true" && s != "false" { + return fmt.Errorf("default_value must be %q or %q when value_type is %q, got %q", "true", "false", valueType, s) + } + } + } + } + + return nil +} + +func buildOrganizationRepositoryCustomProperty(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) + + cp := &github.CustomProperty{ + PropertyName: &propertyName, + ValueType: valueType, + Required: &required, + Description: &description, + } + + if v, ok := d.GetOk("default_value"); ok { + if defaultValue := expandStringList(v.([]any)); len(defaultValue) > 0 { + // Only multi_select sends an array; the other types send a bare string. + switch valueType { + case github.PropertyValueTypeMultiSelect: + cp.DefaultValue = defaultValue + default: + cp.DefaultValue = defaultValue[0] + } + } + } + + if v, ok := d.GetOk("allowed_values"); ok { + cp.AllowedValues = expandStringList(v.([]any)) + } + + if v, ok := d.GetOk("values_editable_by"); ok { + s := v.(string) + cp.ValuesEditableBy = &s + } + + return cp +} + +func resourceGithubOrganizationRepositoryCustomPropertyCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + if ok, diags := checkOrganizationOK(meta); !ok { + return diags + } + + client := meta.v3client + owner := meta.name + propertyName := d.Get("property_name").(string) + + tflog.Debug(ctx, "Creating organization custom property", map[string]any{"org": owner, "property": propertyName}) + + cp, _, err := client.Organizations.CreateOrUpdateCustomProperty(ctx, owner, propertyName, buildOrganizationRepositoryCustomProperty(d)) + if err != nil { + return diag.Errorf("error creating organization custom property %q: %v", propertyName, err) + } + + if cp.GetPropertyName() == "" { + return diag.Errorf("organization %q returned a custom property with an empty name when creating %q", owner, propertyName) + } + + defaultValue, err := flattenOrganizationRepositoryCustomPropertyDefaultValue(cp) + if err != nil { + return diag.Errorf("error reading organization custom property %q: %v", propertyName, err) + } + + d.SetId(cp.GetPropertyName()) + if err := d.Set("property_name", cp.GetPropertyName()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("value_type", string(cp.ValueType)); err != nil { + return diag.FromErr(err) + } + if err := d.Set("required", cp.GetRequired()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("default_value", defaultValue); err != nil { + return diag.FromErr(err) + } + if err := d.Set("description", cp.GetDescription()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("allowed_values", cp.AllowedValues); err != nil { + return diag.FromErr(err) + } + if err := d.Set("values_editable_by", cp.GetValuesEditableBy()); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubOrganizationRepositoryCustomPropertyRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + if ok, diags := checkOrganizationOK(meta); !ok { + return diags + } + + client := meta.v3client + owner := meta.name + propertyName := d.Get("property_name").(string) + + cp, _, err := client.Organizations.GetCustomProperty(ctx, owner, propertyName) + if err != nil { + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == 404 { + tflog.Info(ctx, "Removing organization custom property from state because it no longer exists", map[string]any{"org": owner, "property": propertyName}) + d.SetId("") + return nil + } + return diag.Errorf("error reading organization custom property %q: %v", propertyName, err) + } + + if cp.GetPropertyName() == "" { + return diag.Errorf("organization %q returned a custom property with an empty name when reading %q", owner, propertyName) + } + + switch cp.ValueType { + case github.PropertyValueTypeSingleSelect, github.PropertyValueTypeMultiSelect: + default: + cp.AllowedValues = nil + } + + defaultValue, err := flattenOrganizationRepositoryCustomPropertyDefaultValue(cp) + if err != nil { + return diag.Errorf("error reading organization custom property %q: %v", propertyName, err) + } + + d.SetId(cp.GetPropertyName()) + if err := d.Set("property_name", cp.GetPropertyName()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("value_type", string(cp.ValueType)); err != nil { + return diag.FromErr(err) + } + if err := d.Set("required", cp.GetRequired()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("default_value", defaultValue); err != nil { + return diag.FromErr(err) + } + if err := d.Set("description", cp.GetDescription()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("allowed_values", cp.AllowedValues); err != nil { + return diag.FromErr(err) + } + if err := d.Set("values_editable_by", cp.GetValuesEditableBy()); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubOrganizationRepositoryCustomPropertyUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + if ok, diags := checkOrganizationOK(meta); !ok { + return diags + } + + client := meta.v3client + owner := meta.name + propertyName := d.Get("property_name").(string) + + tflog.Debug(ctx, "Updating organization custom property", map[string]any{"org": owner, "property": propertyName}) + + cp, _, err := client.Organizations.CreateOrUpdateCustomProperty(ctx, owner, propertyName, buildOrganizationRepositoryCustomProperty(d)) + if err != nil { + return diag.Errorf("error updating organization custom property %q: %v", propertyName, err) + } + + if cp.GetPropertyName() == "" { + return diag.Errorf("organization %q returned a custom property with an empty name when updating %q", owner, propertyName) + } + + defaultValue, err := flattenOrganizationRepositoryCustomPropertyDefaultValue(cp) + if err != nil { + return diag.Errorf("error reading organization custom property %q: %v", propertyName, err) + } + + d.SetId(cp.GetPropertyName()) + if err := d.Set("property_name", cp.GetPropertyName()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("value_type", string(cp.ValueType)); err != nil { + return diag.FromErr(err) + } + if err := d.Set("required", cp.GetRequired()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("default_value", defaultValue); err != nil { + return diag.FromErr(err) + } + if err := d.Set("description", cp.GetDescription()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("allowed_values", cp.AllowedValues); err != nil { + return diag.FromErr(err) + } + if err := d.Set("values_editable_by", cp.GetValuesEditableBy()); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubOrganizationRepositoryCustomPropertyDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + if ok, diags := checkOrganizationOK(meta); !ok { + return diags + } + + client := meta.v3client + owner := meta.name + propertyName := d.Get("property_name").(string) + + tflog.Debug(ctx, "Deleting organization custom property", map[string]any{"org": owner, "property": propertyName}) + + if _, err := client.Organizations.RemoveCustomProperty(ctx, owner, propertyName); err != nil { + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == 404 { + return nil + } + return diag.Errorf("error deleting organization custom property %q: %v", propertyName, err) + } + + return nil +} + +func resourceGithubOrganizationRepositoryCustomPropertyImport(ctx context.Context, d *schema.ResourceData, _ any) ([]*schema.ResourceData, error) { + propertyName := d.Id() + if propertyName == "" { + return nil, errors.New("custom property name must not be empty") + } + + // Read looks the property up by attribute, so seed it from the import ID. + if err := d.Set("property_name", propertyName); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} diff --git a/github/resource_github_organization_repository_custom_property_test.go b/github/resource_github_organization_repository_custom_property_test.go new file mode 100644 index 0000000000..478ffe89be --- /dev/null +++ b/github/resource_github_organization_repository_custom_property_test.go @@ -0,0 +1,595 @@ +package github + +import ( + "fmt" + "regexp" + "testing" + + "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/plancheck" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccGithubOrganizationRepositoryCustomProperty(t *testing.T) { + const resourceAddr = "github_organization_repository_custom_property.test" + + t.Parallel() + + t.Run("creates a string property without error", func(t *testing.T) { + t.Parallel() + + name := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + config := fmt.Sprintf(` +resource "github_organization_repository_custom_property" "test" { + property_name = %[1]q + value_type = "string" + description = "tf-acc-test string property" + default_value = ["dev"] +} +`, name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceAddr, tfjsonpath.New("property_name"), knownvalue.StringExact(name)), + statecheck.ExpectKnownValue(resourceAddr, tfjsonpath.New("value_type"), knownvalue.StringExact("string")), + statecheck.ExpectKnownValue(resourceAddr, tfjsonpath.New("values_editable_by"), knownvalue.StringExact("org_actors")), + statecheck.ExpectKnownValue(resourceAddr, tfjsonpath.New("default_value"), knownvalue.ListExact([]knownvalue.Check{ + knownvalue.StringExact("dev"), + })), + }, + }, + }, + }) + }) + + t.Run("creates a true_false property with a default value", func(t *testing.T) { + t.Parallel() + + name := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + config := fmt.Sprintf(` +resource "github_organization_repository_custom_property" "test" { + property_name = %[1]q + value_type = "true_false" + description = "tf-acc-test true_false property" + default_value = [%%q] +} +`, name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, "false"), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceAddr, tfjsonpath.New("default_value"), knownvalue.ListExact([]knownvalue.Check{ + knownvalue.StringExact("false"), + })), + }, + }, + { + Config: fmt.Sprintf(config, "true"), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction(resourceAddr, plancheck.ResourceActionUpdate), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceAddr, tfjsonpath.New("default_value"), knownvalue.ListExact([]knownvalue.Check{ + knownvalue.StringExact("true"), + })), + }, + }, + { + ResourceName: resourceAddr, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) + + t.Run("creates a multi_select property with multiple default values", func(t *testing.T) { + t.Parallel() + + name := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + config := fmt.Sprintf(` +resource "github_organization_repository_custom_property" "test" { + property_name = %[1]q + value_type = "multi_select" + description = "tf-acc-test multi_select property" + allowed_values = ["one", "two", "three"] + default_value = %%s +} +`, name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, `["one", "two"]`), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceAddr, tfjsonpath.New("default_value"), knownvalue.ListExact([]knownvalue.Check{ + knownvalue.StringExact("one"), + knownvalue.StringExact("two"), + })), + }, + }, + { + Config: fmt.Sprintf(config, `["three"]`), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction(resourceAddr, plancheck.ResourceActionUpdate), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceAddr, tfjsonpath.New("default_value"), knownvalue.ListExact([]knownvalue.Check{ + knownvalue.StringExact("three"), + })), + }, + }, + { + ResourceName: resourceAddr, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) + + t.Run("creates a single_select property and grows allowed_values", func(t *testing.T) { + t.Parallel() + + name := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + config := fmt.Sprintf(` +resource "github_organization_repository_custom_property" "test" { + property_name = %[1]q + value_type = "single_select" + description = "tf-acc-test single_select property %%[1]s" + allowed_values = %%[2]s +} +`, name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, "initial", `["one"]`), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceAddr, tfjsonpath.New("allowed_values"), knownvalue.ListExact([]knownvalue.Check{ + knownvalue.StringExact("one"), + })), + }, + }, + { + Config: fmt.Sprintf(config, "updated", `["one", "two"]`), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction(resourceAddr, plancheck.ResourceActionUpdate), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceAddr, tfjsonpath.New("allowed_values"), knownvalue.ListExact([]knownvalue.Check{ + knownvalue.StringExact("one"), + knownvalue.StringExact("two"), + })), + statecheck.ExpectKnownValue(resourceAddr, tfjsonpath.New("description"), knownvalue.StringExact("tf-acc-test single_select property updated")), + }, + }, + }, + }) + }) + + t.Run("imports without error", func(t *testing.T) { + t.Parallel() + + name := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + config := fmt.Sprintf(` +resource "github_organization_repository_custom_property" "test" { + property_name = %[1]q + value_type = "string" + description = "tf-acc-test import" +} +`, name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + {Config: config}, + { + ResourceName: resourceAddr, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) + + t.Run("recreates a property deleted outside of terraform", func(t *testing.T) { + t.Parallel() + + name := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + config := fmt.Sprintf(` +resource "github_organization_repository_custom_property" "test" { + property_name = %[1]q + value_type = "string" + description = "tf-acc-test out-of-band delete" +} +`, name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + {Config: config}, + { + // Read must classify the resulting 404 as "gone" and drop the + // resource from state, so the next plan recreates it rather + // than erroring. + PreConfig: func() { + if _, err := testAccConf.meta.v3client.Organizations.RemoveCustomProperty(t.Context(), testAccConf.meta.name, name); err != nil { + t.Fatalf("failed to delete organization custom property %s out of band: %v", name, err) + } + }, + Config: config, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction(resourceAddr, plancheck.ResourceActionCreate), + }, + }, + }, + }, + }) + }) + + t.Run("destroys cleanly when the property is already gone", func(t *testing.T) { + t.Parallel() + + name := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + config := fmt.Sprintf(` +resource "github_organization_repository_custom_property" "test" { + property_name = %[1]q + value_type = "string" + description = "tf-acc-test delete of a missing property" +} +`, name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + {Config: config}, + { + // Delete must treat a 404 as success. Removing the property + // out of band immediately before destroy exercises that branch, + // which the recreate test above never reaches. + PreConfig: func() { + if _, err := testAccConf.meta.v3client.Organizations.RemoveCustomProperty(t.Context(), testAccConf.meta.name, name); err != nil { + t.Fatalf("failed to delete organization custom property %s out of band: %v", name, err) + } + }, + Config: config, + Destroy: true, + }, + }, + }) + }) + + t.Run("creates a url property with a default value", func(t *testing.T) { + t.Parallel() + + name := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + config := fmt.Sprintf(` +resource "github_organization_repository_custom_property" "test" { + property_name = %[1]q + value_type = "url" + description = "tf-acc-test url property" + default_value = ["https://example.com/runbook"] +} +`, name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceAddr, tfjsonpath.New("value_type"), knownvalue.StringExact("url")), + statecheck.ExpectKnownValue(resourceAddr, tfjsonpath.New("default_value"), knownvalue.ListExact([]knownvalue.Check{ + knownvalue.StringExact("https://example.com/runbook"), + })), + }, + }, + { + ResourceName: resourceAddr, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) + + t.Run("rejects a non-boolean default_value on true_false", func(t *testing.T) { + t.Parallel() + + name := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + config := fmt.Sprintf(` +resource "github_organization_repository_custom_property" "test" { + property_name = %[1]q + value_type = "true_false" + default_value = ["True"] +} +`, name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`default_value must be "true" or "false"`), + }, + }, + }) + }) + + t.Run("rejects an empty string in allowed_values", func(t *testing.T) { + t.Parallel() + + name := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + config := fmt.Sprintf(` +resource "github_organization_repository_custom_property" "test" { + property_name = %[1]q + value_type = "single_select" + allowed_values = [""] +} +`, name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("expected .* to not be an empty string"), + }, + }, + }) + }) + + t.Run("forces new when property_name changes", func(t *testing.T) { + t.Parallel() + + nameBefore := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + nameAfter := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + config := ` +resource "github_organization_repository_custom_property" "test" { + property_name = %[1]q + value_type = "string" +} +` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + {Config: fmt.Sprintf(config, nameBefore)}, + { + Config: fmt.Sprintf(config, nameAfter), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction(resourceAddr, plancheck.ResourceActionDestroyBeforeCreate), + }, + }, + }, + }, + }) + }) + + t.Run("forces new when value_type changes", func(t *testing.T) { + t.Parallel() + + name := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + config := fmt.Sprintf(` +resource "github_organization_repository_custom_property" "test" { + property_name = %[1]q + %%s +} +`, name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + {Config: fmt.Sprintf(config, `value_type = "string"`)}, + { + Config: fmt.Sprintf(config, "value_type = \"single_select\"\n allowed_values = [\"x\"]"), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction(resourceAddr, plancheck.ResourceActionDestroyBeforeCreate), + }, + }, + }, + }, + }) + }) + + t.Run("rejects allowed_values on string type", func(t *testing.T) { + t.Parallel() + + name := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + config := fmt.Sprintf(` +resource "github_organization_repository_custom_property" "test" { + property_name = %[1]q + value_type = "string" + allowed_values = ["nope"] +} +`, name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("allowed_values must not be set"), + }, + }, + }) + }) + + t.Run("requires allowed_values on single_select type", func(t *testing.T) { + t.Parallel() + + name := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + config := fmt.Sprintf(` +resource "github_organization_repository_custom_property" "test" { + property_name = %[1]q + value_type = "single_select" +} +`, name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("allowed_values is required"), + }, + }, + }) + }) + + t.Run("rejects multiple default_value entries on a scalar type", func(t *testing.T) { + t.Parallel() + + name := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + config := fmt.Sprintf(` +resource "github_organization_repository_custom_property" "test" { + property_name = %[1]q + value_type = "string" + default_value = ["one", "two"] +} +`, name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("default_value must contain at most one element"), + }, + }, + }) + }) + + t.Run("rejects invalid values_editable_by", func(t *testing.T) { + t.Parallel() + + name := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + config := fmt.Sprintf(` +resource "github_organization_repository_custom_property" "test" { + property_name = %[1]q + value_type = "string" + values_editable_by = "nope" +} +`, name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile("nope"), + }, + }, + }) + }) + + t.Run("updates values_editable_by from org_actors to org_and_repo_actors", func(t *testing.T) { + t.Parallel() + + name := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + config := fmt.Sprintf(` +resource "github_organization_repository_custom_property" "test" { + property_name = %[1]q + value_type = "string" + values_editable_by = %%q +} +`, name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, "org_actors"), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceAddr, tfjsonpath.New("values_editable_by"), knownvalue.StringExact("org_actors")), + }, + }, + { + Config: fmt.Sprintf(config, "org_and_repo_actors"), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction(resourceAddr, plancheck.ResourceActionUpdate), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceAddr, tfjsonpath.New("values_editable_by"), knownvalue.StringExact("org_and_repo_actors")), + }, + }, + }, + }) + }) + + t.Run("retains values_editable_by set out-of-band when omitted from config", func(t *testing.T) { + t.Parallel() + + // Mirrors the upstream behaviour where a value set via the UI before + // Terraform managed the property is reflected back into state via the + // Computed attribute even when the config omits it. + name := fmt.Sprintf("%s%s", testResourcePrefix, acctest.RandString(testRandomIDLength)) + config := fmt.Sprintf(` +resource "github_organization_repository_custom_property" "test" { + property_name = %[1]q + value_type = "string" + %%s +} +`, name) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, `values_editable_by = "org_and_repo_actors"`), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceAddr, tfjsonpath.New("values_editable_by"), knownvalue.StringExact("org_and_repo_actors")), + }, + }, + { + Config: fmt.Sprintf(config, ""), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceAddr, tfjsonpath.New("values_editable_by"), knownvalue.StringExact("org_and_repo_actors")), + }, + }, + }, + }) + }) +} diff --git a/github/util_custom_property.go b/github/util_custom_property.go new file mode 100644 index 0000000000..b7ede11ced --- /dev/null +++ b/github/util_custom_property.go @@ -0,0 +1,48 @@ +package github + +import ( + "fmt" + "strconv" + + "github.com/google/go-github/v89/github" +) + +// flattenOrganizationRepositoryCustomPropertyDefaultValue normalises the +// polymorphic default_value returned by the API into a list of strings. The +// wire type depends on value_type: multi_select is an array, true_false is a +// stringified bool and the rest are plain strings. +func flattenOrganizationRepositoryCustomPropertyDefaultValue(cp *github.CustomProperty) ([]string, error) { + if cp.DefaultValue == nil { + return nil, nil + } + + switch cp.ValueType { + case github.PropertyValueTypeMultiSelect: + if v, ok := cp.DefaultValueStrings(); ok { + return v, nil + } + case github.PropertyValueTypeTrueFalse: + if v, ok := cp.DefaultValueBool(); ok { + return []string{strconv.FormatBool(v)}, nil + } + default: + if v, ok := cp.DefaultValueString(); ok { + return []string{v}, nil + } + } + + return nil, fmt.Errorf("default_value %#v could not be parsed for value_type %q", cp.DefaultValue, cp.ValueType) +} + +// parseRepositoryCustomPropertyValueToStringSlice normalises the polymorphic +// value of a custom property set on a repository into a list of strings. +func parseRepositoryCustomPropertyValueToStringSlice(prop *github.CustomPropertyValue) ([]string, error) { + switch value := prop.Value.(type) { + case string: + return []string{value}, nil + case []string: + return value, nil + default: + return nil, fmt.Errorf("custom property value couldn't be parsed as a string or a list of strings: %s", value) + } +} diff --git a/templates/data-sources/organization_custom_properties.md.tmpl b/templates/data-sources/organization_custom_properties.md.tmpl index 45a0ddfe97..5bb53261ea 100644 --- a/templates/data-sources/organization_custom_properties.md.tmpl +++ b/templates/data-sources/organization_custom_properties.md.tmpl @@ -6,6 +6,8 @@ description: |- # {{.Name}} ({{.Type}}) +~> **Deprecated:** Use the singular [`github_organization_repository_custom_property`](organization_repository_custom_property) data source instead. This data source will be removed in a future major release. + Use this data source to retrieve information about a GitHub organization custom property. ## Example Usage diff --git a/templates/resources/organization_custom_properties.md.tmpl b/templates/resources/organization_custom_properties.md.tmpl index a65ecca029..2c4c30bd99 100644 --- a/templates/resources/organization_custom_properties.md.tmpl +++ b/templates/resources/organization_custom_properties.md.tmpl @@ -6,6 +6,8 @@ description: |- # {{.Name}} ({{.Type}}) +~> **Deprecated:** Use the singular [`github_organization_repository_custom_property`](organization_repository_custom_property) resource instead. This resource will be removed in a future major release. + This resource allows you to create and manage custom properties for a GitHub organization. Custom properties enable you to add metadata to repositories within your organization. You can use custom properties to add context about repositories, such as who owns them, when they expire, or compliance requirements.