diff --git a/github/provider.go b/github/provider.go index 2d5d6dc33a..eeb46a947a 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..8ad74e462a --- /dev/null +++ b/github/resource_github_repository_custom_properties.go @@ -0,0 +1,334 @@ +package github + +import ( + "context" + "fmt" + + "github.com/google/go-github/v84/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubRepositoryCustomProperties() *schema.Resource { + return &schema.Resource{ + Description: "Manages custom properties for a GitHub repository. This resource allows you to set multiple custom property values on a single repository in a single resource block, with in-place updates when values change.", + + CreateContext: resourceGithubRepositoryCustomPropertiesCreate, + ReadContext: resourceGithubRepositoryCustomPropertiesRead, + UpdateContext: resourceGithubRepositoryCustomPropertiesUpdate, + DeleteContext: resourceGithubRepositoryCustomPropertiesDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubRepositoryCustomPropertiesImport, + }, + + CustomizeDiff: customdiff.All( + diffRepository, + ), + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + Description: "Name of the repository.", + }, + "repository_id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the GitHub repository.", + }, + "property": { + Type: schema.TypeSet, + Required: true, + MinItems: 1, + Description: "Set of custom property values for this repository.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "Name of the custom property (must be defined at the organization level).", + }, + "value": { + Type: schema.TypeSet, + Required: true, + MinItems: 1, + Description: "Value(s) of the custom property. For multi_select properties, multiple values can be specified.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + }, + Set: resourceGithubRepositoryCustomPropertiesHash, + }, + }, + } +} + +// resourceGithubRepositoryCustomPropertiesHash creates a hash for a property block +// using only the property name, so that value changes are detected as in-place +// updates rather than remove+add within the set. +func resourceGithubRepositoryCustomPropertiesHash(v any) int { + raw := v.(map[string]any) + name := raw["name"].(string) + return schema.HashString(name) +} + +func resourceGithubRepositoryCustomPropertiesApply(ctx context.Context, d *schema.ResourceData, meta any) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + repoName := d.Get("repository").(string) + properties := d.Get("property").(*schema.Set).List() + + // Get all organization custom property definitions to determine types + orgProperties, _, err := client.Organizations.GetAllCustomProperties(ctx, owner) + if err != nil { + return fmt.Errorf("error reading organization custom property definitions: %w", err) + } + + // Create a map of property names to their types + propertyTypes := make(map[string]github.PropertyValueType) + for _, prop := range orgProperties { + if prop.PropertyName != nil { + propertyTypes[*prop.PropertyName] = prop.ValueType + } + } + + // Build custom property values for this repository + customProperties := make([]*github.CustomPropertyValue, 0, len(properties)) + + for _, propBlock := range properties { + propMap := propBlock.(map[string]any) + propertyName := propMap["name"].(string) + propertyValues := expandStringList(propMap["value"].(*schema.Set).List()) + + propertyType, ok := propertyTypes[propertyName] + if !ok { + return fmt.Errorf("custom property %q is not defined at the organization level", propertyName) + } + + customProperty := &github.CustomPropertyValue{ + PropertyName: propertyName, + } + + switch propertyType { + case github.PropertyValueTypeMultiSelect: + customProperty.Value = propertyValues + case github.PropertyValueTypeString, github.PropertyValueTypeSingleSelect, + github.PropertyValueTypeTrueFalse, github.PropertyValueTypeURL: + if len(propertyValues) > 0 { + customProperty.Value = propertyValues[0] + } + default: + return fmt.Errorf("unsupported property type %q for property %q", propertyType, propertyName) + } + + customProperties = append(customProperties, customProperty) + } + + _, err = client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repoName, customProperties) + if err != nil { + return fmt.Errorf("error setting custom properties for repository %s/%s: %w", owner, repoName, err) + } + + return nil +} + +func resourceGithubRepositoryCustomPropertiesCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + err := checkOrganization(meta) + if err != nil { + return diag.FromErr(err) + } + + owner := meta.(*Owner).name + client := meta.(*Owner).v3client + repoName := d.Get("repository").(string) + + if err := resourceGithubRepositoryCustomPropertiesApply(ctx, d, meta); err != nil { + return diag.FromErr(err) + } + + id, err := buildID(owner, repoName) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return diag.FromErr(err) + } + + if err := d.Set("repository_id", int(repo.GetID())); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubRepositoryCustomPropertiesUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + err := checkOrganization(meta) + if err != nil { + return diag.FromErr(err) + } + + if err := resourceGithubRepositoryCustomPropertiesApply(ctx, d, meta); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubRepositoryCustomPropertiesRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + err := checkOrganization(meta) + if err != nil { + return diag.FromErr(err) + } + + ctx = tflog.SetField(ctx, "id", d.Id()) + + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + + _, repoName, err := parseID2(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + // Get current properties from state to know which ones we're managing. + // On import this will be empty, which is handled below. + propertiesFromState := d.Get("property").(*schema.Set).List() + managedPropertyNames := make(map[string]bool) + for _, propBlock := range propertiesFromState { + propMap := propBlock.(map[string]any) + managedPropertyNames[propMap["name"].(string)] = true + } + + isImport := len(managedPropertyNames) == 0 + + // Read actual properties from GitHub + allCustomProperties, _, err := client.Repositories.GetAllCustomPropertyValues(ctx, owner, repoName) + if err != nil { + return diag.FromErr(fmt.Errorf("error reading custom properties for repository %s/%s: %w", 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)) + } + + // 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 + } + + if prop.Value == nil { + continue + } + + propertyValue, err := parseRepositoryCustomPropertyValueToStringSlice(prop) + if err != nil { + return nil, fmt.Errorf("error parsing property %q: %w", prop.PropertyName, err) + } + + if len(propertyValue) == 0 { + continue + } + + result = append(result, map[string]any{ + "name": prop.PropertyName, + "value": propertyValue, + }) + } + return result, nil +} + +func resourceGithubRepositoryCustomPropertiesDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + err := checkOrganization(meta) + if err != nil { + return diag.FromErr(err) + } + + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + + _, repoName, err := parseID2(d.Id()) + if err != nil { + return diag.FromErr(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 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 is inferred from the provider config. + // On import, Read will detect empty state and import ALL properties. + repoName := d.Id() + + owner := meta.(*Owner).name + client := meta.(*Owner).v3client + + id, err := buildID(owner, repoName) + if err != nil { + return nil, err + } + d.SetId(id) + + if err := d.Set("repository", repoName); err != nil { + return nil, err + } + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return nil, fmt.Errorf("failed to retrieve repository %s: %w", repoName, err) + } + + if err := d.Set("repository_id", int(repo.GetID())); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} 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..4783938d98 --- /dev/null +++ b/github/resource_github_repository_custom_properties_test.go @@ -0,0 +1,233 @@ +package github + +import ( + "fmt" + "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/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(testCustomPropsRepoNameFmt, testResourcePrefix, randomID) + envPropName := fmt.Sprintf(testCustomPropsEnvPropNameFmt, 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 = 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) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + 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)), + }, + }, + }, + }) + }) + + t.Run("updates property value in place", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf(testCustomPropsRepoNameFmt, testResourcePrefix, randomID) + propName := fmt.Sprintf(testCustomPropsEnvPropNameFmt, 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 = 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 = 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, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testCustomPropsResourceAddr, tfjsonpath.New("property"), knownvalue.SetSizeExact(1)), + }, + }, + { + Config: configUpdate, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(testCustomPropsResourceAddr, tfjsonpath.New("property"), knownvalue.SetSizeExact(1)), + }, + }, + }, + }) + }) + + t.Run("imports all properties for a repository", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf(testCustomPropsRepoNameFmt, testResourcePrefix, randomID) + propName := fmt.Sprintf(testCustomPropsEnvPropNameFmt, 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 = 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: testCustomPropsResourceAddr, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) + + t.Run("creates multi_select property", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf(testCustomPropsRepoNameFmt, 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 = github_repository.test.name + + property { + name = github_organization_custom_properties.test.property_name + value = ["go", "rust"] + } + } + `, propName, repoName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + 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 new file mode 100644 index 0000000000..d20fe0d441 --- /dev/null +++ b/website/docs/r/repository_custom_properties.html.markdown @@ -0,0 +1,90 @@ +--- +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 = 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 = "my-repo" + + property { + name = "languages" + value = ["go", "typescript", "python"] + } + + property { + name = "environment" + value = ["staging"] + } +} +``` + +## Argument Reference + +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 + +* `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`. + +## 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. + +``` +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. + +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.