diff --git a/github/resource_github_organization_ruleset.go b/github/resource_github_organization_ruleset.go index 1fac297d03..1560fb3a1e 100644 --- a/github/resource_github_organization_ruleset.go +++ b/github/resource_github_organization_ruleset.go @@ -94,7 +94,7 @@ func resourceGithubOrganizationRuleset() *schema.Resource { Type: schema.TypeList, Optional: true, MaxItems: 1, - Description: "Parameters for an organization ruleset condition. `ref_name` is required for `branch` and `tag` targets, but must not be set for `push` targets. One of `repository_name` or `repository_id` is always required.", + Description: "Parameters for an organization ruleset condition.The branch and tag rulesets conditions object should contain both repository_name and ref_name properties, or both repository_id and ref_name properties, or both repository_property and ref_name properties. The push rulesets conditions object does not require the ref_name property.", Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "ref_name": { @@ -123,13 +123,83 @@ func resourceGithubOrganizationRuleset() *schema.Resource { }, }, }, + "repository_property": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + ExactlyOneOf: []string{"conditions.0.repository_property", "conditions.0.repository_name", "conditions.0.repository_id"}, + Description: "Conditions to target repositories by custom or system properties.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "include": { + Type: schema.TypeList, + Optional: true, + ConfigMode: schema.SchemaConfigModeAttr, + Description: "The repository properties and values to include. All of these properties must match for the condition to pass.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the repository property to target.", + }, + "property_values": { + Type: schema.TypeList, + Required: true, + Description: "The values to match for the repository property.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "source": { + Type: schema.TypeString, + Optional: true, + Description: "The source of the repository property. Defaults to 'custom' if not specified. Can be one of: custom, system", + Default: "custom", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"custom", "system"}, false)), + }, + }, + }, + }, + "exclude": { + Type: schema.TypeList, + Optional: true, + ConfigMode: schema.SchemaConfigModeAttr, + Description: "The repository properties and values to exclude. The ruleset will not apply if any of these properties match.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the repository property to target.", + }, + "property_values": { + Type: schema.TypeList, + Required: true, + Description: "The values to match for the repository property.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "source": { + Type: schema.TypeString, + Optional: true, + Description: "The source of the repository property. Defaults to 'custom' if not specified. Can be one of: custom, system", + Default: "custom", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"custom", "system"}, false)), + }, + }, + }, + }, + }, + }, + }, "repository_name": { Type: schema.TypeList, Optional: true, MaxItems: 1, + ExactlyOneOf: []string{"conditions.0.repository_property", "conditions.0.repository_name", "conditions.0.repository_id"}, Description: "Targets repositories that match the specified name patterns.", - ExactlyOneOf: []string{"conditions.0.repository_id"}, - AtLeastOneOf: []string{"conditions.0.repository_id"}, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "include": { @@ -158,9 +228,10 @@ func resourceGithubOrganizationRuleset() *schema.Resource { }, }, "repository_id": { - Type: schema.TypeList, - Optional: true, - Description: "The repository IDs that the ruleset applies to. One of these IDs must match for the condition to pass.", + Type: schema.TypeList, + Optional: true, + ExactlyOneOf: []string{"conditions.0.repository_property", "conditions.0.repository_name", "conditions.0.repository_id"}, + Description: "The repository IDs that the ruleset applies to. One of these IDs must match for the ruleset to apply.", Elem: &schema.Schema{ Type: schema.TypeInt, }, diff --git a/github/resource_github_organization_ruleset_test.go b/github/resource_github_organization_ruleset_test.go index 6f8dd502c5..4a08230946 100644 --- a/github/resource_github_organization_ruleset_test.go +++ b/github/resource_github_organization_ruleset_test.go @@ -8,6 +8,9 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "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" ) func TestAccGithubOrganizationRuleset(t *testing.T) { @@ -182,6 +185,300 @@ resource "github_organization_ruleset" "test" { }) }) + t.Run("create_ruleset_with_repository_property", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + rulesetName := fmt.Sprintf("%s-repo-prop-ruleset-%s", testResourcePrefix, randomID) + propName := fmt.Sprintf("%s_team_%s", testResourcePrefix, randomID) + + config := fmt.Sprintf(` +resource "github_organization_custom_properties" "team" { + property_name = "%[2]s" + value_type = "single_select" + required = false + allowed_values = ["blue", "red", "backend", "platform"] +} + +resource "github_organization_ruleset" "test" { + name = "%[1]s" + target = "branch" + enforcement = "active" + + conditions { + repository_property { + include = [{ + name = github_organization_custom_properties.team.property_name + source = "custom" + property_values = ["blue"] + }] + exclude = [] + } + + ref_name { + include = ["~DEFAULT_BRANCH"] + exclude = [] + } + } + + rules { + creation = true + update = true + deletion = true + required_linear_history = true + } +} +`, rulesetName, propName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasPaidOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("name"), knownvalue.StringExact(rulesetName)), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("target"), knownvalue.StringExact("branch")), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("enforcement"), knownvalue.StringExact("active")), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("include").AtSliceIndex(0).AtMapKey("name"), knownvalue.StringExact(propName)), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("include").AtSliceIndex(0).AtMapKey("source"), knownvalue.StringExact("custom")), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("include").AtSliceIndex(0).AtMapKey("property_values").AtSliceIndex(0), knownvalue.StringExact("blue")), + }, + }, + }, + }) + }) + + t.Run("create_ruleset_with_repository_property_exclude", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + rulesetName := fmt.Sprintf("%s-repo-prop-exclude-ruleset-%s", testResourcePrefix, randomID) + propName := fmt.Sprintf("%s_team_%s", testResourcePrefix, randomID) + + config := fmt.Sprintf(` +resource "github_organization_custom_properties" "team" { + property_name = "%[2]s" + value_type = "single_select" + required = false + allowed_values = ["blue", "red", "backend", "platform"] +} + +resource "github_organization_ruleset" "test" { + name = "%[1]s" + target = "branch" + enforcement = "active" + + conditions { + repository_property { + include = [] + exclude = [{ + name = github_organization_custom_properties.team.property_name + source = "custom" + property_values = ["red"] + }] + } + + ref_name { + include = ["~DEFAULT_BRANCH"] + exclude = [] + } + } + + rules { + required_linear_history = true + } +} +`, rulesetName, propName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasPaidOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("name"), knownvalue.StringExact(rulesetName)), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("exclude").AtSliceIndex(0).AtMapKey("name"), knownvalue.StringExact(propName)), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("exclude").AtSliceIndex(0).AtMapKey("source"), knownvalue.StringExact("custom")), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("exclude").AtSliceIndex(0).AtMapKey("property_values").AtSliceIndex(0), knownvalue.StringExact("red")), + }, + }, + }, + }) + }) + + t.Run("create_ruleset_with_multiple_repository_properties", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + rulesetName := fmt.Sprintf("%s-repo-prop-multiple-%s", testResourcePrefix, randomID) + propEnvironmentName := fmt.Sprintf("%s_environment_%s", testResourcePrefix, randomID) + propTierName := fmt.Sprintf("%s_tier_%s", testResourcePrefix, randomID) + + config := fmt.Sprintf(` +resource "github_organization_custom_properties" "environment" { + property_name = "%[2]s" + value_type = "single_select" + required = false + allowed_values = ["production", "staging"] +} + +resource "github_organization_custom_properties" "tier" { + property_name = "%[3]s" + value_type = "single_select" + required = false + allowed_values = ["premium", "enterprise"] +} + +resource "github_organization_ruleset" "test" { + name = "%[1]s" + target = "branch" + enforcement = "active" + + conditions { + repository_property { + include = [ + { + name = github_organization_custom_properties.environment.property_name + source = "custom" + property_values = ["production"] + }, + { + name = github_organization_custom_properties.tier.property_name + source = "custom" + property_values = ["premium", "enterprise"] + } + ] + exclude = [] + } + + ref_name { + include = ["~DEFAULT_BRANCH"] + exclude = [] + } + } + + rules { + required_signatures = true + } +} +`, rulesetName, propEnvironmentName, propTierName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasPaidOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("name"), knownvalue.StringExact(rulesetName)), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("include").AtSliceIndex(0).AtMapKey("name"), knownvalue.StringExact(propEnvironmentName)), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("include").AtSliceIndex(0).AtMapKey("source"), knownvalue.StringExact("custom")), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("include").AtSliceIndex(0).AtMapKey("property_values").AtSliceIndex(0), knownvalue.StringExact("production")), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("include").AtSliceIndex(1).AtMapKey("name"), knownvalue.StringExact(propTierName)), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("include").AtSliceIndex(1).AtMapKey("source"), knownvalue.StringExact("custom")), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("include").AtSliceIndex(1).AtMapKey("property_values").AtSliceIndex(0), knownvalue.StringExact("premium")), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("include").AtSliceIndex(1).AtMapKey("property_values").AtSliceIndex(1), knownvalue.StringExact("enterprise")), + }, + }, + }, + }) + }) + + t.Run("update_repository_property", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + rulesetName := fmt.Sprintf("%s-repo-prop-update-%s", testResourcePrefix, randomID) + propName := fmt.Sprintf("%s_team_%s", testResourcePrefix, randomID) + + config := fmt.Sprintf(` +resource "github_organization_custom_properties" "team" { + property_name = "%[2]s" + value_type = "single_select" + required = false + allowed_values = ["blue", "red", "backend", "platform"] +} + +resource "github_organization_ruleset" "test" { + name = "%[1]s" + target = "branch" + enforcement = "active" + + conditions { + repository_property { + include = [{ + name = github_organization_custom_properties.team.property_name + source = "custom" + property_values = ["blue"] + }] + exclude = [] + } + + ref_name { + include = ["~DEFAULT_BRANCH"] + exclude = [] + } + } + + rules { + creation = true + } +} +`, rulesetName, propName) + + configUpdated := fmt.Sprintf(` +resource "github_organization_custom_properties" "team" { + property_name = "%[2]s" + value_type = "single_select" + required = false + allowed_values = ["blue", "red", "backend", "platform"] +} + +resource "github_organization_ruleset" "test" { + name = "%[1]s" + target = "branch" + enforcement = "active" + + conditions { + repository_property { + include = [{ + name = github_organization_custom_properties.team.property_name + source = "custom" + property_values = ["backend", "platform"] + }] + exclude = [] + } + + ref_name { + include = ["~DEFAULT_BRANCH"] + exclude = [] + } + } + + rules { + creation = true + update = true + } +} +`, rulesetName, propName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasPaidOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("name"), knownvalue.StringExact(rulesetName)), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("include").AtSliceIndex(0).AtMapKey("property_values").AtSliceIndex(0), knownvalue.StringExact("blue")), + }, + }, + { + Config: configUpdated, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("name"), knownvalue.StringExact(rulesetName)), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("include").AtSliceIndex(0).AtMapKey("property_values").AtSliceIndex(0), knownvalue.StringExact("backend")), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("include").AtSliceIndex(0).AtMapKey("property_values").AtSliceIndex(1), knownvalue.StringExact("platform")), + }, + }, + }, + }) + }) + t.Run("create_push_ruleset", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) rulesetName := fmt.Sprintf("%s-push-ruleset-%s", testResourcePrefix, randomID) @@ -682,6 +979,142 @@ resource "github_organization_ruleset" "test" { }) }) + t.Run("validates_conditions_require_exactly_one_repository_targeting", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + resourceName := "test-multiple-repo-targeting" + config := fmt.Sprintf(` + resource "github_organization_ruleset" "%s" { + name = "test-multiple-targeting-%s" + target = "branch" + enforcement = "active" + + conditions { + ref_name { + include = ["~ALL"] + exclude = [] + } + repository_name { + include = ["~ALL"] + exclude = [] + } + repository_id = [123] + } + + rules { + creation = true + } + } + `, resourceName, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasPaidOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`(?s)only one of.*conditions\.0\.repository_id.*conditions\.0\.repository_name.*conditions\.0\.repository_property.*can be specified`), + }, + }, + }) + }) + + t.Run("validates_conditions_require_at_least_one_repository_targeting", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + resourceName := "test-no-repo-targeting" + config := fmt.Sprintf(` + resource "github_organization_ruleset" "%s" { + name = "test-no-targeting-%s" + target = "branch" + enforcement = "active" + + conditions { + ref_name { + include = ["~ALL"] + exclude = [] + } + } + + rules { + creation = true + } + } + `, resourceName, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasPaidOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`(?s)one of.*conditions\.0\.repository_id.*conditions\.0\.repository_name.*conditions\.0\.repository_property.*must be specified`), + }, + }, + }) + }) + + t.Run("validates_repository_property_works_as_single_targeting_option", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + rulesetName := fmt.Sprintf("%s-repo-prop-only-%s", testResourcePrefix, randomID) + propName := fmt.Sprintf("%s_environment_%s", testResourcePrefix, randomID) + + config := fmt.Sprintf(` +resource "github_organization_custom_properties" "environment" { + property_name = "%[2]s" + value_type = "single_select" + required = false + allowed_values = ["production", "staging"] +} + +resource "github_organization_ruleset" "test" { + name = "%[1]s" + target = "branch" + enforcement = "active" + + conditions { + repository_property { + include = [{ + name = "%[2]s" + source = "custom" + property_values = ["production", "staging"] + }] + exclude = [] + } + + ref_name { + include = ["~DEFAULT_BRANCH"] + exclude = [] + } + } + + rules { + creation = true + update = true + } + + depends_on = [github_organization_custom_properties.environment] +} +`, rulesetName, propName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasPaidOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("name"), knownvalue.StringExact(rulesetName)), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("target"), knownvalue.StringExact("branch")), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("enforcement"), knownvalue.StringExact("active")), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("include").AtSliceIndex(0).AtMapKey("name"), knownvalue.StringExact(propName)), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("include").AtSliceIndex(0).AtMapKey("source"), knownvalue.StringExact("custom")), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("include").AtSliceIndex(0).AtMapKey("property_values").AtSliceIndex(0), knownvalue.StringExact("production")), + statecheck.ExpectKnownValue("github_organization_ruleset.test", tfjsonpath.New("conditions").AtSliceIndex(0).AtMapKey("repository_property").AtSliceIndex(0).AtMapKey("include").AtSliceIndex(0).AtMapKey("property_values").AtSliceIndex(1), knownvalue.StringExact("staging")), + }, + }, + }, + }) + }) + t.Run("creates_push_ruleset", func(t *testing.T) { randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) rulesetName := fmt.Sprintf("%stest-push-%s", testResourcePrefix, randomID) diff --git a/github/util_rules.go b/github/util_rules.go index 71c08b2ea1..b5006f591a 100644 --- a/github/util_rules.go +++ b/github/util_rules.go @@ -250,12 +250,61 @@ func expandConditions(input []any, org bool) *github.RepositoryRulesetConditions } rulesetConditions.RepositoryID = &github.RepositoryRulesetRepositoryIDsConditionParameters{RepositoryIDs: repositoryIDs} + } else if v, ok := inputConditions["repository_property"].([]any); ok && v != nil && len(v) != 0 { + rulesetConditions.RepositoryProperty = expandRepositoryPropertyConditions(v) } } return rulesetConditions } +func expandRepositoryPropertyConditions(v []any) *github.RepositoryRulesetRepositoryPropertyConditionParameters { + repositoryProperties := v[0].(map[string]any) + include := make([]*github.RepositoryRulesetRepositoryPropertyTargetParameters, 0) + exclude := make([]*github.RepositoryRulesetRepositoryPropertyTargetParameters, 0) + + for _, v := range repositoryProperties["include"].([]any) { + if v != nil { + propertyMap := v.(map[string]any) + propertyValues := make([]string, 0) + for _, pv := range propertyMap["property_values"].([]any) { + if pv != nil { + propertyValues = append(propertyValues, pv.(string)) + } + } + property := &github.RepositoryRulesetRepositoryPropertyTargetParameters{ + Name: propertyMap["name"].(string), + Source: github.Ptr(propertyMap["source"].(string)), + PropertyValues: propertyValues, + } + include = append(include, property) + } + } + + for _, v := range repositoryProperties["exclude"].([]any) { + if v != nil { + propertyMap := v.(map[string]any) + propertyValues := make([]string, 0) + for _, pv := range propertyMap["property_values"].([]any) { + if pv != nil { + propertyValues = append(propertyValues, pv.(string)) + } + } + property := &github.RepositoryRulesetRepositoryPropertyTargetParameters{ + Name: propertyMap["name"].(string), + Source: github.Ptr(propertyMap["source"].(string)), + PropertyValues: propertyValues, + } + exclude = append(exclude, property) + } + } + + return &github.RepositoryRulesetRepositoryPropertyConditionParameters{ + Include: include, + Exclude: exclude, + } +} + func flattenConditions(ctx context.Context, conditions *github.RepositoryRulesetConditions, org bool) []any { if conditions == nil || reflect.DeepEqual(conditions, &github.RepositoryRulesetConditions{}) { tflog.Debug(ctx, "Conditions are empty, returning empty list") @@ -296,11 +345,39 @@ func flattenConditions(ctx context.Context, conditions *github.RepositoryRuleset if conditions.RepositoryID != nil { conditionsMap["repository_id"] = conditions.RepositoryID.RepositoryIDs } + + if conditions.RepositoryProperty != nil { + repositoryPropertySlice := make([]map[string]any, 0) + + repositoryPropertySlice = append(repositoryPropertySlice, map[string]any{ + "include": flattenRulesetRepositoryPropertyTargetParameters(conditions.RepositoryProperty.Include), + "exclude": flattenRulesetRepositoryPropertyTargetParameters(conditions.RepositoryProperty.Exclude), + }) + conditionsMap["repository_property"] = repositoryPropertySlice + } } return []any{conditionsMap} } +func flattenRulesetRepositoryPropertyTargetParameters(input []*github.RepositoryRulesetRepositoryPropertyTargetParameters) []map[string]any { + result := make([]map[string]any, 0) + + for _, v := range input { + propertyMap := make(map[string]any) + propertyMap["name"] = v.Name + source := v.GetSource() + if source == "" { + source = "custom" + } + propertyMap["source"] = source + propertyMap["property_values"] = v.PropertyValues + result = append(result, propertyMap) + } + + return result +} + func expandRules(input []any, org bool) *github.RepositoryRulesetRules { if len(input) == 0 || input[0] == nil { return &github.RepositoryRulesetRules{} diff --git a/github/util_rules_test.go b/github/util_rules_test.go index d99e6e4589..c53afc8700 100644 --- a/github/util_rules_test.go +++ b/github/util_rules_test.go @@ -814,3 +814,488 @@ func TestRoundTripRequiredReviewers(t *testing.T) { t.Errorf("Expected reviewer type to be Team after round trip, got %v", reviewerBlock[0]["type"]) } } + +func TestExpandRepositoryPropertyConditions_SingleInclude(t *testing.T) { + input := []any{ + map[string]any{ + "include": []any{ + map[string]any{ + "name": "env", + "source": "custom", + "property_values": []any{"prod"}, + }, + }, + "exclude": []any{}, + }, + } + + result := expandRepositoryPropertyConditions(input) + + if result == nil { + t.Fatal("Expected result to not be nil") + } + + if len(result.Include) != 1 { + t.Fatalf("Expected 1 include property, got %d", len(result.Include)) + } + + if len(result.Exclude) != 0 { + t.Fatalf("Expected 0 exclude properties, got %d", len(result.Exclude)) + } + + prop := result.Include[0] + if prop.Name != "env" { + t.Errorf("Expected name to be 'env', got %s", prop.Name) + } + if prop.Source == nil || *prop.Source != "custom" { + t.Errorf("Expected source to be 'custom', got %v", prop.Source) + } + if len(prop.PropertyValues) != 1 || prop.PropertyValues[0] != "prod" { + t.Errorf("Expected property_values to be ['prod'], got %v", prop.PropertyValues) + } +} + +func TestExpandRepositoryPropertyConditions_IncludeAndExclude(t *testing.T) { + input := []any{ + map[string]any{ + "include": []any{ + map[string]any{ + "name": "env", + "source": "custom", + "property_values": []any{"prod"}, + }, + }, + "exclude": []any{ + map[string]any{ + "name": "tier", + "source": "system", + "property_values": []any{"free"}, + }, + }, + }, + } + + result := expandRepositoryPropertyConditions(input) + + if result == nil { + t.Fatal("Expected result to not be nil") + } + + if len(result.Include) != 1 { + t.Fatalf("Expected 1 include property, got %d", len(result.Include)) + } + + if len(result.Exclude) != 1 { + t.Fatalf("Expected 1 exclude property, got %d", len(result.Exclude)) + } + + includeProp := result.Include[0] + if includeProp.Name != "env" { + t.Errorf("Expected include name to be 'env', got %s", includeProp.Name) + } + if includeProp.Source == nil || *includeProp.Source != "custom" { + t.Errorf("Expected include source to be 'custom', got %v", includeProp.Source) + } + + excludeProp := result.Exclude[0] + if excludeProp.Name != "tier" { + t.Errorf("Expected exclude name to be 'tier', got %s", excludeProp.Name) + } + if excludeProp.Source == nil || *excludeProp.Source != "system" { + t.Errorf("Expected exclude source to be 'system', got %v", excludeProp.Source) + } +} + +func TestExpandRepositoryPropertyConditions_MultipleValues(t *testing.T) { + input := []any{ + map[string]any{ + "include": []any{ + map[string]any{ + "name": "env", + "source": "custom", + "property_values": []any{"prod", "staging", "dev"}, + }, + }, + "exclude": []any{}, + }, + } + + result := expandRepositoryPropertyConditions(input) + + if result == nil { + t.Fatal("Expected result to not be nil") + } + + if len(result.Include) != 1 { + t.Fatalf("Expected 1 include property, got %d", len(result.Include)) + } + + prop := result.Include[0] + if len(prop.PropertyValues) != 3 { + t.Fatalf("Expected 3 property values, got %d", len(prop.PropertyValues)) + } + + expectedValues := []string{"prod", "staging", "dev"} + for i, expected := range expectedValues { + if prop.PropertyValues[i] != expected { + t.Errorf("Expected property_values[%d] to be '%s', got '%s'", i, expected, prop.PropertyValues[i]) + } + } +} + +func TestExpandRepositoryPropertyConditions_MultipleProperties(t *testing.T) { + input := []any{ + map[string]any{ + "include": []any{ + map[string]any{ + "name": "env", + "source": "custom", + "property_values": []any{"prod"}, + }, + map[string]any{ + "name": "tier", + "source": "system", + "property_values": []any{"premium", "enterprise"}, + }, + }, + "exclude": []any{}, + }, + } + + result := expandRepositoryPropertyConditions(input) + + if result == nil { + t.Fatal("Expected result to not be nil") + } + + if len(result.Include) != 2 { + t.Fatalf("Expected 2 include properties, got %d", len(result.Include)) + } + + // Check first property + if result.Include[0].Name != "env" { + t.Errorf("Expected first property name to be 'env', got %s", result.Include[0].Name) + } + if len(result.Include[0].PropertyValues) != 1 { + t.Errorf("Expected first property to have 1 value, got %d", len(result.Include[0].PropertyValues)) + } + + // Check second property + if result.Include[1].Name != "tier" { + t.Errorf("Expected second property name to be 'tier', got %s", result.Include[1].Name) + } + if len(result.Include[1].PropertyValues) != 2 { + t.Errorf("Expected second property to have 2 values, got %d", len(result.Include[1].PropertyValues)) + } +} + +func TestExpandRepositoryPropertyConditions_NilElements(t *testing.T) { + input := []any{ + map[string]any{ + "include": []any{ + map[string]any{ + "name": "env", + "source": "custom", + "property_values": []any{"prod"}, + }, + nil, + map[string]any{ + "name": "tier", + "source": "system", + "property_values": []any{"premium"}, + }, + }, + "exclude": []any{}, + }, + } + + result := expandRepositoryPropertyConditions(input) + + if result == nil { + t.Fatal("Expected result to not be nil") + } + + // Nil element should be skipped, so we should have 2 properties + if len(result.Include) != 2 { + t.Fatalf("Expected 2 include properties (nil skipped), got %d", len(result.Include)) + } + + if result.Include[0].Name != "env" { + t.Errorf("Expected first property name to be 'env', got %s", result.Include[0].Name) + } + if result.Include[1].Name != "tier" { + t.Errorf("Expected second property name to be 'tier', got %s", result.Include[1].Name) + } +} + +func TestExpandRepositoryPropertyConditions_NilPropertyValues(t *testing.T) { + input := []any{ + map[string]any{ + "include": []any{ + map[string]any{ + "name": "env", + "source": "custom", + "property_values": []any{"prod", nil, "staging"}, + }, + }, + "exclude": []any{}, + }, + } + + result := expandRepositoryPropertyConditions(input) + + if result == nil { + t.Fatal("Expected result to not be nil") + } + + if len(result.Include) != 1 { + t.Fatalf("Expected 1 include property, got %d", len(result.Include)) + } + + prop := result.Include[0] + // Nil value should be skipped, so we should have 2 values + if len(prop.PropertyValues) != 2 { + t.Fatalf("Expected 2 property values (nil skipped), got %d", len(prop.PropertyValues)) + } + + if prop.PropertyValues[0] != "prod" { + t.Errorf("Expected first value to be 'prod', got '%s'", prop.PropertyValues[0]) + } + if prop.PropertyValues[1] != "staging" { + t.Errorf("Expected second value to be 'staging', got '%s'", prop.PropertyValues[1]) + } +} + +func TestFlattenRulesetRepositoryPropertyTargetParameters(t *testing.T) { + input := []*github.RepositoryRulesetRepositoryPropertyTargetParameters{ + { + Name: "env", + Source: github.Ptr("custom"), + PropertyValues: []string{"prod", "staging"}, + }, + { + Name: "tier", + Source: github.Ptr("system"), + PropertyValues: []string{"premium"}, + }, + } + + result := flattenRulesetRepositoryPropertyTargetParameters(input) + + if len(result) != 2 { + t.Fatalf("Expected 2 properties, got %d", len(result)) + } + + // Check first property + if result[0]["name"] != "env" { + t.Errorf("Expected first property name to be 'env', got %v", result[0]["name"]) + } + if result[0]["source"] != "custom" { + t.Errorf("Expected first property source to be 'custom', got %v", result[0]["source"]) + } + values := result[0]["property_values"].([]string) + if len(values) != 2 || values[0] != "prod" || values[1] != "staging" { + t.Errorf("Expected first property values to be ['prod', 'staging'], got %v", values) + } + + // Check second property + if result[1]["name"] != "tier" { + t.Errorf("Expected second property name to be 'tier', got %v", result[1]["name"]) + } +} + +func TestFlattenRulesetRepositoryPropertyTargetParameters_EmptySource(t *testing.T) { + input := []*github.RepositoryRulesetRepositoryPropertyTargetParameters{ + { + Name: "env", + Source: github.Ptr(""), + PropertyValues: []string{"prod"}, + }, + } + + result := flattenRulesetRepositoryPropertyTargetParameters(input) + + if len(result) != 1 { + t.Fatalf("Expected 1 property, got %d", len(result)) + } + + // Empty source should default to "custom" + if result[0]["source"] != "custom" { + t.Errorf("Expected source to default to 'custom', got %v", result[0]["source"]) + } +} + +func TestRoundTripRepositoryPropertyConditions(t *testing.T) { + input := []any{ + map[string]any{ + "include": []any{ + map[string]any{ + "name": "env", + "source": "custom", + "property_values": []any{"prod", "staging"}, + }, + map[string]any{ + "name": "tier", + "source": "system", + "property_values": []any{"premium"}, + }, + }, + "exclude": []any{ + map[string]any{ + "name": "region", + "source": "custom", + "property_values": []any{"us-west"}, + }, + }, + }, + } + + // Expand + expanded := expandRepositoryPropertyConditions(input) + + // Flatten + flattenedInclude := flattenRulesetRepositoryPropertyTargetParameters(expanded.Include) + flattenedExclude := flattenRulesetRepositoryPropertyTargetParameters(expanded.Exclude) + + // Verify include + if len(flattenedInclude) != 2 { + t.Fatalf("Expected 2 include properties after round trip, got %d", len(flattenedInclude)) + } + + if flattenedInclude[0]["name"] != "env" { + t.Errorf("Expected first include name to be 'env', got %v", flattenedInclude[0]["name"]) + } + if flattenedInclude[0]["source"] != "custom" { + t.Errorf("Expected first include source to be 'custom', got %v", flattenedInclude[0]["source"]) + } + includeValues := flattenedInclude[0]["property_values"].([]string) + if len(includeValues) != 2 || includeValues[0] != "prod" || includeValues[1] != "staging" { + t.Errorf("Expected first include values to be ['prod', 'staging'], got %v", includeValues) + } + + if flattenedInclude[1]["name"] != "tier" { + t.Errorf("Expected second include name to be 'tier', got %v", flattenedInclude[1]["name"]) + } + + // Verify exclude + if len(flattenedExclude) != 1 { + t.Fatalf("Expected 1 exclude property after round trip, got %d", len(flattenedExclude)) + } + + if flattenedExclude[0]["name"] != "region" { + t.Errorf("Expected exclude name to be 'region', got %v", flattenedExclude[0]["name"]) + } + excludeValues := flattenedExclude[0]["property_values"].([]string) + if len(excludeValues) != 1 || excludeValues[0] != "us-west" { + t.Errorf("Expected exclude values to be ['us-west'], got %v", excludeValues) + } +} + +func TestFlattenRulesetRepositoryPropertyTargetParameters_Empty(t *testing.T) { + // Test nil input + result := flattenRulesetRepositoryPropertyTargetParameters(nil) + if len(result) != 0 { + t.Errorf("Expected empty slice for nil input, got %v", result) + } + + // Test empty slice input + result = flattenRulesetRepositoryPropertyTargetParameters([]*github.RepositoryRulesetRepositoryPropertyTargetParameters{}) + if len(result) != 0 { + t.Errorf("Expected empty slice for empty input, got %v", result) + } +} + +func TestFlattenRulesetRepositoryPropertyTargetParameters_SingleProperty(t *testing.T) { + input := []*github.RepositoryRulesetRepositoryPropertyTargetParameters{ + { + Name: "env", + Source: github.Ptr("system"), + PropertyValues: []string{"prod", "staging"}, + }, + } + + result := flattenRulesetRepositoryPropertyTargetParameters(input) + + if len(result) != 1 { + t.Fatalf("Expected 1 property, got %d", len(result)) + } + + if result[0]["name"] != "env" { + t.Errorf("Expected name to be 'env', got %v", result[0]["name"]) + } + + if result[0]["source"] != "system" { + t.Errorf("Expected source to be 'system', got %v", result[0]["source"]) + } + + values := result[0]["property_values"].([]string) + if len(values) != 2 || values[0] != "prod" || values[1] != "staging" { + t.Errorf("Expected property_values to be ['prod', 'staging'], got %v", values) + } +} + +func TestFlattenRulesetRepositoryPropertyTargetParameters_NilSource(t *testing.T) { + input := []*github.RepositoryRulesetRepositoryPropertyTargetParameters{ + { + Name: "env", + Source: nil, + PropertyValues: []string{"prod"}, + }, + } + + result := flattenRulesetRepositoryPropertyTargetParameters(input) + + if len(result) != 1 { + t.Fatalf("Expected 1 property, got %d", len(result)) + } + + // Nil source should default to "custom" + if result[0]["source"] != "custom" { + t.Errorf("Expected source to default to 'custom' for nil source, got %v", result[0]["source"]) + } +} + +func TestFlattenRulesetRepositoryPropertyTargetParameters_EmptyPropertyValues(t *testing.T) { + input := []*github.RepositoryRulesetRepositoryPropertyTargetParameters{ + { + Name: "env", + Source: github.Ptr("custom"), + PropertyValues: []string{}, + }, + } + + result := flattenRulesetRepositoryPropertyTargetParameters(input) + + if len(result) != 1 { + t.Fatalf("Expected 1 property, got %d", len(result)) + } + + values := result[0]["property_values"].([]string) + if len(values) != 0 { + t.Errorf("Expected property_values to be empty array, got %v", values) + } +} + +func TestFlattenRulesetRepositoryPropertyTargetParameters_NilPropertyValues(t *testing.T) { + input := []*github.RepositoryRulesetRepositoryPropertyTargetParameters{ + { + Name: "env", + Source: github.Ptr("custom"), + PropertyValues: nil, + }, + } + + result := flattenRulesetRepositoryPropertyTargetParameters(input) + + if len(result) != 1 { + t.Fatalf("Expected 1 property, got %d", len(result)) + } + + // Nil PropertyValues should be preserved in the map + values := result[0]["property_values"] + if values != nil { + if valSlice, ok := values.([]string); ok && len(valSlice) != 0 { + t.Errorf("Expected property_values to be nil or empty, got %v", values) + } + } +} diff --git a/github/util_ruleset_validation.go b/github/util_ruleset_validation.go index 1e784be97a..81898182d0 100644 --- a/github/util_ruleset_validation.go +++ b/github/util_ruleset_validation.go @@ -171,13 +171,6 @@ func validateConditionsFieldForBranchAndTagTargets(ctx context.Context, target g return fmt.Errorf("ref_name must be set for %s target", target) } - // Repository rulesets don't have repository_name or repository_id, only org rulesets do. - if isOrg { - if (conditions["repository_name"] == nil || len(conditions["repository_name"].([]any)) == 0) && (conditions["repository_id"] == nil || len(conditions["repository_id"].([]any)) == 0) { - tflog.Debug(ctx, fmt.Sprintf("Missing repository_name or repository_id for %s target", target), map[string]any{"target": target}) - return fmt.Errorf("either repository_name or repository_id must be set for %s target", target) - } - } tflog.Debug(ctx, fmt.Sprintf("Conditions validation passed for %s target", target)) return nil } diff --git a/github/util_ruleset_validation_test.go b/github/util_ruleset_validation_test.go index 7cf434b5f0..51e3fb51f3 100644 --- a/github/util_ruleset_validation_test.go +++ b/github/util_ruleset_validation_test.go @@ -165,37 +165,6 @@ func Test_validateConditionsFieldForBranchAndTagTargets(t *testing.T) { expectError: true, errorMsg: "ref_name must be set for branch target", }, - { - name: "invalid branch target without repository_name or repository_id", - target: github.RulesetTargetBranch, - conditions: map[string]any{ - "ref_name": []any{map[string]any{"include": []any{"~DEFAULT_BRANCH"}, "exclude": []any{}}}, - }, - expectError: true, - errorMsg: "either repository_name or repository_id must be set for branch target", - }, - { - name: "invalid tag target with nil repository_name and repository_id", - target: github.RulesetTargetTag, - conditions: map[string]any{ - "ref_name": []any{map[string]any{"include": []any{"v*"}, "exclude": []any{}}}, - "repository_name": nil, - "repository_id": nil, - }, - expectError: true, - errorMsg: "either repository_name or repository_id must be set for tag target", - }, - { - name: "invalid branch target with empty repository_name and repository_id slices", - target: github.RulesetTargetBranch, - conditions: map[string]any{ - "ref_name": []any{map[string]any{"include": []any{"~DEFAULT_BRANCH"}, "exclude": []any{}}}, - "repository_name": []any{}, - "repository_id": []any{}, - }, - expectError: true, - errorMsg: "either repository_name or repository_id must be set for branch target", - }, } for _, tt := range tests { diff --git a/website/docs/r/organization_ruleset.html.markdown b/website/docs/r/organization_ruleset.html.markdown index 5a7e10d506..f0a052a159 100644 --- a/website/docs/r/organization_ruleset.html.markdown +++ b/website/docs/r/organization_ruleset.html.markdown @@ -99,6 +99,47 @@ resource "github_organization_ruleset" "example_push" { } } } + +# Example with repository_property targeting +resource "github_organization_ruleset" "example_property" { + name = "example_property" + target = "branch" + enforcement = "active" + + conditions { + ref_name { + include = ["~ALL"] + exclude = [] + } + + repository_property { + include = [ + { + name = "environment" + property_values = ["production", "staging"] + source = "custom" + }, + { + name = "team" + property_values = ["backend"] + source = "custom" + } + ] + exclude = [ + { + name = "archived" + property_values = ["true"] + source = "system" + } + ] + } + } + + rules { + required_signatures = true + pull_request {} + } +} ``` ## Argument Reference @@ -325,10 +366,11 @@ The `rules` block supports the following: #### conditions #### - `ref_name` - (Optional) (Block List, Max: 1) Required for `branch` and `tag` targets. Must NOT be set for `push` targets. (see [below for nested schema](#conditionsref_name)) -- `repository_id` (Optional) (List of Number) The repository IDs that the ruleset applies to. One of these IDs must match for the condition to pass. Conflicts with `repository_name`. -- `repository_name` (Optional) (Block List, Max: 1) Conflicts with `repository_id`. (see [below for nested schema](#conditionsrepository_name)) +- `repository_id` (Optional) (List of Number) The repository IDs that the ruleset applies to. One of these IDs must match for the condition to pass. +- `repository_name` (Optional) (Block List, Max: 1) Targets repositories that match the specified name patterns. (see [below for nested schema](#conditionsrepository_name)) +- `repository_property` (Optional) (Block List, Max: 1) Targets repositories by custom or system properties. (see [below for nested schema](#conditionsrepository_property)) -One of `repository_id` and `repository_name` must be set for the rule to target any repositories. +Exactly one of `repository_id`, `repository_name`, or `repository_property` must be set for the rule to target repositories. ~> **Note:** For `push` targets, do not include `ref_name` in conditions. Push rulesets operate on file content, not on refs. @@ -344,6 +386,20 @@ One of `repository_id` and `repository_name` must be set for the rule to target - `include` - (Required) (List of String) Array of repository names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~ALL` to include all repositories. - `protected` - (Optional) (Boolean) Whether renaming of target repositories is prevented. Defaults to `false`. +#### conditions.repository_property #### + +- `include` - (Optional) (List of Repository Properties) The repository properties and values to include. All of these properties must match for the condition to pass. (see [below for nested schema](#conditionsrepository_propertyproperties)) + +- `exclude` - (Optional) (List of Repository Properties) The repository properties and values to exclude. The condition will not pass if any of these properties match. (see [below for nested schema](#conditionsrepository_propertyproperties)) + +#### conditions.repository_property.properties #### + +- `name` (Required) (String) The name of the repository property to target. + +- `property_values` (Required) (Array of String) The values to match for the repository property. + +- `source` (String) The source of the repository property. Defaults to 'custom' if not specified. Can be one of: `custom`, `system` + ## Attributes Reference The following additional attributes are exported: