From 6892990bc831f71b854debcf93936826785e5276 Mon Sep 17 00:00:00 2001 From: Misha Kushakov Date: Fri, 27 Feb 2026 17:48:22 +0100 Subject: [PATCH 1/6] feat: add github_repository_custom_properties resource (batch) New resource for managing multiple custom property values on a repository in a single resource block with in-place updates. Complements the existing singular github_repository_custom_property resource. --- github/provider.go | 1 + ...rce_github_repository_custom_properties.go | 252 ++++++++++++++++++ ...ithub_repository_custom_properties_test.go | 243 +++++++++++++++++ 3 files changed, 496 insertions(+) create mode 100644 github/resource_github_repository_custom_properties.go create mode 100644 github/resource_github_repository_custom_properties_test.go diff --git a/github/provider.go b/github/provider.go index 2d5d6dc33a..b6db8efd83 100644 --- a/github/provider.go +++ b/github/provider.go @@ -192,6 +192,7 @@ func Provider() *schema.Provider { "github_repository_collaborator": resourceGithubRepositoryCollaborator(), "github_repository_collaborators": resourceGithubRepositoryCollaborators(), "github_repository_custom_property": resourceGithubRepositoryCustomProperty(), + "github_repository_custom_properties": resourceGithubRepositoryCustomProperties(), "github_repository_deploy_key": resourceGithubRepositoryDeployKey(), "github_repository_deployment_branch_policy": resourceGithubRepositoryDeploymentBranchPolicy(), "github_repository_environment": resourceGithubRepositoryEnvironment(), diff --git a/github/resource_github_repository_custom_properties.go b/github/resource_github_repository_custom_properties.go new file mode 100644 index 0000000000..cd799132cc --- /dev/null +++ b/github/resource_github_repository_custom_properties.go @@ -0,0 +1,252 @@ +package github + +import ( + "context" + "fmt" + "log" + "strings" + + "github.com/google/go-github/v83/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubRepositoryCustomProperties() *schema.Resource { + return &schema.Resource{ + Description: "Manages custom properties for a GitHub repository. This resource allows you to set multiple custom property values on a single repository in a single resource block, with in-place updates when values change.", + Create: resourceGithubRepositoryCustomPropertiesCreateOrUpdate, + Read: resourceGithubRepositoryCustomPropertiesRead, + Update: resourceGithubRepositoryCustomPropertiesCreateOrUpdate, + Delete: resourceGithubRepositoryCustomPropertiesDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubRepositoryCustomPropertiesImport, + }, + + Schema: map[string]*schema.Schema{ + "repository_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the repository.", + }, + "property": { + Type: schema.TypeSet, + Required: true, + MinItems: 1, + Description: "Set of custom property values for this repository.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "Name of the custom property (must be defined at the organization level).", + }, + "value": { + Type: schema.TypeSet, + Required: true, + MinItems: 1, + Description: "Value(s) of the custom property. For multi_select properties, multiple values can be specified.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + }, + Set: resourceGithubRepositoryCustomPropertiesHash, + }, + }, + } +} + +// resourceGithubRepositoryCustomPropertiesHash creates a hash for a property block +// using only the property name, so that value changes are detected as in-place +// updates rather than remove+add within the set. +func resourceGithubRepositoryCustomPropertiesHash(v any) int { + raw := v.(map[string]any) + name := raw["name"].(string) + return schema.HashString(name) +} + +func resourceGithubRepositoryCustomPropertiesCreateOrUpdate(d *schema.ResourceData, meta any) error { + if err := checkOrganization(meta); err != nil { + return err + } + + client := meta.(*Owner).v3client + ctx := context.Background() + owner := meta.(*Owner).name + repoName := d.Get("repository_name").(string) + properties := d.Get("property").(*schema.Set).List() + + // Get all organization custom property definitions to determine types + orgProperties, _, err := client.Organizations.GetAllCustomProperties(ctx, owner) + if err != nil { + return fmt.Errorf("error reading organization custom property definitions: %w", err) + } + + // Create a map of property names to their types + propertyTypes := make(map[string]github.PropertyValueType) + for _, prop := range orgProperties { + if prop.PropertyName != nil { + propertyTypes[*prop.PropertyName] = prop.ValueType + } + } + + // Build custom property values for this repository + customProperties := make([]*github.CustomPropertyValue, 0, len(properties)) + + for _, propBlock := range properties { + propMap := propBlock.(map[string]any) + propertyName := propMap["name"].(string) + propertyValues := expandStringList(propMap["value"].(*schema.Set).List()) + + propertyType, ok := propertyTypes[propertyName] + if !ok { + return fmt.Errorf("custom property %q is not defined at the organization level", propertyName) + } + + customProperty := &github.CustomPropertyValue{ + PropertyName: propertyName, + } + + switch propertyType { + case github.PropertyValueTypeMultiSelect: + customProperty.Value = propertyValues + case github.PropertyValueTypeString, github.PropertyValueTypeSingleSelect, + github.PropertyValueTypeTrueFalse, github.PropertyValueTypeURL: + if len(propertyValues) > 0 { + customProperty.Value = propertyValues[0] + } + default: + return fmt.Errorf("unsupported property type %q for property %q", propertyType, propertyName) + } + + customProperties = append(customProperties, customProperty) + } + + _, err = client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repoName, customProperties) + if err != nil { + return fmt.Errorf("error setting custom properties for repository %s/%s: %w", owner, repoName, err) + } + + d.SetId(buildTwoPartID(owner, repoName)) + + return resourceGithubRepositoryCustomPropertiesRead(d, meta) +} + +func resourceGithubRepositoryCustomPropertiesRead(d *schema.ResourceData, meta any) error { + if err := checkOrganization(meta); err != nil { + return err + } + + client := meta.(*Owner).v3client + ctx := context.Background() + + owner, repoName, err := parseTwoPartID(d.Id(), "owner", "repository") + if err != nil { + return err + } + + // Get current properties from state to know which ones we're managing. + // On import this will be empty, which is handled below. + propertiesFromState := d.Get("property").(*schema.Set).List() + managedPropertyNames := make(map[string]bool) + for _, propBlock := range propertiesFromState { + propMap := propBlock.(map[string]any) + managedPropertyNames[propMap["name"].(string)] = true + } + + isImport := len(managedPropertyNames) == 0 + + // Read actual properties from GitHub + allCustomProperties, _, err := client.Repositories.GetAllCustomPropertyValues(ctx, owner, repoName) + if err != nil { + return fmt.Errorf("error reading custom properties for repository %s/%s: %w", owner, repoName, err) + } + + // Build the property set — either all properties (import) or only managed ones + managedProperties := make([]any, 0) + for _, prop := range allCustomProperties { + if !isImport && !managedPropertyNames[prop.PropertyName] { + continue + } + + // Skip properties with nil/null values (unset) + if prop.Value == nil { + continue + } + + propertyValue, err := parseRepositoryCustomPropertyValueToStringSlice(prop) + if err != nil { + return fmt.Errorf("error parsing property %q for repository %s/%s: %w", prop.PropertyName, owner, repoName, err) + } + + if len(propertyValue) == 0 { + continue + } + + managedProperties = append(managedProperties, map[string]any{ + "name": prop.PropertyName, + "value": propertyValue, + }) + } + + // If no properties exist, remove resource from state + if len(managedProperties) == 0 { + log.Printf("[WARN] No custom properties found for %s/%s, removing from state", owner, repoName) + d.SetId("") + return nil + } + + _ = d.Set("repository_name", repoName) + _ = d.Set("property", managedProperties) + + return nil +} + +func resourceGithubRepositoryCustomPropertiesDelete(d *schema.ResourceData, meta any) error { + if err := checkOrganization(meta); err != nil { + return err + } + + client := meta.(*Owner).v3client + ctx := context.Background() + + owner, repoName, err := parseTwoPartID(d.Id(), "owner", "repository") + if err != nil { + return err + } + + properties := d.Get("property").(*schema.Set).List() + if len(properties) == 0 { + return nil + } + + // Set all managed properties to nil (removes them) + customProperties := make([]*github.CustomPropertyValue, 0, len(properties)) + for _, propBlock := range properties { + propMap := propBlock.(map[string]any) + customProperties = append(customProperties, &github.CustomPropertyValue{ + PropertyName: propMap["name"].(string), + Value: nil, + }) + } + + _, err = client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repoName, customProperties) + if err != nil { + return fmt.Errorf("error deleting custom properties for repository %s/%s: %w", owner, repoName, err) + } + + return nil +} + +func resourceGithubRepositoryCustomPropertiesImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + // Import ID format: owner/repo (using standard two-part ID) + // On import, Read will detect empty state and import ALL properties + parts := strings.SplitN(d.Id(), "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return nil, fmt.Errorf("invalid import ID %q, expected format: owner/repository", d.Id()) + } + + d.SetId(buildTwoPartID(parts[0], parts[1])) + return []*schema.ResourceData{d}, nil +} 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..2dd27c1a96 --- /dev/null +++ b/github/resource_github_repository_custom_properties_test.go @@ -0,0 +1,243 @@ +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("creates and reads multiple custom properties", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%srepo-custom-props-%s", testResourcePrefix, randomID) + envPropName := fmt.Sprintf("tf-acc-env-%s", randomID) + teamPropName := fmt.Sprintf("tf-acc-team-%s", randomID) + + config := fmt.Sprintf(` + resource "github_organization_custom_properties" "environment" { + allowed_values = ["production", "staging", "development"] + description = "Deployment environment" + property_name = "%s" + value_type = "single_select" + } + + resource "github_organization_custom_properties" "team" { + description = "Team responsible" + property_name = "%s" + value_type = "string" + } + + resource "github_repository" "test" { + name = "%s" + auto_init = true + } + + resource "github_repository_custom_properties" "test" { + repository_name = github_repository.test.name + + property { + name = github_organization_custom_properties.environment.property_name + value = ["production"] + } + + property { + name = github_organization_custom_properties.team.property_name + value = ["platform-team"] + } + } + `, envPropName, teamPropName, repoName) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_repository_custom_properties.test", "repository_name", repoName), + resource.TestCheckResourceAttr("github_repository_custom_properties.test", "property.#", "2"), + resource.TestCheckTypeSetElemNestedAttrs("github_repository_custom_properties.test", "property.*", map[string]string{ + "name": envPropName, + }), + resource.TestCheckTypeSetElemNestedAttrs("github_repository_custom_properties.test", "property.*", map[string]string{ + "name": teamPropName, + }), + ) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + }) + + t.Run("updates property value in place", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%srepo-custom-props-%s", testResourcePrefix, randomID) + propName := fmt.Sprintf("tf-acc-env-%s", randomID) + + configCreate := fmt.Sprintf(` + resource "github_organization_custom_properties" "test" { + allowed_values = ["production", "staging", "development"] + description = "Deployment environment" + property_name = "%s" + value_type = "single_select" + } + + resource "github_repository" "test" { + name = "%s" + auto_init = true + } + + resource "github_repository_custom_properties" "test" { + repository_name = github_repository.test.name + + property { + name = github_organization_custom_properties.test.property_name + value = ["production"] + } + } + `, propName, repoName) + + configUpdate := fmt.Sprintf(` + resource "github_organization_custom_properties" "test" { + allowed_values = ["production", "staging", "development"] + description = "Deployment environment" + property_name = "%s" + value_type = "single_select" + } + + resource "github_repository" "test" { + name = "%s" + auto_init = true + } + + resource "github_repository_custom_properties" "test" { + repository_name = github_repository.test.name + + property { + name = github_organization_custom_properties.test.property_name + value = ["staging"] + } + } + `, propName, repoName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: configCreate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_repository_custom_properties.test", "property.#", "1"), + resource.TestCheckTypeSetElemNestedAttrs("github_repository_custom_properties.test", "property.*", map[string]string{ + "name": propName, + }), + ), + }, + { + Config: configUpdate, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_repository_custom_properties.test", "property.#", "1"), + resource.TestCheckTypeSetElemNestedAttrs("github_repository_custom_properties.test", "property.*", map[string]string{ + "name": propName, + }), + ), + }, + }, + }) + }) + + t.Run("imports all properties for a repository", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%srepo-custom-props-%s", testResourcePrefix, randomID) + propName := fmt.Sprintf("tf-acc-env-%s", randomID) + + config := fmt.Sprintf(` + resource "github_organization_custom_properties" "test" { + allowed_values = ["production", "staging"] + description = "Deployment environment" + property_name = "%s" + value_type = "single_select" + } + + resource "github_repository" "test" { + name = "%s" + auto_init = true + } + + resource "github_repository_custom_properties" "test" { + repository_name = github_repository.test.name + + property { + name = github_organization_custom_properties.test.property_name + value = ["production"] + } + } + `, propName, repoName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + }, + { + ResourceName: "github_repository_custom_properties.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) + + t.Run("creates multi_select property", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%srepo-custom-props-%s", testResourcePrefix, randomID) + propName := fmt.Sprintf("tf-acc-tags-%s", randomID) + + config := fmt.Sprintf(` + resource "github_organization_custom_properties" "test" { + allowed_values = ["go", "python", "rust", "typescript"] + description = "Language tags" + property_name = "%s" + value_type = "multi_select" + } + + resource "github_repository" "test" { + name = "%s" + auto_init = true + } + + resource "github_repository_custom_properties" "test" { + repository_name = github_repository.test.name + + property { + name = github_organization_custom_properties.test.property_name + value = ["go", "rust"] + } + } + `, propName, repoName) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_repository_custom_properties.test", "property.#", "1"), + resource.TestCheckTypeSetElemNestedAttrs("github_repository_custom_properties.test", "property.*", map[string]string{ + "name": propName, + "value.#": "2", + }), + ) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + }) +} From 26bf4103c8c22bc2a48bf4a0caf92bbb40bc3a72 Mon Sep 17 00:00:00 2001 From: Misha Kushakov Date: Fri, 27 Feb 2026 17:58:58 +0100 Subject: [PATCH 2/6] docs: add github_repository_custom_properties resource documentation --- ...repository_custom_properties.html.markdown | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 website/docs/r/repository_custom_properties.html.markdown diff --git a/website/docs/r/repository_custom_properties.html.markdown b/website/docs/r/repository_custom_properties.html.markdown new file mode 100644 index 0000000000..b1305aa250 --- /dev/null +++ b/website/docs/r/repository_custom_properties.html.markdown @@ -0,0 +1,88 @@ +--- +layout: "github" +page_title: "GitHub: github_repository_custom_properties" +description: |- + Manages multiple custom property values for a GitHub repository +--- + +# github_repository_custom_properties + +This resource allows you to manage multiple custom property values for a GitHub repository in a single resource block. Property values are updated in-place when changed, without recreating the resource. + +~> **Note:** This resource manages **values** for custom properties that have already been defined at the organization level (e.g. using [`github_organization_custom_properties`](organization_custom_properties.html)). It cannot create new property definitions. + +~> **Note:** This resource requires the provider to be configured with an organization owner. Individual user accounts are not supported. + +## Example Usage + +```hcl +resource "github_repository" "example" { + name = "example" +} + +resource "github_repository_custom_properties" "example" { + repository_name = github_repository.example.name + + property { + name = "environment" + value = ["production"] + } + + property { + name = "team" + value = ["platform"] + } +} +``` + +## Example Usage - Multi-Select Property + +```hcl +resource "github_repository_custom_properties" "example" { + repository_name = "my-repo" + + property { + name = "languages" + value = ["go", "typescript", "python"] + } + + property { + name = "environment" + value = ["staging"] + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `repository_name` - (Required) The name of the repository. Changing this will force the resource to be recreated. + +* `property` - (Required) One or more property blocks as defined below. At least one must be specified. + +### property + +* `name` - (Required) The name of the custom property. Must correspond to a property already defined at the organization level. + +* `value` - (Required) The value(s) for the custom property. This is always specified as a set of strings, even for non-multi-select properties. For `string`, `single_select`, `true_false`, and `url` property types, provide a single value. For `multi_select` properties, multiple values can be provided. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `id` - A composite ID in the format `owner:repository_name`. + +## Import + +Repository custom properties can be imported using the `owner/repository_name` format. When imported, **all** custom property values currently set on the repository will be imported into state. + +``` +terraform import github_repository_custom_properties.example my-org/my-repo +``` + +## Differences from `github_repository_custom_property` + +This resource (`github_repository_custom_properties`, plural) manages **all** custom property values for a repository in a single resource block, with in-place updates when values change. This is useful when you want to manage multiple properties together as a unit. + +The singular [`github_repository_custom_property`](repository_custom_property.html) resource manages a **single** property value per resource instance. Use it when you need independent lifecycle management for each property. From e6829dc1f8b11e8482377d2784b6b7a460b1fe49 Mon Sep 17 00:00:00 2001 From: Misha Kushakov Date: Fri, 20 Mar 2026 23:25:08 +0100 Subject: [PATCH 3/6] refactor: address PR review for github_repository_custom_properties - Use context-aware CRUD functions (CreateContext, ReadContext, etc.) - Rename repository_name to repository, add computed repository_id field - Add CustomizeDiff with diffRepository for repo rename support - Separate Create and Update into distinct functions - Use buildID/parseID2 with : separator instead of buildTwoPartID - Replace log.Printf with tflog for structured logging - Return d.Set errors instead of swallowing them - Extract filterManagedCustomProperties to reduce cognitive complexity - Update tests to use ConfigStateChecks and extract duplicate literals - Update import format to accept repository name (owner from provider) - Update documentation to reflect schema changes --- github/provider.go | 2 +- ...rce_github_repository_custom_properties.go | 196 +++++++++++++----- ...ithub_repository_custom_properties_test.go | 82 ++++---- ...repository_custom_properties.html.markdown | 14 +- 4 files changed, 184 insertions(+), 110 deletions(-) diff --git a/github/provider.go b/github/provider.go index b6db8efd83..eeb46a947a 100644 --- a/github/provider.go +++ b/github/provider.go @@ -192,7 +192,7 @@ func Provider() *schema.Provider { "github_repository_collaborator": resourceGithubRepositoryCollaborator(), "github_repository_collaborators": resourceGithubRepositoryCollaborators(), "github_repository_custom_property": resourceGithubRepositoryCustomProperty(), - "github_repository_custom_properties": resourceGithubRepositoryCustomProperties(), + "github_repository_custom_properties": resourceGithubRepositoryCustomProperties(), "github_repository_deploy_key": resourceGithubRepositoryDeployKey(), "github_repository_deployment_branch_policy": resourceGithubRepositoryDeploymentBranchPolicy(), "github_repository_environment": resourceGithubRepositoryEnvironment(), diff --git a/github/resource_github_repository_custom_properties.go b/github/resource_github_repository_custom_properties.go index cd799132cc..8ad74e462a 100644 --- a/github/resource_github_repository_custom_properties.go +++ b/github/resource_github_repository_custom_properties.go @@ -3,31 +3,41 @@ package github import ( "context" "fmt" - "log" - "strings" - "github.com/google/go-github/v83/github" + "github.com/google/go-github/v84/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) func resourceGithubRepositoryCustomProperties() *schema.Resource { return &schema.Resource{ Description: "Manages custom properties for a GitHub repository. This resource allows you to set multiple custom property values on a single repository in a single resource block, with in-place updates when values change.", - Create: resourceGithubRepositoryCustomPropertiesCreateOrUpdate, - Read: resourceGithubRepositoryCustomPropertiesRead, - Update: resourceGithubRepositoryCustomPropertiesCreateOrUpdate, - Delete: resourceGithubRepositoryCustomPropertiesDelete, + + CreateContext: resourceGithubRepositoryCustomPropertiesCreate, + ReadContext: resourceGithubRepositoryCustomPropertiesRead, + UpdateContext: resourceGithubRepositoryCustomPropertiesUpdate, + DeleteContext: resourceGithubRepositoryCustomPropertiesDelete, Importer: &schema.ResourceImporter{ StateContext: resourceGithubRepositoryCustomPropertiesImport, }, + CustomizeDiff: customdiff.All( + diffRepository, + ), + Schema: map[string]*schema.Schema{ - "repository_name": { + "repository": { Type: schema.TypeString, Required: true, - ForceNew: true, Description: "Name of the repository.", }, + "repository_id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the GitHub repository.", + }, "property": { Type: schema.TypeSet, Required: true, @@ -66,15 +76,10 @@ func resourceGithubRepositoryCustomPropertiesHash(v any) int { return schema.HashString(name) } -func resourceGithubRepositoryCustomPropertiesCreateOrUpdate(d *schema.ResourceData, meta any) error { - if err := checkOrganization(meta); err != nil { - return err - } - +func resourceGithubRepositoryCustomPropertiesApply(ctx context.Context, d *schema.ResourceData, meta any) error { client := meta.(*Owner).v3client - ctx := context.Background() owner := meta.(*Owner).name - repoName := d.Get("repository_name").(string) + repoName := d.Get("repository").(string) properties := d.Get("property").(*schema.Set).List() // Get all organization custom property definitions to determine types @@ -128,22 +133,68 @@ func resourceGithubRepositoryCustomPropertiesCreateOrUpdate(d *schema.ResourceDa return fmt.Errorf("error setting custom properties for repository %s/%s: %w", owner, repoName, err) } - d.SetId(buildTwoPartID(owner, repoName)) + return nil +} + +func resourceGithubRepositoryCustomPropertiesCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + err := checkOrganization(meta) + if err != nil { + return diag.FromErr(err) + } - return resourceGithubRepositoryCustomPropertiesRead(d, meta) + owner := meta.(*Owner).name + client := meta.(*Owner).v3client + repoName := d.Get("repository").(string) + + if err := resourceGithubRepositoryCustomPropertiesApply(ctx, d, meta); err != nil { + return diag.FromErr(err) + } + + id, err := buildID(owner, repoName) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return diag.FromErr(err) + } + + if err := d.Set("repository_id", int(repo.GetID())); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubRepositoryCustomPropertiesUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + err := checkOrganization(meta) + if err != nil { + return diag.FromErr(err) + } + + if err := resourceGithubRepositoryCustomPropertiesApply(ctx, d, meta); err != nil { + return diag.FromErr(err) + } + + return nil } -func resourceGithubRepositoryCustomPropertiesRead(d *schema.ResourceData, meta any) error { - if err := checkOrganization(meta); err != nil { - return err +func resourceGithubRepositoryCustomPropertiesRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + err := checkOrganization(meta) + if err != nil { + return diag.FromErr(err) } + ctx = tflog.SetField(ctx, "id", d.Id()) + client := meta.(*Owner).v3client - ctx := context.Background() + owner := meta.(*Owner).name - owner, repoName, err := parseTwoPartID(d.Id(), "owner", "repository") + _, repoName, err := parseID2(d.Id()) if err != nil { - return err + return diag.FromErr(err) } // Get current properties from state to know which ones we're managing. @@ -160,60 +211,73 @@ func resourceGithubRepositoryCustomPropertiesRead(d *schema.ResourceData, meta a // Read actual properties from GitHub allCustomProperties, _, err := client.Repositories.GetAllCustomPropertyValues(ctx, owner, repoName) if err != nil { - return fmt.Errorf("error reading custom properties for repository %s/%s: %w", owner, repoName, err) + return diag.FromErr(fmt.Errorf("error reading custom properties for repository %s/%s: %w", owner, repoName, err)) } - // Build the property set — either all properties (import) or only managed ones - managedProperties := make([]any, 0) - for _, prop := range allCustomProperties { - if !isImport && !managedPropertyNames[prop.PropertyName] { + managedProperties, err := filterManagedCustomProperties(allCustomProperties, managedPropertyNames, isImport) + if err != nil { + return diag.FromErr(fmt.Errorf("error processing custom properties for repository %s/%s: %w", owner, repoName, err)) + } + + // If no properties exist, remove resource from state + if len(managedProperties) == 0 { + tflog.Warn(ctx, "No custom properties found, removing from state", map[string]any{"owner": owner, "repository": repoName}) + d.SetId("") + return nil + } + + if err := d.Set("repository", repoName); err != nil { + return diag.FromErr(err) + } + if err := d.Set("property", managedProperties); err != nil { + return diag.FromErr(err) + } + + return nil +} + +// filterManagedCustomProperties builds the property set from GitHub API results, +// filtering to only managed properties (or all properties during import). +func filterManagedCustomProperties(allProps []*github.CustomPropertyValue, managed map[string]bool, isImport bool) ([]any, error) { + result := make([]any, 0) + for _, prop := range allProps { + if !isImport && !managed[prop.PropertyName] { continue } - // Skip properties with nil/null values (unset) if prop.Value == nil { continue } propertyValue, err := parseRepositoryCustomPropertyValueToStringSlice(prop) if err != nil { - return fmt.Errorf("error parsing property %q for repository %s/%s: %w", prop.PropertyName, owner, repoName, err) + return nil, fmt.Errorf("error parsing property %q: %w", prop.PropertyName, err) } if len(propertyValue) == 0 { continue } - managedProperties = append(managedProperties, map[string]any{ + result = append(result, map[string]any{ "name": prop.PropertyName, "value": propertyValue, }) } - - // If no properties exist, remove resource from state - if len(managedProperties) == 0 { - log.Printf("[WARN] No custom properties found for %s/%s, removing from state", owner, repoName) - d.SetId("") - return nil - } - - _ = d.Set("repository_name", repoName) - _ = d.Set("property", managedProperties) - - return nil + return result, nil } -func resourceGithubRepositoryCustomPropertiesDelete(d *schema.ResourceData, meta any) error { - if err := checkOrganization(meta); err != nil { - return err +func resourceGithubRepositoryCustomPropertiesDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + err := checkOrganization(meta) + if err != nil { + return diag.FromErr(err) } client := meta.(*Owner).v3client - ctx := context.Background() + owner := meta.(*Owner).name - owner, repoName, err := parseTwoPartID(d.Id(), "owner", "repository") + _, repoName, err := parseID2(d.Id()) if err != nil { - return err + return diag.FromErr(err) } properties := d.Get("property").(*schema.Set).List() @@ -233,20 +297,38 @@ func resourceGithubRepositoryCustomPropertiesDelete(d *schema.ResourceData, meta _, err = client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repoName, customProperties) if err != nil { - return fmt.Errorf("error deleting custom properties for repository %s/%s: %w", owner, repoName, err) + return diag.FromErr(fmt.Errorf("error deleting custom properties for repository %s/%s: %w", owner, repoName, err)) } return nil } func resourceGithubRepositoryCustomPropertiesImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { - // Import ID format: owner/repo (using standard two-part ID) - // On import, Read will detect empty state and import ALL properties - parts := strings.SplitN(d.Id(), "/", 2) - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return nil, fmt.Errorf("invalid import ID %q, expected format: owner/repository", d.Id()) + // Import ID format: — owner is inferred from the provider config. + // On import, Read will detect empty state and import ALL properties. + repoName := d.Id() + + owner := meta.(*Owner).name + client := meta.(*Owner).v3client + + id, err := buildID(owner, repoName) + if err != nil { + return nil, err + } + d.SetId(id) + + if err := d.Set("repository", repoName); err != nil { + return nil, err + } + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return nil, fmt.Errorf("failed to retrieve repository %s: %w", repoName, err) + } + + if err := d.Set("repository_id", int(repo.GetID())); err != nil { + return nil, err } - d.SetId(buildTwoPartID(parts[0], parts[1])) return []*schema.ResourceData{d}, nil } diff --git a/github/resource_github_repository_custom_properties_test.go b/github/resource_github_repository_custom_properties_test.go index 2dd27c1a96..4783938d98 100644 --- a/github/resource_github_repository_custom_properties_test.go +++ b/github/resource_github_repository_custom_properties_test.go @@ -6,13 +6,22 @@ import ( "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/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +const ( + testCustomPropsRepoNameFmt = "%srepo-custom-props-%s" + testCustomPropsEnvPropNameFmt = "tf-acc-env-%s" + testCustomPropsResourceAddr = "github_repository_custom_properties.test" ) func TestAccGithubRepositoryCustomProperties(t *testing.T) { t.Run("creates and reads multiple custom properties", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-custom-props-%s", testResourcePrefix, randomID) - envPropName := fmt.Sprintf("tf-acc-env-%s", randomID) + repoName := fmt.Sprintf(testCustomPropsRepoNameFmt, testResourcePrefix, randomID) + envPropName := fmt.Sprintf(testCustomPropsEnvPropNameFmt, randomID) teamPropName := fmt.Sprintf("tf-acc-team-%s", randomID) config := fmt.Sprintf(` @@ -35,7 +44,7 @@ func TestAccGithubRepositoryCustomProperties(t *testing.T) { } resource "github_repository_custom_properties" "test" { - repository_name = github_repository.test.name + repository = github_repository.test.name property { name = github_organization_custom_properties.environment.property_name @@ -49,24 +58,17 @@ func TestAccGithubRepositoryCustomProperties(t *testing.T) { } `, envPropName, teamPropName, repoName) - check := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_repository_custom_properties.test", "repository_name", repoName), - resource.TestCheckResourceAttr("github_repository_custom_properties.test", "property.#", "2"), - resource.TestCheckTypeSetElemNestedAttrs("github_repository_custom_properties.test", "property.*", map[string]string{ - "name": envPropName, - }), - resource.TestCheckTypeSetElemNestedAttrs("github_repository_custom_properties.test", "property.*", map[string]string{ - "name": teamPropName, - }), - ) - resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasOrgs(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: config, - Check: check, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testCustomPropsResourceAddr, tfjsonpath.New("repository"), knownvalue.StringExact(repoName)), + statecheck.ExpectKnownValue(testCustomPropsResourceAddr, tfjsonpath.New("repository_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue(testCustomPropsResourceAddr, tfjsonpath.New("property"), knownvalue.SetSizeExact(2)), + }, }, }, }) @@ -74,8 +76,8 @@ func TestAccGithubRepositoryCustomProperties(t *testing.T) { t.Run("updates property value in place", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-custom-props-%s", testResourcePrefix, randomID) - propName := fmt.Sprintf("tf-acc-env-%s", randomID) + repoName := fmt.Sprintf(testCustomPropsRepoNameFmt, testResourcePrefix, randomID) + propName := fmt.Sprintf(testCustomPropsEnvPropNameFmt, randomID) configCreate := fmt.Sprintf(` resource "github_organization_custom_properties" "test" { @@ -91,7 +93,7 @@ func TestAccGithubRepositoryCustomProperties(t *testing.T) { } resource "github_repository_custom_properties" "test" { - repository_name = github_repository.test.name + repository = github_repository.test.name property { name = github_organization_custom_properties.test.property_name @@ -114,7 +116,7 @@ func TestAccGithubRepositoryCustomProperties(t *testing.T) { } resource "github_repository_custom_properties" "test" { - repository_name = github_repository.test.name + repository = github_repository.test.name property { name = github_organization_custom_properties.test.property_name @@ -129,21 +131,15 @@ func TestAccGithubRepositoryCustomProperties(t *testing.T) { Steps: []resource.TestStep{ { Config: configCreate, - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_repository_custom_properties.test", "property.#", "1"), - resource.TestCheckTypeSetElemNestedAttrs("github_repository_custom_properties.test", "property.*", map[string]string{ - "name": propName, - }), - ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testCustomPropsResourceAddr, tfjsonpath.New("property"), knownvalue.SetSizeExact(1)), + }, }, { Config: configUpdate, - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_repository_custom_properties.test", "property.#", "1"), - resource.TestCheckTypeSetElemNestedAttrs("github_repository_custom_properties.test", "property.*", map[string]string{ - "name": propName, - }), - ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testCustomPropsResourceAddr, tfjsonpath.New("property"), knownvalue.SetSizeExact(1)), + }, }, }, }) @@ -151,8 +147,8 @@ func TestAccGithubRepositoryCustomProperties(t *testing.T) { t.Run("imports all properties for a repository", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-custom-props-%s", testResourcePrefix, randomID) - propName := fmt.Sprintf("tf-acc-env-%s", randomID) + repoName := fmt.Sprintf(testCustomPropsRepoNameFmt, testResourcePrefix, randomID) + propName := fmt.Sprintf(testCustomPropsEnvPropNameFmt, randomID) config := fmt.Sprintf(` resource "github_organization_custom_properties" "test" { @@ -168,7 +164,7 @@ func TestAccGithubRepositoryCustomProperties(t *testing.T) { } resource "github_repository_custom_properties" "test" { - repository_name = github_repository.test.name + repository = github_repository.test.name property { name = github_organization_custom_properties.test.property_name @@ -185,7 +181,7 @@ func TestAccGithubRepositoryCustomProperties(t *testing.T) { Config: config, }, { - ResourceName: "github_repository_custom_properties.test", + ResourceName: testCustomPropsResourceAddr, ImportState: true, ImportStateVerify: true, }, @@ -195,7 +191,7 @@ func TestAccGithubRepositoryCustomProperties(t *testing.T) { t.Run("creates multi_select property", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-custom-props-%s", testResourcePrefix, randomID) + repoName := fmt.Sprintf(testCustomPropsRepoNameFmt, testResourcePrefix, randomID) propName := fmt.Sprintf("tf-acc-tags-%s", randomID) config := fmt.Sprintf(` @@ -212,7 +208,7 @@ func TestAccGithubRepositoryCustomProperties(t *testing.T) { } resource "github_repository_custom_properties" "test" { - repository_name = github_repository.test.name + repository = github_repository.test.name property { name = github_organization_custom_properties.test.property_name @@ -221,21 +217,15 @@ func TestAccGithubRepositoryCustomProperties(t *testing.T) { } `, propName, repoName) - check := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_repository_custom_properties.test", "property.#", "1"), - resource.TestCheckTypeSetElemNestedAttrs("github_repository_custom_properties.test", "property.*", map[string]string{ - "name": propName, - "value.#": "2", - }), - ) - resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasOrgs(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { Config: config, - Check: check, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testCustomPropsResourceAddr, tfjsonpath.New("property"), knownvalue.SetSizeExact(1)), + }, }, }, }) diff --git a/website/docs/r/repository_custom_properties.html.markdown b/website/docs/r/repository_custom_properties.html.markdown index b1305aa250..d20fe0d441 100644 --- a/website/docs/r/repository_custom_properties.html.markdown +++ b/website/docs/r/repository_custom_properties.html.markdown @@ -21,7 +21,7 @@ resource "github_repository" "example" { } resource "github_repository_custom_properties" "example" { - repository_name = github_repository.example.name + repository = github_repository.example.name property { name = "environment" @@ -39,7 +39,7 @@ resource "github_repository_custom_properties" "example" { ```hcl resource "github_repository_custom_properties" "example" { - repository_name = "my-repo" + repository = "my-repo" property { name = "languages" @@ -57,7 +57,9 @@ resource "github_repository_custom_properties" "example" { The following arguments are supported: -* `repository_name` - (Required) The name of the repository. Changing this will force the resource to be recreated. +* `repository` - (Required) The name of the repository. + +* `repository_id` - The ID of the GitHub repository (computed). * `property` - (Required) One or more property blocks as defined below. At least one must be specified. @@ -71,14 +73,14 @@ The following arguments are supported: In addition to all arguments above, the following attributes are exported: -* `id` - A composite ID in the format `owner:repository_name`. +* `id` - A composite ID in the format `owner:repository`. ## Import -Repository custom properties can be imported using the `owner/repository_name` format. When imported, **all** custom property values currently set on the repository will be imported into state. +Repository custom properties can be imported using the repository name. When imported, **all** custom property values currently set on the repository will be imported into state. ``` -terraform import github_repository_custom_properties.example my-org/my-repo +terraform import github_repository_custom_properties.example my-repo ``` ## Differences from `github_repository_custom_property` From 18c09b525e844f48d91822f2c725ee0621e4289a Mon Sep 17 00:00:00 2001 From: Misha Kushakov Date: Mon, 27 Apr 2026 17:17:33 +0200 Subject: [PATCH 4/6] docs(repository_custom_properties): clarify non-authoritative behavior and import management --- website/docs/r/repository_custom_properties.html.markdown | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/website/docs/r/repository_custom_properties.html.markdown b/website/docs/r/repository_custom_properties.html.markdown index d20fe0d441..9c320fd9a5 100644 --- a/website/docs/r/repository_custom_properties.html.markdown +++ b/website/docs/r/repository_custom_properties.html.markdown @@ -9,6 +9,8 @@ description: |- This resource allows you to manage multiple custom property values for a GitHub repository in a single resource block. Property values are updated in-place when changed, without recreating the resource. +~> **Note:** This resource is **non-authoritative**: it only manages the custom property values explicitly declared in the `property` blocks. Any other custom properties set on the repository by other sources (UI, API, or other tooling) will be ignored and left unchanged. + ~> **Note:** This resource manages **values** for custom properties that have already been defined at the organization level (e.g. using [`github_organization_custom_properties`](organization_custom_properties.html)). It cannot create new property definitions. ~> **Note:** This resource requires the provider to be configured with an organization owner. Individual user accounts are not supported. @@ -77,7 +79,7 @@ In addition to all arguments above, the following attributes are exported: ## Import -Repository custom properties can be imported using the repository name. When imported, **all** custom property values currently set on the repository will be imported into state. +Repository custom properties can be imported using the repository name. When imported, **all** custom property values currently set on the repository will be imported into state. After import, only the properties present in your configuration will continue to be managed; any properties not declared in `property` blocks will be ignored on subsequent plans. ``` terraform import github_repository_custom_properties.example my-repo @@ -85,6 +87,6 @@ terraform import github_repository_custom_properties.example my-repo ## Differences from `github_repository_custom_property` -This resource (`github_repository_custom_properties`, plural) manages **all** custom property values for a repository in a single resource block, with in-place updates when values change. This is useful when you want to manage multiple properties together as a unit. +This resource (`github_repository_custom_properties`, plural) manages **multiple** custom property values for a repository in a single resource block, with in-place updates when values change. It is non-authoritative — only the properties declared in the configuration are managed. This is useful when you want to manage multiple properties together as a unit without affecting properties managed elsewhere. The singular [`github_repository_custom_property`](repository_custom_property.html) resource manages a **single** property value per resource instance. Use it when you need independent lifecycle management for each property. From c1fcd971a92d6edd449a6f3b953c2164f521faa3 Mon Sep 17 00:00:00 2001 From: Misha Kushakov Date: Mon, 27 Apr 2026 17:40:59 +0200 Subject: [PATCH 5/6] feat(repository_custom_properties): enhance resource description and improve error handling Co-authored-by: Copilot --- ...rce_github_repository_custom_properties.go | 30 ++++------ ...ithub_repository_custom_properties_test.go | 55 ++++++++----------- ...repository_custom_properties.html.markdown | 4 +- 3 files changed, 37 insertions(+), 52 deletions(-) diff --git a/github/resource_github_repository_custom_properties.go b/github/resource_github_repository_custom_properties.go index 8ad74e462a..651fde8bfd 100644 --- a/github/resource_github_repository_custom_properties.go +++ b/github/resource_github_repository_custom_properties.go @@ -3,6 +3,7 @@ package github import ( "context" "fmt" + "net/http" "github.com/google/go-github/v84/github" "github.com/hashicorp/terraform-plugin-log/tflog" @@ -13,7 +14,7 @@ import ( func resourceGithubRepositoryCustomProperties() *schema.Resource { return &schema.Resource{ - Description: "Manages custom properties for a GitHub repository. This resource allows you to set multiple custom property values on a single repository in a single resource block, with in-place updates when values change.", + Description: "Manages custom properties for a GitHub repository. This resource is non-authoritative: it only manages the property values explicitly declared in the resource block, with in-place updates when values change. Properties set by other sources (UI, API, or other tooling) are ignored.", CreateContext: resourceGithubRepositoryCustomPropertiesCreate, ReadContext: resourceGithubRepositoryCustomPropertiesRead, @@ -191,11 +192,7 @@ func resourceGithubRepositoryCustomPropertiesRead(ctx context.Context, d *schema client := meta.(*Owner).v3client owner := meta.(*Owner).name - - _, repoName, err := parseID2(d.Id()) - if err != nil { - return diag.FromErr(err) - } + repoName := d.Get("repository").(string) // Get current properties from state to know which ones we're managing. // On import this will be empty, which is handled below. @@ -211,12 +208,12 @@ func resourceGithubRepositoryCustomPropertiesRead(ctx context.Context, d *schema // Read actual properties from GitHub allCustomProperties, _, err := client.Repositories.GetAllCustomPropertyValues(ctx, owner, repoName) if err != nil { - return diag.FromErr(fmt.Errorf("error reading custom properties for repository %s/%s: %w", owner, repoName, err)) + return diag.Errorf("error reading custom properties for repository %s/%s: %v", owner, repoName, err) } managedProperties, err := filterManagedCustomProperties(allCustomProperties, managedPropertyNames, isImport) if err != nil { - return diag.FromErr(fmt.Errorf("error processing custom properties for repository %s/%s: %w", owner, repoName, err)) + return diag.Errorf("error processing custom properties for repository %s/%s: %v", owner, repoName, err) } // If no properties exist, remove resource from state @@ -274,16 +271,9 @@ func resourceGithubRepositoryCustomPropertiesDelete(ctx context.Context, d *sche client := meta.(*Owner).v3client owner := meta.(*Owner).name - - _, repoName, err := parseID2(d.Id()) - if err != nil { - return diag.FromErr(err) - } + repoName := d.Get("repository").(string) properties := d.Get("property").(*schema.Set).List() - if len(properties) == 0 { - return nil - } // Set all managed properties to nil (removes them) customProperties := make([]*github.CustomPropertyValue, 0, len(properties)) @@ -295,9 +285,13 @@ func resourceGithubRepositoryCustomPropertiesDelete(ctx context.Context, d *sche }) } - _, err = client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repoName, customProperties) + resp, err := client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repoName, customProperties) if err != nil { - return diag.FromErr(fmt.Errorf("error deleting custom properties for repository %s/%s: %w", owner, repoName, err)) + // If the repository was deleted, the resource is already gone + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil + } + return diag.FromErr(handleArchivedRepoDelete(err, "custom properties", repoName, owner, repoName)) } return nil diff --git a/github/resource_github_repository_custom_properties_test.go b/github/resource_github_repository_custom_properties_test.go index 4783938d98..ddce234ffa 100644 --- a/github/resource_github_repository_custom_properties_test.go +++ b/github/resource_github_repository_custom_properties_test.go @@ -7,6 +7,7 @@ import ( "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" ) @@ -67,7 +68,7 @@ func TestAccGithubRepositoryCustomProperties(t *testing.T) { ConfigStateChecks: []statecheck.StateCheck{ statecheck.ExpectKnownValue(testCustomPropsResourceAddr, tfjsonpath.New("repository"), knownvalue.StringExact(repoName)), statecheck.ExpectKnownValue(testCustomPropsResourceAddr, tfjsonpath.New("repository_id"), knownvalue.NotNull()), - statecheck.ExpectKnownValue(testCustomPropsResourceAddr, tfjsonpath.New("property"), knownvalue.SetSizeExact(2)), + statecheck.ExpectKnownValue(testCustomPropsResourceAddr, tfjsonpath.New("property"), knownvalue.NotNull()), }, }, }, @@ -79,7 +80,7 @@ func TestAccGithubRepositoryCustomProperties(t *testing.T) { repoName := fmt.Sprintf(testCustomPropsRepoNameFmt, testResourcePrefix, randomID) propName := fmt.Sprintf(testCustomPropsEnvPropNameFmt, randomID) - configCreate := fmt.Sprintf(` + configTmpl := ` resource "github_organization_custom_properties" "test" { allowed_values = ["production", "staging", "development"] description = "Deployment environment" @@ -97,48 +98,30 @@ func TestAccGithubRepositoryCustomProperties(t *testing.T) { property { name = github_organization_custom_properties.test.property_name - value = ["production"] - } - } - `, propName, repoName) - - configUpdate := fmt.Sprintf(` - resource "github_organization_custom_properties" "test" { - allowed_values = ["production", "staging", "development"] - description = "Deployment environment" - property_name = "%s" - value_type = "single_select" - } - - resource "github_repository" "test" { - name = "%s" - auto_init = true - } - - resource "github_repository_custom_properties" "test" { - repository = github_repository.test.name - - property { - name = github_organization_custom_properties.test.property_name - value = ["staging"] + value = ["%s"] } } - `, propName, repoName) + ` resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasOrgs(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: configCreate, + Config: fmt.Sprintf(configTmpl, propName, repoName, "production"), ConfigStateChecks: []statecheck.StateCheck{ - statecheck.ExpectKnownValue(testCustomPropsResourceAddr, tfjsonpath.New("property"), knownvalue.SetSizeExact(1)), + statecheck.ExpectKnownValue(testCustomPropsResourceAddr, tfjsonpath.New("property"), knownvalue.NotNull()), }, }, { - Config: configUpdate, + Config: fmt.Sprintf(configTmpl, propName, repoName, "staging"), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction(testCustomPropsResourceAddr, plancheck.ResourceActionUpdate), + }, + }, ConfigStateChecks: []statecheck.StateCheck{ - statecheck.ExpectKnownValue(testCustomPropsResourceAddr, tfjsonpath.New("property"), knownvalue.SetSizeExact(1)), + statecheck.ExpectKnownValue(testCustomPropsResourceAddr, tfjsonpath.New("property"), knownvalue.NotNull()), }, }, }, @@ -224,7 +207,15 @@ func TestAccGithubRepositoryCustomProperties(t *testing.T) { { Config: config, ConfigStateChecks: []statecheck.StateCheck{ - statecheck.ExpectKnownValue(testCustomPropsResourceAddr, tfjsonpath.New("property"), knownvalue.SetSizeExact(1)), + statecheck.ExpectKnownValue(testCustomPropsResourceAddr, tfjsonpath.New("property"), knownvalue.SetPartial([]knownvalue.Check{ + knownvalue.ObjectPartial(map[string]knownvalue.Check{ + "name": knownvalue.StringExact(propName), + "value": knownvalue.SetExact([]knownvalue.Check{ + knownvalue.StringExact("go"), + knownvalue.StringExact("rust"), + }), + }), + })), }, }, }, diff --git a/website/docs/r/repository_custom_properties.html.markdown b/website/docs/r/repository_custom_properties.html.markdown index 9c320fd9a5..b56a115ce7 100644 --- a/website/docs/r/repository_custom_properties.html.markdown +++ b/website/docs/r/repository_custom_properties.html.markdown @@ -61,8 +61,6 @@ The following arguments are supported: * `repository` - (Required) The name of the repository. -* `repository_id` - The ID of the GitHub repository (computed). - * `property` - (Required) One or more property blocks as defined below. At least one must be specified. ### property @@ -77,6 +75,8 @@ In addition to all arguments above, the following attributes are exported: * `id` - A composite ID in the format `owner:repository`. +* `repository_id` - The ID of the GitHub repository. + ## Import Repository custom properties can be imported using the repository name. When imported, **all** custom property values currently set on the repository will be imported into state. After import, only the properties present in your configuration will continue to be managed; any properties not declared in `property` blocks will be ignored on subsequent plans. From 03d7c07ee055a9ab11ca41ba3e1410c9e23fd2cc Mon Sep 17 00:00:00 2001 From: Misha Kushakov Date: Mon, 27 Apr 2026 18:06:35 +0200 Subject: [PATCH 6/6] fix(repository_custom_properties): improve error handling for repository not found scenarios Co-authored-by: Copilot --- ...urce_github_repository_custom_properties.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/github/resource_github_repository_custom_properties.go b/github/resource_github_repository_custom_properties.go index 651fde8bfd..9cae6126af 100644 --- a/github/resource_github_repository_custom_properties.go +++ b/github/resource_github_repository_custom_properties.go @@ -2,6 +2,7 @@ package github import ( "context" + "errors" "fmt" "net/http" @@ -208,6 +209,12 @@ func resourceGithubRepositoryCustomPropertiesRead(ctx context.Context, d *schema // Read actual properties from GitHub allCustomProperties, _, err := client.Repositories.GetAllCustomPropertyValues(ctx, owner, repoName) if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Warn(ctx, "Repository not found, removing from state", map[string]any{"owner": owner, "repository": repoName}) + d.SetId("") + return nil + } return diag.Errorf("error reading custom properties for repository %s/%s: %v", owner, repoName, err) } @@ -274,6 +281,9 @@ func resourceGithubRepositoryCustomPropertiesDelete(ctx context.Context, d *sche repoName := d.Get("repository").(string) properties := d.Get("property").(*schema.Set).List() + if len(properties) == 0 { + return nil + } // Set all managed properties to nil (removes them) customProperties := make([]*github.CustomPropertyValue, 0, len(properties)) @@ -285,13 +295,13 @@ func resourceGithubRepositoryCustomPropertiesDelete(ctx context.Context, d *sche }) } - resp, err := client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repoName, customProperties) + _, err = client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repoName, customProperties) if err != nil { - // If the repository was deleted, the resource is already gone - if resp != nil && resp.StatusCode == http.StatusNotFound { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { return nil } - return diag.FromErr(handleArchivedRepoDelete(err, "custom properties", repoName, owner, repoName)) + return diag.Errorf("error deleting custom properties for repository %s/%s: %v", owner, repoName, err) } return nil