diff --git a/github/resource_github_organization_ruleset.go b/github/resource_github_organization_ruleset.go index 014b28ef65..222be5815b 100644 --- a/github/resource_github_organization_ruleset.go +++ b/github/resource_github_organization_ruleset.go @@ -84,7 +84,7 @@ func resourceGithubOrganizationRuleset() *schema.Resource { Type: schema.TypeList, Optional: true, MaxItems: 1, - Description: "Parameters for an organization ruleset condition. `ref_name` is required alongside one of `repository_name` or `repository_id`.", + Description: "Parameters for an organization ruleset condition. `ref_name` is required alongside one of `repository_name`, `repository_id`, or `repository_property`.", Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "ref_name": { @@ -112,12 +112,83 @@ func resourceGithubOrganizationRuleset() *schema.Resource { }, }, }, + "repository_property": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + ExactlyOneOf: []string{"conditions.0.repository_id", "conditions.0.repository_name"}, + AtLeastOneOf: []string{"conditions.0.repository_id", "conditions.0.repository_name"}, + Description: "Conditions to target repositories by property ", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "include": { + Type: schema.TypeList, + Optional: true, + 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", + }, + }, + }, + }, + "exclude": { + Type: schema.TypeList, + Optional: true, + Description: "The repository properties and values to exclude. The condition will not pass 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", + ValidateFunc: validation.StringInSlice([]string{"custom", "system"}, false), + }, + }, + }, + }, + }, + }, + }, "repository_name": { Type: schema.TypeList, Optional: true, MaxItems: 1, - ExactlyOneOf: []string{"conditions.0.repository_id"}, - AtLeastOneOf: []string{"conditions.0.repository_id"}, + ExactlyOneOf: []string{"conditions.0.repository_id", "conditions.0.repository_property"}, + AtLeastOneOf: []string{"conditions.0.repository_id", "conditions.0.repository_property"}, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "include": { @@ -146,9 +217,11 @@ 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_name", "conditions.0.repository_property"}, + AtLeastOneOf: []string{"conditions.0.repository_name", "conditions.0.repository_property"}, + Description: "The repository IDs that the ruleset applies to. One of these IDs must match for the condition to pass.", Elem: &schema.Schema{ Type: schema.TypeInt, }, @@ -225,6 +298,12 @@ func resourceGithubOrganizationRuleset() *schema.Resource { Default: false, Description: "All conversations on code must be resolved before a pull request can be merged. Defaults to `false`.", }, + "automatic_copilot_code_review_enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Automatically request Copilot code review. Defaults to `false`.", + }, }, }, }, @@ -492,6 +571,28 @@ func resourceGithubOrganizationRuleset() *schema.Resource { }, }, }, + "copilot_code_review": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Request Copilot code review for new pull requests automatically if the author has access to Copilot code review.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "review_on_push": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Copilot automatically reviews each new push to the pull reques. Defaults to `false`.", + }, + "review_draft_pull_requests": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Copilot automatically reviews draft pull requests before they are marked as ready for review. Defaults to `false`.", + }, + }, + }, + }, }, }, }, diff --git a/github/resource_github_organization_ruleset_test.go b/github/resource_github_organization_ruleset_test.go index 9aae9f35b9..3f0958cff6 100644 --- a/github/resource_github_organization_ruleset_test.go +++ b/github/resource_github_organization_ruleset_test.go @@ -22,7 +22,7 @@ func TestGithubOrganizationRulesets(t *testing.T) { t.Run("Creates and updates organization rulesets without errors", func(t *testing.T) { - config := fmt.Sprintf(` + rulesetRefName := fmt.Sprintf(` resource "github_organization_ruleset" "test" { name = "test-%s" target = "branch" @@ -84,11 +84,77 @@ func TestGithubOrganizationRulesets(t *testing.T) { pattern = "test" } + copilot_code_review { + review_on_push = true + review_draft_pull_requests = false + } + non_fast_forward = true } } `, randomID) + rulesetRepositoryProperty := fmt.Sprintf(` + resource "github_organization_ruleset" "test" { + name = "test-%s" + target = "branch" + enforcement = "active" + + conditions { + repository_property { + include = [ { + name: "team", + property_values: ["blue"], + }] + exclude = [] + } + } + + rules { + creation = true + + update = true + + deletion = true + required_linear_history = true + + required_signatures = false + + pull_request { + required_approving_review_count = 2 + required_review_thread_resolution = true + require_code_owner_review = true + dismiss_stale_reviews_on_push = true + require_last_push_approval = true + } + + required_status_checks { + + required_check { + context = "ci" + } + + strict_required_status_checks_policy = true + } + + required_workflows { + required_workflow { + path = "path/to/workflow.yaml" + repository_id = 1234 + } + } + + branch_name_pattern { + name = "test" + negate = false + operator = "starts_with" + pattern = "test" + } + + non_fast_forward = true + } + } + `, randomID) check := resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr( "github_organization_ruleset.test", "name", @@ -98,9 +164,17 @@ func TestGithubOrganizationRulesets(t *testing.T) { "github_organization_ruleset.test", "enforcement", "active", ), + resource.TestCheckResourceAttr( + "github_organization_ruleset.test", "rules.0.copilot_code_review.0.review_on_push", + "true", + ), + resource.TestCheckResourceAttr( + "github_organization_ruleset.test", "rules.0.copilot_code_review.0.review_draft_pull_requests", + "false", + ), ) - testCase := func(t *testing.T, mode string) { + testCase := func(t *testing.T, mode string, config string) { resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessMode(t, mode) }, Providers: testAccProviders, @@ -114,7 +188,8 @@ func TestGithubOrganizationRulesets(t *testing.T) { } t.Run("with an enterprise account", func(t *testing.T) { - testCase(t, enterprise) + testCase(t, enterprise, rulesetRefName) + testCase(t, enterprise, rulesetRepositoryProperty) }) }) diff --git a/github/respository_rules_utils.go b/github/respository_rules_utils.go index 44ccb27de1..8dcc1efc80 100644 --- a/github/respository_rules_utils.go +++ b/github/respository_rules_utils.go @@ -87,71 +87,120 @@ func expandConditions(input []interface{}, org bool) *github.RulesetConditions { // ref_name is available for both repo and org rulesets if v, ok := inputConditions["ref_name"].([]interface{}); ok && v != nil && len(v) != 0 { - inputRefName := v[0].(map[string]interface{}) - include := make([]string, 0) - exclude := make([]string, 0) + rulesetConditions.RefName = expandRefNameConditions(v) + } - for _, v := range inputRefName["include"].([]interface{}) { - if v != nil { - include = append(include, v.(string)) - } + // org-only fields + if org { + // repository_name and repository_id + if v, ok := inputConditions["repository_name"].([]interface{}); ok && v != nil && len(v) != 0 { + rulesetConditions.RepositoryName = expandRepositoryNameConditions(v) + } else if v, ok := inputConditions["repository_id"].([]interface{}); ok && v != nil && len(v) != 0 { + rulesetConditions.RepositoryID = expandRepositoryIDConditions(v) + } else if v, ok := inputConditions["repository_property"].([]interface{}); ok && v != nil && len(v) != 0 { + rulesetConditions.RepositoryProperty = expandRepositoryPropertyConditions(v) } + } - for _, v := range inputRefName["exclude"].([]interface{}) { - if v != nil { - exclude = append(exclude, v.(string)) - } + return rulesetConditions +} + +func expandRefNameConditions(v []interface{}) *github.RulesetRefConditionParameters { + inputRefName := v[0].(map[string]interface{}) + include := make([]string, 0) + exclude := make([]string, 0) + + for _, v := range inputRefName["include"].([]interface{}) { + if v != nil { + include = append(include, v.(string)) } + } - rulesetConditions.RefName = &github.RulesetRefConditionParameters{ - Include: include, - Exclude: exclude, + for _, v := range inputRefName["exclude"].([]interface{}) { + if v != nil { + exclude = append(exclude, v.(string)) } } - // org-only fields - if org { - // repository_name and repository_id - if v, ok := inputConditions["repository_name"].([]interface{}); ok && v != nil && len(v) != 0 { - inputRepositoryName := v[0].(map[string]interface{}) - include := make([]string, 0) - exclude := make([]string, 0) + return &github.RulesetRefConditionParameters{ + Include: include, + Exclude: exclude, + } +} - for _, v := range inputRepositoryName["include"].([]interface{}) { - if v != nil { - include = append(include, v.(string)) - } - } +func expandRepositoryNameConditions(v []interface{}) *github.RulesetRepositoryNamesConditionParameters { + inputRepositoryName := v[0].(map[string]interface{}) + include := make([]string, 0) + exclude := make([]string, 0) - for _, v := range inputRepositoryName["exclude"].([]interface{}) { - if v != nil { - exclude = append(exclude, v.(string)) - } - } + for _, v := range inputRepositoryName["include"].([]interface{}) { + if v != nil { + include = append(include, v.(string)) + } + } - protected := inputRepositoryName["protected"].(bool) + for _, v := range inputRepositoryName["exclude"].([]interface{}) { + if v != nil { + exclude = append(exclude, v.(string)) + } + } - rulesetConditions.RepositoryName = &github.RulesetRepositoryNamesConditionParameters{ - Include: include, - Exclude: exclude, - Protected: &protected, - } - } else if v, ok := inputConditions["repository_id"].([]interface{}); ok && v != nil && len(v) != 0 { - repositoryIDs := make([]int64, 0) + protected := inputRepositoryName["protected"].(bool) - for _, v := range v { - if v != nil { - repositoryIDs = append(repositoryIDs, int64(v.(int))) - } - } + return &github.RulesetRepositoryNamesConditionParameters{ + Include: include, + Exclude: exclude, + Protected: &protected, + } +} + +func expandRepositoryIDConditions(v []interface{}) *github.RulesetRepositoryIDsConditionParameters { - rulesetConditions.RepositoryID = &github.RulesetRepositoryIDsConditionParameters{RepositoryIDs: repositoryIDs} + repositoryIDs := make([]int64, 0) + + for _, v := range v { + if v != nil { + repositoryIDs = append(repositoryIDs, int64(v.(int))) } } - return rulesetConditions + return &github.RulesetRepositoryIDsConditionParameters{RepositoryIDs: repositoryIDs} } +func expandRepositoryPropertyConditions(v []interface{}) *github.RulesetRepositoryPropertyConditionParameters { + repositoryProperties := v[0].(map[string]interface{}) + include := make([]github.RulesetRepositoryPropertyTargetParameters, 0) + exclude := make([]github.RulesetRepositoryPropertyTargetParameters, 0) + + for _, v := range repositoryProperties["include"].([]interface{}) { + if v != nil { + propertyMap := v.(map[string]interface{}) + property := github.RulesetRepositoryPropertyTargetParameters{ + Name: propertyMap["name"].(string), + Source: github.String(propertyMap["source"].(string)), + Values: convertInterfaceSliceToStringSlice(propertyMap["property_values"].([]interface{})), + } + include = append(include, property) + } + } + + for _, v := range repositoryProperties["exclude"].([]interface{}) { + if v != nil { + propertyMap := v.(map[string]interface{}) + property := github.RulesetRepositoryPropertyTargetParameters{ + Name: propertyMap["name"].(string), + Source: github.String(propertyMap["source"].(string)), + Values: convertInterfaceSliceToStringSlice(propertyMap["property_values"].([]interface{})), + } + exclude = append(exclude, property) + } + } + + return &github.RulesetRepositoryPropertyConditionParameters{ + Include: include, + Exclude: exclude, + } +} func flattenConditions(conditions *github.RulesetConditions, org bool) []interface{} { if conditions == nil || conditions.RefName == nil { return []interface{}{} @@ -189,11 +238,34 @@ func flattenConditions(conditions *github.RulesetConditions, org bool) []interfa if conditions.RepositoryID != nil { conditionsMap["repository_id"] = conditions.RepositoryID.RepositoryIDs } + if conditions.RepositoryProperty != nil { + repositoryPropertySlice := make([]map[string]interface{}, 0) + + repositoryPropertySlice = append(repositoryPropertySlice, map[string]interface{}{ + "include": flattenRulesetRepositoryPropertyTargetParameters(conditions.RepositoryProperty.Include), + "exclude": flattenRulesetRepositoryPropertyTargetParameters(conditions.RepositoryProperty.Exclude), + }) + conditionsMap["repository_property"] = repositoryPropertySlice + } } return []interface{}{conditionsMap} } +func flattenRulesetRepositoryPropertyTargetParameters(input []github.RulesetRepositoryPropertyTargetParameters) []map[string]interface{} { + result := make([]map[string]interface{}, 0) + + for _, v := range input { + propertyMap := make(map[string]interface{}) + propertyMap["name"] = v.Name + propertyMap["source"] = v.Source + propertyMap["property_values"] = v.Values + result = append(result, propertyMap) + } + + return result +} + func expandRules(input []interface{}, org bool) []*github.RepositoryRule { if len(input) == 0 || input[0] == nil { return nil @@ -412,9 +484,41 @@ func expandRules(input []interface{}, org bool) []*github.RepositoryRule { rulesSlice = append(rulesSlice, github.NewRequiredCodeScanningRule(params)) } + // Copilot code review rule + if v, ok := rulesMap["copilot_code_review"].([]interface{}); ok && len(v) != 0 { + copilotCodeReviewMap := v[0].(map[string]interface{}) + + // Create parameters map + paramsMap := map[string]interface{}{ + "review_on_push": copilotCodeReviewMap["review_on_push"].(bool), + "review_draft_pull_requests": copilotCodeReviewMap["review_draft_pull_requests"].(bool), + } + + // Marshal to JSON + paramsJSON, err := json.Marshal(paramsMap) + if err != nil { + log.Printf("[INFO] Error marshalling copilot_code_review parameters: %v", err) + } else { + paramsRaw := json.RawMessage(paramsJSON) + rule := &github.RepositoryRule{ + Type: "copilot_code_review", + Parameters: ¶msRaw, + } + rulesSlice = append(rulesSlice, rule) + } + } + return rulesSlice } +func convertInterfaceSliceToStringSlice(input []interface{}) []string { + result := make([]string, len(input)) + for i, v := range input { + result[i] = v.(string) + } + return result +} + func flattenRules(rules []*github.RepositoryRule, org bool) []interface{} { if len(rules) == 0 || rules == nil { return []interface{}{} @@ -542,6 +646,31 @@ func flattenRules(rules []*github.RepositoryRule, org bool) []interface{} { rule["min_entries_to_merge"] = params.MinEntriesToMerge rule["min_entries_to_merge_wait_minutes"] = params.MinEntriesToMergeWaitMinutes rulesMap[v.Type] = []map[string]interface{}{rule} + + case "copilot_code_review": + // Handle copilot_code_review rule parameters + var params map[string]interface{} + + err := json.Unmarshal(*v.Parameters, ¶ms) + if err != nil { + log.Printf("[INFO] Unexpected error unmarshalling rule %s with parameters: %v", + v.Type, v.Parameters) + } + + rule := make(map[string]interface{}) + if reviewOnPush, ok := params["review_on_push"].(bool); ok { + rule["review_on_push"] = reviewOnPush + } else { + rule["review_on_push"] = false + } + + if reviewDraftPRs, ok := params["review_draft_pull_requests"].(bool); ok { + rule["review_draft_pull_requests"] = reviewDraftPRs + } else { + rule["review_draft_pull_requests"] = false + } + + rulesMap[v.Type] = []map[string]interface{}{rule} } } diff --git a/website/docs/r/organization_ruleset.html.markdown b/website/docs/r/organization_ruleset.html.markdown index 1f31a19b42..86873963b9 100644 --- a/website/docs/r/organization_ruleset.html.markdown +++ b/website/docs/r/organization_ruleset.html.markdown @@ -39,6 +39,11 @@ resource "github_organization_ruleset" "example" { required_linear_history = true required_signatures = true + copilot_code_review { + review_on_push = true + review_draft_pull_requests = false + } + branch_name_pattern { name = "example" negate = false @@ -75,6 +80,8 @@ The `rules` block supports the following: * `committer_email_pattern` - (Optional) (Block List, Max: 1) Parameters to be used for the committer_email_pattern rule. This rule only applies to repositories within an enterprise, it cannot be applied to repositories owned by individuals or regular organizations. (see [below for nested schema](#rules.committer_email_pattern)) +* `copilot_code_review` - (Optional) (Block List, Max: 1) Request Copilot code review for new pull requests automatically if the author has access to Copilot code review. (see [below for nested schema](#rules.copilot_code_review)) + * `creation` - (Optional) (Boolean) Only allow users with bypass permission to create matching refs. * `deletion` - (Optional) (Boolean) Only allow users with bypass permissions to delete matching refs. @@ -137,6 +144,12 @@ The `rules` block supports the following: * `negate` - (Optional) (Boolean) If true, the rule will fail if the pattern matches. +#### rules.copilot_code_review #### + +* `review_on_push` - (Optional) (Boolean) Copilot automatically reviews each new push to the pull request. Defaults to `false`. + +* `review_draft_pull_requests` - (Optional) (Boolean) Copilot automatically reviews draft pull requests before they are marked as ready for review. Defaults to `false`. + #### rules.pull_request #### * `dismiss_stale_reviews_on_push` - (Optional) (Boolean) New, reviewable commits pushed will dismiss previous pull request review approvals. Defaults to `false`. @@ -214,8 +227,9 @@ The `rules` block supports the following: #### conditions #### * `ref_name` - (Required) (Block List, Min: 1, Max: 1) (see [below for nested schema](#conditions.ref_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](#conditions.repository_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` and `repository_property`. +* `repository_name` (Optional) (Block List, Max: 1) Conflicts with `repository_id` and `repository_property`. (see [below for nested schema](#conditions.repository_name)) +* `repository_property` (Optional) (Block List, Max: 1) Conflicts with `repository_id` and `repository_name`. (see [below for nested schema](#conditions.repository_property)) One of `repository_id` and `repository_name` must be set for the rule to target any repositories. @@ -231,6 +245,19 @@ 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. +#### 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](#conditions.repository_property.properties)) + +* `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](#conditions.repository_property.properties)) + +#### 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: