diff --git a/github/provider.go b/github/provider.go index 8f339dd272..daae203b1e 100644 --- a/github/provider.go +++ b/github/provider.go @@ -128,6 +128,7 @@ func Provider() terraform.ResourceProvider { "github_organization_custom_role": resourceGithubOrganizationCustomRole(), "github_organization_project": resourceGithubOrganizationProject(), "github_organization_security_manager": resourceGithubOrganizationSecurityManager(), + "github_organization_ruleset": resourceGithubOrganizationRuleset(), "github_organization_settings": resourceGithubOrganizationSettings(), "github_organization_webhook": resourceGithubOrganizationWebhook(), "github_project_card": resourceGithubProjectCard(), @@ -144,6 +145,7 @@ func Provider() terraform.ResourceProvider { "github_repository_milestone": resourceGithubRepositoryMilestone(), "github_repository_project": resourceGithubRepositoryProject(), "github_repository_pull_request": resourceGithubRepositoryPullRequest(), + "github_repository_ruleset": resourceGithubRepositoryRuleset(), "github_repository_tag_protection": resourceGithubRepositoryTagProtection(), "github_repository_webhook": resourceGithubRepositoryWebhook(), "github_team": resourceGithubTeam(), diff --git a/github/resource_github_organization_ruleset.go b/github/resource_github_organization_ruleset.go new file mode 100644 index 0000000000..6012f8087b --- /dev/null +++ b/github/resource_github_organization_ruleset.go @@ -0,0 +1,591 @@ +package github + +import ( + "context" + "log" + "net/http" + "strconv" + + "github.com/google/go-github/v53/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/helper/validation" +) + +func resourceGithubOrganizationRuleset() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubOrganizationRulesetCreate, + Read: resourceGithubOrganizationRulesetRead, + Update: resourceGithubOrganizationRulesetUpdate, + Delete: resourceGithubOrganizationRulesetDelete, + Importer: &schema.ResourceImporter{ + State: func(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + return []*schema.ResourceData{d}, nil + }, + }, + + SchemaVersion: 1, + + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringLenBetween(1, 100), + Description: "The name of the ruleset.", + }, + "target": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{"branch", "tag"}, false), + Description: "Possible values are `branch` and `tag`.", + }, + "owner": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "Owner of the repository. If not provided, the provider's default owner is used.", + }, + "enforcement": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{"disabled", "active", "evaluate"}, false), + Description: "Possible values for Enforcement are `disabled`, `active`, `evaluate`. Note: `evaluate` is currently only supported for owners of type `organization`.", + }, + + "bypass_actors": { + Type: schema.TypeList, + Optional: true, + Description: "The actors that can bypass the rules in this ruleset.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "actor_id": { + Type: schema.TypeInt, + Required: true, + Description: "The ID of the actor that can bypass a ruleset", + }, + "actor_type": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{"RepositoryRole", "Team", "Integration", "OrganizationAdmin"}, false), + Description: "The type of actor that can bypass a ruleset. Can be one of: `RepositoryRole`, `Team`, `Integration`, `OrganizationAdmin`.", + }, + "bypass_mode": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice([]string{"always", "pull_request"}, false), + Description: "When the specified actor can bypass the ruleset. pull_request means that an actor can only bypass rules on pull requests. Can be one of: `always`, `pull_request`.", + }, + }}, + }, + "node_id": { + Type: schema.TypeString, + Computed: true, + Description: "GraphQL global node id for use with v4 API.", + }, + "ruleset_id": { + Type: schema.TypeInt, + Computed: true, + Description: "GitHub ID for the ruleset.", + }, + "conditions": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: "Parameters for a repository ruleset ref name condition.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "ref_name": { + Type: schema.TypeList, + Required: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "inlcude": { + Type: schema.TypeList, + Required: true, + Description: "Array of ref names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~DEFAULT_BRANCH` to include the default branch or `~ALL` to include all branches.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "exclude": { + Type: schema.TypeList, + Required: true, + Description: "Array of ref names or patterns to exclude. The condition will not pass if any of these patterns match.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + }, + }, + "repository_name": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + ConflictsWith: []string{"conditions.0.repository_id"}, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "inlcude": { + Type: schema.TypeList, + Required: true, + Description: "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.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "exclude": { + Type: schema.TypeList, + Required: true, + Description: "Array of repository names or patterns to exclude. The condition will not pass if any of these patterns match.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "protected": { + Type: schema.TypeBool, + Optional: true, + Description: "Whether renaming of target repositories is prevented.", + }, + }, + }, + }, + "repository_id": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + ConflictsWith: []string{"conditions.0.repository_name"}, + 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, + }, + }, + }, + }, + }, + "rules": { + Type: schema.TypeList, + Required: true, + MaxItems: 1, + Description: "Rules within the ruleset.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "creation": { + Type: schema.TypeBool, + Optional: true, + Description: "Only allow users with bypass permission to create matching refs.", + }, + "update": { + Type: schema.TypeBool, + Optional: true, + Description: "Only allow users with bypass permission to update matching refs.", + }, + "deletion": { + Type: schema.TypeBool, + Optional: true, + Description: "Only allow users with bypass permissions to delete matching refs.", + }, + "required_linear_history": { + Type: schema.TypeBool, + Optional: true, + Description: "Prevent merge commits from being pushed to matching branches.", + }, + "required_signatures": { + Type: schema.TypeBool, + Optional: true, + Description: "Commits pushed to matching branches must have verified signatures.", + }, + "pull_request": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Require all commits be made to a non-target branch and submitted via a pull request before they can be merged.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "dismiss_stale_reviews_on_push": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "New, reviewable commits pushed will dismiss previous pull request review approvals. Defaults to `false`.", + }, + "require_code_owner_review": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Require an approving review in pull requests that modify files that have a designated code owner. Defaults to `false`.", + }, + "require_last_push_approval": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Whether the most recent reviewable push must be approved by someone other than the person who pushed it. Defaults to `false`.", + }, + "required_approving_review_count": { + Type: schema.TypeInt, + Optional: true, + Default: 0, + Description: "The number of approving reviews that are required before a pull request can be merged. Defaults to `0`.", + }, + "required_review_thread_resolution": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "All conversations on code must be resolved before a pull request can be merged. Defaults to `false`.", + }, + }, + }, + }, + "required_status_checks": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Choose which status checks must pass before branches can be merged into a branch that matches this rule. When enabled, commits must first be pushed to another branch, then merged or pushed directly to a branch that matches this rule after status checks have passed.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "required_check": { + Type: schema.TypeList, + MinItems: 1, + Required: true, + Description: "Status checks that are required.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "context": { + Type: schema.TypeString, + Required: true, + Description: "The status check context name that must be present on the commit.", + }, + "integration_id": { + Type: schema.TypeInt, + Optional: true, + Description: "The optional integration ID that this status check must originate from.", + }, + }, + }, + }, + "strict_required_status_checks_policy": { + Type: schema.TypeBool, + Optional: true, + Description: "Whether pull requests targeting a matching branch must be tested with the latest code. This setting will not take effect unless at least one status check is enabled. Defaults to `false`.", + }, + }, + }, + }, + "non_fast_forward": { + Type: schema.TypeBool, + Optional: true, + Description: "Prevent users with push access from force pushing to branches.", + }, + "commit_message_pattern": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Parameters to be used for the commit_message_pattern rule.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "name": { + Type: schema.TypeString, + Optional: true, + Description: "How this rule will appear to users.", + }, + "negate": { + Type: schema.TypeBool, + Optional: true, + Description: "If true, the rule will fail if the pattern matches.", + }, + "operator": { + Type: schema.TypeString, + Required: true, + Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", + }, + "pattern": { + Type: schema.TypeString, + Required: true, + Description: "The pattern to match with.", + }, + }, + }, + }, + "commit_author_email_pattern": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Parameters to be used for the commit_author_email_pattern rule.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "name": { + Type: schema.TypeString, + Optional: true, + Description: "How this rule will appear to users.", + }, + "negate": { + Type: schema.TypeBool, + Optional: true, + Description: "If true, the rule will fail if the pattern matches.", + }, + "operator": { + Type: schema.TypeString, + Required: true, + Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", + }, + "pattern": { + Type: schema.TypeString, + Required: true, + Description: "The pattern to match with.", + }, + }, + }, + }, + "committer_email_pattern": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Parameters to be used for the committer_email_pattern rule.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "name": { + Type: schema.TypeString, + Optional: true, + Description: "How this rule will appear to users.", + }, + "negate": { + Type: schema.TypeBool, + Optional: true, + Description: "If true, the rule will fail if the pattern matches.", + }, + "operator": { + Type: schema.TypeString, + Required: true, + Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", + }, + "pattern": { + Type: schema.TypeString, + Required: true, + Description: "The pattern to match with.", + }, + }, + }, + }, + "branch_name_pattern": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Parameters to be used for the branch_name_pattern rule.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "name": { + Type: schema.TypeString, + Optional: true, + Description: "How this rule will appear to users.", + }, + "negate": { + Type: schema.TypeBool, + Optional: true, + Description: "If true, the rule will fail if the pattern matches.", + }, + "operator": { + Type: schema.TypeString, + Required: true, + Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", + }, + "pattern": { + Type: schema.TypeString, + Required: true, + Description: "The pattern to match with.", + }, + }, + }, + }, + "tag_name_pattern": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Parameters to be used for the tag_name_pattern rule.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "name": { + Type: schema.TypeString, + Optional: true, + Description: "How this rule will appear to users.", + }, + "negate": { + Type: schema.TypeBool, + Optional: true, + Description: "If true, the rule will fail if the pattern matches.", + }, + "operator": { + Type: schema.TypeString, + Required: true, + Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", + }, + "pattern": { + Type: schema.TypeString, + Required: true, + Description: "The pattern to match with.", + }, + }, + }, + }, + }, + }, + }, + "etag": { + Type: schema.TypeString, + Computed: true, + }, + }, + // CustomizeDiff: customDiffFunction, + } +} + +func resourceGithubOrganizationRulesetCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + + owner := meta.(*Owner).name + if explicitOwner, ok := d.GetOk("owner"); ok { + owner = explicitOwner.(string) + } + + rulesetReq := resourceGithubRulesetObject(d, owner) + + ctx := context.Background() + + var ruleset *github.Ruleset + var err error + + ruleset, _, err = client.Organizations.CreateOrganizationRuleset(ctx, owner, rulesetReq) + if err != nil { + return err + } + d.SetId(strconv.FormatInt(*ruleset.ID, 10)) + + return resourceGithubOrganizationRulesetRead(d, meta) +} + +func resourceGithubOrganizationRulesetRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + + owner := meta.(*Owner).name + if explicitOwner, ok := d.GetOk("owner"); ok { + owner = explicitOwner.(string) + } + + rulesetID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return unconvertibleIdErr(d.Id(), err) + } + + // When the user has not authenticated the provider, AnonymousHTTPClient is used, therefore owner == "". In this + // case lookup the owner in the data, and use that, if present. + if explicitOwner, _, ok := resourceGithubParseFullName(d); ok && owner == "" { + owner = explicitOwner + } + + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + if !d.IsNewResource() { + ctx = context.WithValue(ctx, ctxEtag, d.Get("etag").(string)) + } + + var ruleset *github.Ruleset + var resp *github.Response + + ruleset, resp, err = client.Organizations.GetOrganizationRuleset(ctx, owner, rulesetID) + if err != nil { + if ghErr, ok := err.(*github.ErrorResponse); ok { + if ghErr.Response.StatusCode == http.StatusNotModified { + return nil + } + if ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[INFO] Removing ruleset %s: %d from state because it no longer exists in GitHub", + owner, rulesetID) + d.SetId("") + return nil + } + } + } + + d.Set("etag", resp.Header.Get("ETag")) + d.Set("name", ruleset.Name) + d.Set("target", ruleset.GetTarget()) + d.Set("enforcement", ruleset.Enforcement) + d.Set("bypass_actors", flattenBypassActors(ruleset.BypassActors)) + d.Set("conditions", flattenConditions(ruleset.GetConditions(), true)) + d.Set("rules", flattenRules(ruleset.Rules, true)) + d.Set("node_id", ruleset.GetNodeID()) + d.Set("ruleset_id", ruleset.ID) + d.Set("owner", owner) + + return nil +} + +func resourceGithubOrganizationRulesetUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + + owner := meta.(*Owner).name + if explicitOwner, ok := d.GetOk("owner"); ok { + owner = explicitOwner.(string) + } + + rulesetReq := resourceGithubRulesetObject(d, owner) + + rulesetID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return unconvertibleIdErr(d.Id(), err) + } + + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + ruleset, _, err := client.Organizations.UpdateOrganizationRuleset(ctx, owner, rulesetID, rulesetReq) + if err != nil { + return err + } + d.SetId(strconv.FormatInt(*ruleset.ID, 10)) + + return resourceGithubOrganizationRulesetRead(d, meta) +} + +func resourceGithubOrganizationRulesetDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + if explicitOwner, ok := d.GetOk("owner"); ok { + owner = explicitOwner.(string) + } + + rulesetID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return unconvertibleIdErr(d.Id(), err) + } + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + log.Printf("[DEBUG] Deleting organization ruleset: %s: %d", owner, rulesetID) + _, err = client.Organizations.DeleteOrganizationRuleset(ctx, owner, rulesetID) + return err +} diff --git a/github/resource_github_repository_ruleset.go b/github/resource_github_repository_ruleset.go new file mode 100644 index 0000000000..d964abb46f --- /dev/null +++ b/github/resource_github_repository_ruleset.go @@ -0,0 +1,575 @@ +package github + +import ( + "context" + "log" + "net/http" + "strconv" + + "github.com/google/go-github/v53/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/helper/validation" +) + +func resourceGithubRepositoryRuleset() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubRepositoryRulesetCreate, + Read: resourceGithubRepositoryRulesetRead, + Update: resourceGithubRepositoryRulesetUpdate, + Delete: resourceGithubRepositoryRulesetDelete, + Importer: &schema.ResourceImporter{ + State: func(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + return []*schema.ResourceData{d}, nil + }, + }, + + SchemaVersion: 1, + + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringLenBetween(1, 100), + Description: "The name of the ruleset.", + }, + "target": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{"branch", "tag"}, false), + Description: "Possible values are `branch` and `tag`.", + }, + "repository": { + Type: schema.TypeString, + Optional: true, + Description: "Name of the repository to apply rulset to.", + }, + "owner": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "Owner of the repository. If not provided, the provider's default owner is used.", + }, + "enforcement": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{"disabled", "active", "evaluate"}, false), + Description: "Possible values for Enforcement are `disabled`, `active`, `evaluate`. Note: `evaluate` is currently only supported for owners of type `organization`.", + }, + + "bypass_actors": { + Type: schema.TypeList, + Optional: true, + Description: "The actors that can bypass the rules in this ruleset.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "actor_id": { + Type: schema.TypeInt, + Required: true, + Description: "The ID of the actor that can bypass a ruleset", + }, + "actor_type": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{"RepositoryRole", "Team", "Integration", "OrganizationAdmin"}, false), + Description: "The type of actor that can bypass a ruleset. Can be one of: `RepositoryRole`, `Team`, `Integration`, `OrganizationAdmin`.", + }, + "bypass_mode": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice([]string{"always", "pull_request"}, false), + Description: "When the specified actor can bypass the ruleset. pull_request means that an actor can only bypass rules on pull requests. Can be one of: `always`, `pull_request`.", + }, + }}, + }, + "node_id": { + Type: schema.TypeString, + Computed: true, + Description: "GraphQL global node id for use with v4 API.", + }, + "ruleset_id": { + Type: schema.TypeInt, + Computed: true, + Description: "GitHub ID for the ruleset.", + }, + "conditions": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: "Parameters for a repository ruleset ref name condition.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "ref_name": { + Type: schema.TypeList, + Required: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "include": { + Type: schema.TypeList, + Required: true, + Description: "Array of ref names or patterns to include. One of these patterns must match for the condition to pass. Also accepts `~DEFAULT_BRANCH` to include the default branch or `~ALL` to include all branches.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "exclude": { + Type: schema.TypeList, + Required: true, + Description: "Array of ref names or patterns to exclude. The condition will not pass if any of these patterns match.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + }, + }, + }, + }, + }, + "rules": { + Type: schema.TypeList, + Required: true, + MaxItems: 1, + Description: "Rules within the ruleset.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "creation": { + Type: schema.TypeBool, + Optional: true, + Description: "Only allow users with bypass permission to create matching refs.", + }, + "update": { + Type: schema.TypeBool, + Optional: true, + Description: "Only allow users with bypass permission to update matching refs.", + }, + "deletion": { + Type: schema.TypeBool, + Optional: true, + Description: "Only allow users with bypass permissions to delete matching refs.", + }, + "required_linear_history": { + Type: schema.TypeBool, + Optional: true, + Description: "Prevent merge commits from being pushed to matching branches.", + }, + "required_deployments": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Choose which environments must be successfully deployed to before branches can be merged into a branch that matches this rule.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Required: true, + }, + "required_deployment_environments": { + Type: schema.TypeList, + Required: true, + Description: "The environments that must be successfully deployed to before branches can be merged.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + }, + }, + "required_signatures": { + Type: schema.TypeBool, + Optional: true, + Description: "Commits pushed to matching branches must have verified signatures.", + }, + "pull_request": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Require all commits be made to a non-target branch and submitted via a pull request before they can be merged.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "dismiss_stale_reviews_on_push": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "New, reviewable commits pushed will dismiss previous pull request review approvals. Defaults to `false`.", + }, + "require_code_owner_review": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Require an approving review in pull requests that modify files that have a designated code owner. Defaults to `false`.", + }, + "require_last_push_approval": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Whether the most recent reviewable push must be approved by someone other than the person who pushed it. Defaults to `false`.", + }, + "required_approving_review_count": { + Type: schema.TypeInt, + Optional: true, + Default: 0, + Description: "The number of approving reviews that are required before a pull request can be merged. Defaults to `0`.", + }, + "required_review_thread_resolution": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "All conversations on code must be resolved before a pull request can be merged. Defaults to `false`.", + }, + }, + }, + }, + "required_status_checks": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Choose which status checks must pass before branches can be merged into a branch that matches this rule. When enabled, commits must first be pushed to another branch, then merged or pushed directly to a branch that matches this rule after status checks have passed.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "required_check": { + Type: schema.TypeSet, + MinItems: 1, + Required: true, + Description: "Status checks that are required. Several can be defined.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "context": { + Type: schema.TypeString, + Required: true, + Description: "The status check context name that must be present on the commit.", + }, + "integration_id": { + Type: schema.TypeInt, + Optional: true, + Description: "The optional integration ID that this status check must originate from.", + }, + }, + }, + }, + "strict_required_status_checks_policy": { + Type: schema.TypeBool, + Optional: true, + Description: "Whether pull requests targeting a matching branch must be tested with the latest code. This setting will not take effect unless at least one status check is enabled. Defaults to `false`.", + }, + }, + }, + }, + "non_fast_forward": { + Type: schema.TypeBool, + Optional: true, + Description: "Prevent users with push access from force pushing to branches.", + }, + "commit_message_pattern": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Parameters to be used for the commit_message_pattern rule.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "name": { + Type: schema.TypeString, + Optional: true, + Description: "How this rule will appear to users.", + }, + "negate": { + Type: schema.TypeBool, + Optional: true, + Description: "If true, the rule will fail if the pattern matches.", + }, + "operator": { + Type: schema.TypeString, + Required: true, + Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", + }, + "pattern": { + Type: schema.TypeString, + Required: true, + Description: "The pattern to match with.", + }, + }, + }, + }, + "commit_author_email_pattern": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Parameters to be used for the commit_author_email_pattern rule.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "name": { + Type: schema.TypeString, + Optional: true, + Description: "How this rule will appear to users.", + }, + "negate": { + Type: schema.TypeBool, + Optional: true, + Description: "If true, the rule will fail if the pattern matches.", + }, + "operator": { + Type: schema.TypeString, + Required: true, + Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", + }, + "pattern": { + Type: schema.TypeString, + Required: true, + Description: "The pattern to match with.", + }, + }, + }, + }, + "committer_email_pattern": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Parameters to be used for the committer_email_pattern rule.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "name": { + Type: schema.TypeString, + Optional: true, + Description: "How this rule will appear to users.", + }, + "negate": { + Type: schema.TypeBool, + Optional: true, + Description: "If true, the rule will fail if the pattern matches.", + }, + "operator": { + Type: schema.TypeString, + Required: true, + Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", + }, + "pattern": { + Type: schema.TypeString, + Required: true, + Description: "The pattern to match with.", + }, + }, + }, + }, + "branch_name_pattern": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Parameters to be used for the branch_name_pattern rule.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "name": { + Type: schema.TypeString, + Optional: true, + Description: "How this rule will appear to users.", + }, + "negate": { + Type: schema.TypeBool, + Optional: true, + Description: "If true, the rule will fail if the pattern matches.", + }, + "operator": { + Type: schema.TypeString, + Required: true, + Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", + }, + "pattern": { + Type: schema.TypeString, + Required: true, + Description: "The pattern to match with.", + }, + }, + }, + }, + "tag_name_pattern": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Parameters to be used for the tag_name_pattern rule.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "enabled": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "name": { + Type: schema.TypeString, + Optional: true, + Description: "How this rule will appear to users.", + }, + "negate": { + Type: schema.TypeBool, + Optional: true, + Description: "If true, the rule will fail if the pattern matches.", + }, + "operator": { + Type: schema.TypeString, + Required: true, + Description: "The operator to use for matching. Can be one of: `starts_with`, `ends_with`, `contains`, `regex`.", + }, + "pattern": { + Type: schema.TypeString, + Required: true, + Description: "The pattern to match with.", + }, + }, + }, + }, + }, + }, + }, + "etag": { + Type: schema.TypeString, + Computed: true, + }, + }, + // CustomizeDiff: customDiffFunction, + } +} + +func resourceGithubRepositoryRulesetCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + + rulesetReq := resourceGithubRulesetObject(d, "") + + owner := meta.(*Owner).name + if explicitOwner, ok := d.GetOk("owner"); ok { + owner = explicitOwner.(string) + } + + repoName := d.Get("repository").(string) + ctx := context.Background() + + var ruleset *github.Ruleset + var err error + + ruleset, _, err = client.Repositories.CreateRuleset(ctx, owner, repoName, rulesetReq) + if err != nil { + return err + } + d.SetId(strconv.FormatInt(*ruleset.ID, 10)) + + return resourceGithubRepositoryRulesetRead(d, meta) +} + +func resourceGithubRepositoryRulesetRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + + owner := meta.(*Owner).name + if explicitOwner, ok := d.GetOk("owner"); ok { + owner = explicitOwner.(string) + } + + repoName := d.Get("repository").(string) + rulesetID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return unconvertibleIdErr(d.Id(), err) + } + + ctx := context.WithValue(context.Background(), ctxId, rulesetID) + if !d.IsNewResource() { + ctx = context.WithValue(ctx, ctxEtag, d.Get("etag").(string)) + } + + var ruleset *github.Ruleset + var resp *github.Response + + ruleset, resp, err = client.Repositories.GetRuleset(ctx, owner, repoName, rulesetID, false) + if err != nil { + if ghErr, ok := err.(*github.ErrorResponse); ok { + if ghErr.Response.StatusCode == http.StatusNotModified { + return nil + } + if ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[INFO] Removing ruleset %s/%s: %d from state because it no longer exists in GitHub", + owner, repoName, rulesetID) + d.SetId("") + return nil + } + } + } + + d.Set("etag", resp.Header.Get("ETag")) + d.Set("name", ruleset.Name) + d.Set("target", ruleset.GetTarget()) + d.Set("enforcement", ruleset.Enforcement) + d.Set("bypass_actors", flattenBypassActors(ruleset.BypassActors)) + d.Set("conditions", flattenConditions(ruleset.GetConditions(), false)) + d.Set("rules", flattenRules(ruleset.Rules, false)) + d.Set("node_id", ruleset.GetNodeID()) + d.Set("ruleset_id", ruleset.ID) + d.Set("owner", owner) + + return nil +} + +func resourceGithubRepositoryRulesetUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + + rulesetReq := resourceGithubRulesetObject(d, "") + + owner := meta.(*Owner).name + if explicitOwner, ok := d.GetOk("owner"); ok { + owner = explicitOwner.(string) + } + + repoName := d.Get("repository").(string) + rulesetID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return unconvertibleIdErr(d.Id(), err) + } + + ctx := context.WithValue(context.Background(), ctxId, rulesetID) + + ruleset, _, err := client.Repositories.UpdateRuleset(ctx, owner, repoName, rulesetID, rulesetReq) + if err != nil { + return err + } + d.SetId(strconv.FormatInt(*ruleset.ID, 10)) + + return resourceGithubRepositoryRulesetRead(d, meta) +} + +func resourceGithubRepositoryRulesetDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + if explicitOwner, ok := d.GetOk("owner"); ok { + owner = explicitOwner.(string) + } + + repoName := d.Get("repository").(string) + rulesetID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return unconvertibleIdErr(d.Id(), err) + } + ctx := context.WithValue(context.Background(), ctxId, rulesetID) + + log.Printf("[DEBUG] Deleting repository ruleset: %s/%s: %d", owner, repoName, rulesetID) + _, err = client.Repositories.DeleteRuleset(ctx, owner, repoName, rulesetID) + return err +} diff --git a/github/respository_rules_utils.go b/github/respository_rules_utils.go new file mode 100644 index 0000000000..5c9cd176a4 --- /dev/null +++ b/github/respository_rules_utils.go @@ -0,0 +1,409 @@ +package github + +import ( + "encoding/json" + "log" + + "github.com/google/go-github/v53/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func resourceGithubRulesetObject(d *schema.ResourceData, org string) *github.Ruleset { + isOrgLevel := len(org) > 0 + + var source, sourceType string + if isOrgLevel { + source = org + sourceType = "Organization" + } else { + source = d.Get("repository").(string) + sourceType = "Repository" + } + + return &github.Ruleset{ + Name: d.Get("name").(string), + Target: github.String(d.Get("target").(string)), + Source: source, + SourceType: &sourceType, + Enforcement: d.Get("enforcement").(string), + BypassActors: expandBypassActors(d.Get("bypass_actors").([]interface{})), + Conditions: expandConditions(d.Get("conditions").([]interface{}), isOrgLevel), + Rules: expandRules(d.Get("rules").([]interface{}), isOrgLevel), + } +} + +func expandBypassActors(input []interface{}) []*github.BypassActor { + if len(input) == 0 { + return nil + } + bypassActors := make([]*github.BypassActor, 0) + + for _, v := range input { + inputMap := v.(map[string]interface{}) + actor := &github.BypassActor{} + if v, ok := inputMap["actor_id"].(int); ok { + actor.ActorID = github.Int64(int64(v)) + } + + if v, ok := inputMap["actor_type"].(string); ok { + actor.ActorType = &v + } + + if v, ok := inputMap["bypass_mode"].(string); ok { + actor.BypassMode = &v + } + bypassActors = append(bypassActors, actor) + } + + return bypassActors +} + +func flattenBypassActors(bypassActors []*github.BypassActor) []interface{} { + if bypassActors == nil { + return []interface{}{} + } + + actorsSlice := make([]map[string]interface{}, 0) + for _, v := range bypassActors { + actorMap := make(map[string]interface{}) + + actorMap["actor_id"] = v.GetActorID() + actorMap["actor_type"] = v.GetActorType() + actorMap["bypass_mode"] = v.GetBypassMode() + + actorsSlice = append(actorsSlice, actorMap) + } + + return []interface{}{actorsSlice} +} + +func expandConditions(input []interface{}, org bool) *github.RulesetConditions { + if len(input) == 0 || input[0] == nil { + return nil + } + rulesetConditions := &github.RulesetConditions{} + inputConditions := input[0].(map[string]interface{}) + + // ref_name is available for both repo and org rulesets + if v, ok := inputConditions["ref_name"].([]interface{}); ok && v != nil { + 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)) + } + } + + for _, v := range inputRefName["exclude"].([]interface{}) { + if v != nil { + exclude = append(exclude, v.(string)) + } + } + + rulesetConditions.RefName = &github.RulesetRefConditionParameters{ + Include: include, + Exclude: exclude, + } + } + + // org-only fields + if org { + // repository_name + if v, ok := inputConditions["repository_name"].([]interface{}); ok && v != nil { + inputRepositoryName := v[0].(map[string]interface{}) + include := make([]string, 0) + exclude := make([]string, 0) + + for _, v := range inputRepositoryName["include"].([]interface{}) { + if v != nil { + include = append(include, v.(string)) + } + } + + for _, v := range inputRepositoryName["exclude"].([]interface{}) { + if v != nil { + exclude = append(exclude, v.(string)) + } + } + + protected := inputRepositoryName["protected"].(bool) + + rulesetConditions.RepositoryName = &github.RulesetRepositoryNamesConditionParameters{ + Include: include, + Exclude: exclude, + Protected: &protected, + } + } + + // repository_id + if v, ok := inputConditions["repository_id"].([]interface{}); ok && v != nil { + repositoryIDs := make([]int64, 0) + + for _, v := range v { + if v != nil { + repositoryIDs = append(repositoryIDs, int64(v.(int))) + } + } + + rulesetConditions.RepositoryID = &github.RulesetRepositoryIDsConditionParameters{RepositoryIDs: repositoryIDs} + } + } + + return rulesetConditions +} + +func flattenConditions(conditions *github.RulesetConditions, org bool) []interface{} { + if conditions == nil || conditions.RefName == nil { + return []interface{}{} + } + + conditionsMap := make(map[string]interface{}) + refNameSlice := make([]map[string]interface{}, 0) + + refNameSlice = append(refNameSlice, map[string]interface{}{ + "include": conditions.RefName.Include, + "exclude": conditions.RefName.Exclude, + }) + + conditionsMap["ref_name"] = refNameSlice + + // org-only fields + if org { + repositoryNameSlice := make([]map[string]interface{}, 0) + + if conditions.RepositoryName != nil { + repositoryNameSlice = append(refNameSlice, map[string]interface{}{ + "include": conditions.RepositoryName.Include, + "exclude": conditions.RepositoryName.Exclude, + "protected": *conditions.RepositoryName.Protected, + }) + conditionsMap["repository_name"] = repositoryNameSlice + } + + if conditions.RepositoryID != nil { + conditionsMap["repository_id"] = conditions.RepositoryID.RepositoryIDs + } + } + + return []interface{}{conditionsMap} +} + +func expandRules(input []interface{}, org bool) []*github.RepositoryRule { + if len(input) == 0 || input[0] == nil { + return nil + } + + rulesMap := input[0].(map[string]interface{}) + rulesSlice := make([]*github.RepositoryRule, 0) + + // First we expand rules without parameters + if v, ok := rulesMap["creation"].(bool); ok && v { + rulesSlice = append(rulesSlice, github.NewCreationRule()) + } + + if v, ok := rulesMap["update"].(bool); ok && v { + rulesSlice = append(rulesSlice, github.NewUpdateRule()) + } + + if v, ok := rulesMap["deletion"].(bool); ok && v { + rulesSlice = append(rulesSlice, github.NewDeletionRule()) + } + + if v, ok := rulesMap["required_linear_history"].(bool); ok && v { + rulesSlice = append(rulesSlice, github.NewRequiredLinearHistoryRule()) + } + + if v, ok := rulesMap["required_signatures"].(bool); ok && v { + rulesSlice = append(rulesSlice, github.NewRequiredSignaturesRule()) + } + + if v, ok := rulesMap["non_fast_forward"].(bool); ok && v { + rulesSlice = append(rulesSlice, github.NewNonFastForwardRule()) + } + + // Required deployments rule + if !org { + if v, ok := rulesMap["required_deployments"].([]interface{}); ok && len(v) != 0 { + requiredDeploymentsMap := v[0].(map[string]interface{}) + if enabled, ok := requiredDeploymentsMap["enabled"].(bool); ok && enabled { + envs := make([]string, 0) + for _, v := range requiredDeploymentsMap["required_deployment_environments"].([]interface{}) { + envs = append(envs, v.(string)) + } + + params := &github.RequiredDeploymentEnvironmentsRuleParameters{ + RequiredDeploymentEnvironments: envs, + } + + rulesSlice = append(rulesSlice, github.NewRequiredDeploymentsRule(params)) + } + } + } + + // Pattern parameter rules + for _, k := range []string{"commit_message_pattern", "commit_author_email_pattern", "committer_email_pattern", "branch_name_pattern", "tag_name_pattern"} { + if v, ok := rulesMap[k].([]interface{}); ok && len(v) != 0 { + patternParametersMap := v[0].(map[string]interface{}) + if enabled, ok := patternParametersMap["enabled"].(bool); ok && enabled { + + name := patternParametersMap["name"].(string) + negate := patternParametersMap["negate"].(bool) + + params := &github.RulePatternParameters{ + Name: &name, + Negate: &negate, + Operator: patternParametersMap["operator"].(string), + Pattern: patternParametersMap["pattern"].(string), + } + + switch k { + case "commit_message_pattern": + rulesSlice = append(rulesSlice, github.NewCommitMessagePatternRule(params)) + case "commit_author_email_pattern": + rulesSlice = append(rulesSlice, github.NewCommitAuthorEmailPatternRule(params)) + case "committer_email_pattern": + rulesSlice = append(rulesSlice, github.NewCommitterEmailPatternRule(params)) + case "branch_name_pattern": + rulesSlice = append(rulesSlice, github.NewBranchNamePatternRule(params)) + case "tag_name_pattern": + rulesSlice = append(rulesSlice, github.NewTagNamePatternRule(params)) + } + } + } + } + + // Pull request rule + if v, ok := rulesMap["pull_request"].([]interface{}); ok && len(v) != 0 { + pullRequestMap := v[0].(map[string]interface{}) + if enabled, ok := pullRequestMap["enabled"].(bool); ok && enabled { + params := &github.PullRequestRuleParameters{ + DismissStaleReviewsOnPush: pullRequestMap["dismiss_stale_reviews_on_push"].(bool), + RequireCodeOwnerReview: pullRequestMap["require_code_owner_review"].(bool), + RequireLastPushApproval: pullRequestMap["require_last_push_approval"].(bool), + RequiredApprovingReviewCount: pullRequestMap["required_approving_review_count"].(int), + RequiredReviewThreadResolution: pullRequestMap["required_review_thread_resolution"].(bool), + } + + rulesSlice = append(rulesSlice, github.NewPullRequestRule(params)) + } + } + + // Required status checks rule + if v, ok := rulesMap["required_status_checks"].([]interface{}); ok && len(v) != 0 { + requiredStatusMap := v[0].(map[string]interface{}) + requiredStatusChecks := make([]github.RuleRequiredStatusChecks, 0) + + if requiredStatusChecksInput, ok := requiredStatusMap["required_check"].(interface{}); ok { + + requiredStatusChecksSet := requiredStatusChecksInput.(*schema.Set) + for _, checkMap := range requiredStatusChecksSet.List() { + check := checkMap.(map[string]interface{}) + integrationID := github.Int64(int64(check["integration_id"].(int))) + + params := &github.RuleRequiredStatusChecks{ + Context: check["context"].(string), + IntegrationID: integrationID, + } + requiredStatusChecks = append(requiredStatusChecks, *params) + } + } + + params := &github.RequiredStatusChecksRuleParameters{ + RequiredStatusChecks: requiredStatusChecks, + StrictRequiredStatusChecksPolicy: requiredStatusMap["strict_required_status_checks_policy"].(bool), + } + + rulesSlice = append(rulesSlice, github.NewRequiredStatusChecksRule(params)) + } + + return rulesSlice +} + +func flattenRules(rules []*github.RepositoryRule, org bool) []interface{} { + if len(rules) == 0 || rules == nil { + return []interface{}{} + } + + rulesMap := make(map[string]interface{}) + for _, v := range rules { + switch v.Type { + case "creation", "update", "deletion", "required_linear_history", "required_signatures", "non_fast_forward": + rulesMap[v.Type] = true + + case "commit_message_pattern", "commit_author_email_pattern", "committer_email_pattern", "branch_name_pattern", "tag_name_pattern": + var params github.RulePatternParameters + + 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{}) + rule["name"] = *params.Name + rule["negate"] = *params.Negate + rule["operator"] = params.Operator + rule["pattern"] = params.Pattern + rulesMap[v.Type] = []map[string]interface{}{rule} + + case "required_deployments": + if !org { + var params github.RequiredDeploymentEnvironmentsRuleParameters + + 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{}) + rule["required_deployment_environments"] = params.RequiredDeploymentEnvironments + rulesMap[v.Type] = []map[string]interface{}{rule} + } + + case "pull_request": + var params github.PullRequestRuleParameters + + 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{}) + rule["dismiss_stale_reviews_on_push"] = params.DismissStaleReviewsOnPush + rule["require_code_owner_review"] = params.RequireCodeOwnerReview + rule["require_last_push_approval"] = params.RequireLastPushApproval + rule["required_approving_review_count"] = params.RequiredApprovingReviewCount + rule["required_review_thread_resolution"] = params.RequiredReviewThreadResolution + rulesMap[v.Type] = []map[string]interface{}{rule} + + case "required_status_checks": + var params github.RequiredStatusChecksRuleParameters + + err := json.Unmarshal(*v.Parameters, ¶ms) + if err != nil { + log.Printf("[INFO] Unexpected error unmarshalling rule %s with parameters: %v", + v.Type, v.Parameters) + } + + requiredStatusChecksSlice := make([]map[string]interface{}, 0) + for _, check := range params.RequiredStatusChecks { + integrationID := check.IntegrationID + requiredStatusChecksSlice = append(requiredStatusChecksSlice, map[string]interface{}{ + "context": check.Context, + "integration_id": *integrationID, + }) + } + + rule := make(map[string]interface{}) + rule["required_check"] = requiredStatusChecksSlice + rule["strict_required_status_checks_policy"] = params.StrictRequiredStatusChecksPolicy + rulesMap[v.Type] = []map[string]interface{}{rule} + } + } + + return []interface{}{rulesMap} +}