From 711abcaa799731ffc86467808e9f773ca3a294b4 Mon Sep 17 00:00:00 2001 From: Jeremy Udit Date: Thu, 14 Jan 2021 10:30:04 -0500 Subject: [PATCH] Add `github_branch_protection_v3` Resource (#642) * Add `branch_protection_v3` Resource - add new resource to `website/github.erb` - add new resource to `website/docs/r/.html.markdown` - add new resource to `github/provider.go` - add tests for resource in `github/resource__test.go` - implement new resource in `github/resource_.go` * fixup! gofmt fixes --- github/provider.go | 1 + .../resource_github_branch_protection_v3.go | 335 ++++++++++++++++++ ...source_github_branch_protection_v3_test.go | 273 ++++++++++++++ ...ource_github_branch_protection_v3_utils.go | 304 ++++++++++++++++ go.mod | 1 - .../docs/r/branch_protection_v3.html.markdown | 115 ++++++ website/github.erb | 3 + 7 files changed, 1031 insertions(+), 1 deletion(-) create mode 100644 github/resource_github_branch_protection_v3.go create mode 100644 github/resource_github_branch_protection_v3_test.go create mode 100644 github/resource_github_branch_protection_v3_utils.go create mode 100644 website/docs/r/branch_protection_v3.html.markdown diff --git a/github/provider.go b/github/provider.go index c1aa42cc99..4992379bb5 100644 --- a/github/provider.go +++ b/github/provider.go @@ -45,6 +45,7 @@ func Provider() terraform.ResourceProvider { "github_actions_secret": resourceGithubActionsSecret(), "github_branch": resourceGithubBranch(), "github_branch_protection": resourceGithubBranchProtection(), + "github_branch_protection_v3": resourceGithubBranchProtectionV3(), "github_issue_label": resourceGithubIssueLabel(), "github_membership": resourceGithubMembership(), "github_organization_block": resourceOrganizationBlock(), diff --git a/github/resource_github_branch_protection_v3.go b/github/resource_github_branch_protection_v3.go new file mode 100644 index 0000000000..ff57a41361 --- /dev/null +++ b/github/resource_github_branch_protection_v3.go @@ -0,0 +1,335 @@ +package github + +import ( + "context" + "fmt" + "log" + "net/http" + + "github.com/google/go-github/v32/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/helper/validation" +) + +func resourceGithubBranchProtectionV3() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubBranchProtectionV3Create, + Read: resourceGithubBranchProtectionV3Read, + Update: resourceGithubBranchProtectionV3Update, + Delete: resourceGithubBranchProtectionV3Delete, + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "branch": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "required_status_checks": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "include_admins": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Deprecated: "Use enforce_admins instead", + DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + return true + }, + }, + "strict": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "contexts": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + }, + }, + "required_pull_request_reviews": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + // FIXME: Remove this deprecated field + "include_admins": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Deprecated: "Use enforce_admins instead", + DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + return true + }, + }, + "dismiss_stale_reviews": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "dismissal_users": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "dismissal_teams": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "require_code_owner_reviews": { + Type: schema.TypeBool, + Optional: true, + }, + "required_approving_review_count": { + Type: schema.TypeInt, + Optional: true, + Default: 1, + ValidateFunc: validation.IntBetween(1, 6), + }, + }, + }, + }, + "restrictions": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "users": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "teams": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "apps": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + }, + }, + }, + "enforce_admins": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "require_signed_commits": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "etag": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func resourceGithubBranchProtectionV3Create(d *schema.ResourceData, meta interface{}) error { + err := checkOrganization(meta) + if err != nil { + return err + } + + client := meta.(*Owner).v3client + + orgName := meta.(*Owner).name + repoName := d.Get("repository").(string) + branch := d.Get("branch").(string) + + protectionRequest, err := buildProtectionRequest(d) + if err != nil { + return err + } + ctx := context.Background() + + log.Printf("[DEBUG] Creating branch protection: %s/%s (%s)", + orgName, repoName, branch) + protection, _, err := client.Repositories.UpdateBranchProtection(ctx, + orgName, + repoName, + branch, + protectionRequest, + ) + if err != nil { + return err + } + + if err := checkBranchRestrictionsUsers(protection.GetRestrictions(), protectionRequest.GetRestrictions()); err != nil { + return err + } + + d.SetId(buildTwoPartID(repoName, branch)) + + if err = requireSignedCommitsUpdate(d, meta); err != nil { + return err + } + + return resourceGithubBranchProtectionV3Read(d, meta) +} + +func resourceGithubBranchProtectionV3Read(d *schema.ResourceData, meta interface{}) error { + err := checkOrganization(meta) + if err != nil { + return err + } + + client := meta.(*Owner).v3client + + repoName, branch, err := parseTwoPartID(d.Id(), "repository", "branch") + if err != nil { + return err + } + orgName := meta.(*Owner).name + + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + if !d.IsNewResource() { + ctx = context.WithValue(ctx, ctxEtag, d.Get("etag").(string)) + } + + log.Printf("[DEBUG] Reading branch protection: %s/%s (%s)", + orgName, repoName, branch) + githubProtection, resp, err := client.Repositories.GetBranchProtection(ctx, + orgName, repoName, branch) + if err != nil { + if ghErr, ok := err.(*github.ErrorResponse); ok { + if ghErr.Response.StatusCode == http.StatusNotModified { + if err := requireSignedCommitsRead(d, meta); err != nil { + return fmt.Errorf("Error setting signed commit restriction: %v", err) + } + return nil + } + if ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[WARN] Removing branch protection %s/%s (%s) from state because it no longer exists in GitHub", + orgName, repoName, branch) + d.SetId("") + return nil + } + } + + return err + } + + d.Set("etag", resp.Header.Get("ETag")) + d.Set("repository", repoName) + d.Set("branch", branch) + d.Set("enforce_admins", githubProtection.GetEnforceAdmins().Enabled) + + if err := flattenAndSetRequiredStatusChecks(d, githubProtection); err != nil { + return fmt.Errorf("Error setting required_status_checks: %v", err) + } + + if err := flattenAndSetRequiredPullRequestReviews(d, githubProtection); err != nil { + return fmt.Errorf("Error setting required_pull_request_reviews: %v", err) + } + + if err := flattenAndSetRestrictions(d, githubProtection); err != nil { + return fmt.Errorf("Error setting restrictions: %v", err) + } + + if err := requireSignedCommitsRead(d, meta); err != nil { + return fmt.Errorf("Error setting signed commit restriction: %v", err) + } + + return nil +} + +func resourceGithubBranchProtectionV3Update(d *schema.ResourceData, meta interface{}) error { + err := checkOrganization(meta) + if err != nil { + return err + } + + client := meta.(*Owner).v3client + repoName, branch, err := parseTwoPartID(d.Id(), "repository", "branch") + if err != nil { + return err + } + + protectionRequest, err := buildProtectionRequest(d) + if err != nil { + return err + } + + orgName := meta.(*Owner).name + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + log.Printf("[DEBUG] Updating branch protection: %s/%s (%s)", + orgName, repoName, branch) + protection, _, err := client.Repositories.UpdateBranchProtection(ctx, + orgName, + repoName, + branch, + protectionRequest, + ) + if err != nil { + return err + } + + if err := checkBranchRestrictionsUsers(protection.GetRestrictions(), protectionRequest.GetRestrictions()); err != nil { + return err + } + + if protectionRequest.RequiredPullRequestReviews == nil { + _, err = client.Repositories.RemovePullRequestReviewEnforcement(ctx, + orgName, + repoName, + branch, + ) + if err != nil { + return err + } + } + + d.SetId(buildTwoPartID(repoName, branch)) + + if err = requireSignedCommitsUpdate(d, meta); err != nil { + return err + } + + return resourceGithubBranchProtectionV3Read(d, meta) +} + +func resourceGithubBranchProtectionV3Delete(d *schema.ResourceData, meta interface{}) error { + err := checkOrganization(meta) + if err != nil { + return err + } + + client := meta.(*Owner).v3client + repoName, branch, err := parseTwoPartID(d.Id(), "repository", "branch") + if err != nil { + return err + } + + orgName := meta.(*Owner).name + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + log.Printf("[DEBUG] Deleting branch protection: %s/%s (%s)", orgName, repoName, branch) + _, err = client.Repositories.RemoveBranchProtection(ctx, + orgName, repoName, branch) + return err +} diff --git a/github/resource_github_branch_protection_v3_test.go b/github/resource_github_branch_protection_v3_test.go new file mode 100644 index 0000000000..2947430713 --- /dev/null +++ b/github/resource_github_branch_protection_v3_test.go @@ -0,0 +1,273 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubBranchProtectionV3_defaults(t *testing.T) { + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + t.Run("configures default settings when empty", func(t *testing.T) { + + config := fmt.Sprintf(` + + resource "github_repository" "test" { + name = "tf-acc-test-%s" + auto_init = true + } + + resource "github_branch_protection_v3" "test" { + + repository = github_repository.test.name + branch = "main" + + } + + `, randomID) + + check := resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr( + "github_branch_protection_v3.test", "branch", "main", + ), + resource.TestCheckResourceAttr( + "github_branch_protection_v3.test", "require_signed_commits", "false", + ), + resource.TestCheckResourceAttr( + "github_branch_protection_v3.test", "required_status_checks.#", "0", + ), + resource.TestCheckResourceAttr( + "github_branch_protection_v3.test", "required_pull_request_reviews.#", "0", + ), + resource.TestCheckResourceAttr( + "github_branch_protection_v3.test", "push_restrictions.#", "0", + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + t.Skip("individual account not supported for this operation") + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + + }) +} +func TestAccGithubBranchProtectionV3_required_status_checks(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + t.Run("configures required status checks", func(t *testing.T) { + + config := fmt.Sprintf(` + + resource "github_repository" "test" { + name = "tf-acc-test-%s" + auto_init = true + } + + resource "github_branch_protection_v3" "test" { + + repository = github_repository.test.name + branch = "main" + + required_status_checks { + strict = true + contexts = ["github/foo"] + } + + } + + `, randomID) + + check := resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr( + "github_branch_protection_v3.test", "required_status_checks.#", "1", + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + t.Skip("individual account not supported for this operation") + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + + }) +} +func TestAccGithubBranchProtectionV3_required_pull_request_reviews(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + t.Run("configures required pull request reviews", func(t *testing.T) { + + config := fmt.Sprintf(` + + resource "github_repository" "test" { + name = "tf-acc-test-%s" + auto_init = true + } + + resource "github_branch_protection_v3" "test" { + + repository = github_repository.test.name + branch = "main" + + required_pull_request_reviews { + dismiss_stale_reviews = true + require_code_owner_reviews = true + } + + } + + `, randomID) + + check := resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr( + "github_branch_protection_v3.test", "required_pull_request_reviews.#", "1", + ), + resource.TestCheckResourceAttr( + "github_branch_protection_v3.test", "required_pull_request_reviews.0.dismiss_stale_reviews", "true", + ), + resource.TestCheckResourceAttr( + "github_branch_protection_v3.test", "required_pull_request_reviews.0.require_code_owner_reviews", "true", + ), + resource.TestCheckResourceAttr( + "github_branch_protection_v3.test", "required_pull_request_reviews.0.required_approving_review_count", "1", + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + t.Skip("individual account not supported for this operation") + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + + }) +} + +func TestAccGithubBranchProtectionV3_branch_push_restrictions(t *testing.T) { + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + t.Run("configures branch push restrictions", func(t *testing.T) { + + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + auto_init = true + } + + resource "github_team" "test" { + name = "tf-acc-test-%[1]s" + } + + resource "github_team_repository" "test" { + team_id = "${github_team.test.id}" + repository = "${github_repository.test.name}" + permission = "pull" + } + + resource "github_branch_protection_v3" "test" { + + repository = github_repository.test.name + branch = "main" + + restrictions { + teams = ["${github_team.test.slug}"] + } + + } + `, randomID) + + check := resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr( + "github_branch_protection_v3.test", "restrictions.#", "1", + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + t.Skip("individual account not supported for this operation") + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + + }) + +} diff --git a/github/resource_github_branch_protection_v3_utils.go b/github/resource_github_branch_protection_v3_utils.go new file mode 100644 index 0000000000..c369a92b6e --- /dev/null +++ b/github/resource_github_branch_protection_v3_utils.go @@ -0,0 +1,304 @@ +package github + +import ( + "context" + "errors" + "fmt" + "log" + "strings" + + "github.com/google/go-github/v32/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func buildProtectionRequest(d *schema.ResourceData) (*github.ProtectionRequest, error) { + req := &github.ProtectionRequest{ + EnforceAdmins: d.Get("enforce_admins").(bool), + } + + rsc, err := expandRequiredStatusChecks(d) + if err != nil { + return nil, err + } + req.RequiredStatusChecks = rsc + + rprr, err := expandRequiredPullRequestReviews(d) + if err != nil { + return nil, err + } + req.RequiredPullRequestReviews = rprr + + res, err := expandRestrictions(d) + if err != nil { + return nil, err + } + req.Restrictions = res + + return req, nil +} + +func flattenAndSetRequiredStatusChecks(d *schema.ResourceData, protection *github.Protection) error { + rsc := protection.GetRequiredStatusChecks() + if rsc != nil { + contexts := make([]interface{}, 0, len(rsc.Contexts)) + for _, c := range rsc.Contexts { + contexts = append(contexts, c) + } + + return d.Set("required_status_checks", []interface{}{ + map[string]interface{}{ + "strict": rsc.Strict, + "contexts": schema.NewSet(schema.HashString, contexts), + }, + }) + } + + return d.Set("required_status_checks", []interface{}{}) +} + +func requireSignedCommitsRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + + repoName, branch, err := parseTwoPartID(d.Id(), "repository", "branch") + if err != nil { + return err + } + orgName := meta.(*Owner).name + + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + if !d.IsNewResource() { + ctx = context.WithValue(ctx, ctxEtag, d.Get("etag").(string)) + } + + log.Printf("[DEBUG] Reading branch protection signed commit status: %s/%s (%s)", orgName, repoName, branch) + signedCommitStatus, _, err := client.Repositories.GetSignaturesProtectedBranch(ctx, + orgName, repoName, branch) + if err != nil { + log.Printf("[WARN] Not able to read signature protection: %s/%s (%s)", orgName, repoName, branch) + return nil + } + + return d.Set("require_signed_commits", signedCommitStatus.Enabled) +} + +func requireSignedCommitsUpdate(d *schema.ResourceData, meta interface{}) (err error) { + requiredSignedCommit := d.Get("require_signed_commits").(bool) + client := meta.(*Owner).v3client + + repoName, branch, err := parseTwoPartID(d.Id(), "repository", "branch") + if err != nil { + return err + } + orgName := meta.(*Owner).name + + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + if !d.IsNewResource() { + ctx = context.WithValue(ctx, ctxEtag, d.Get("etag").(string)) + } + + if requiredSignedCommit { + log.Printf("[DEBUG] Enabling branch protection signed commit: %s/%s (%s) - $s", orgName, repoName, branch) + _, _, err = client.Repositories.RequireSignaturesOnProtectedBranch(ctx, orgName, repoName, branch) + } else { + log.Printf("[DEBUG] Removing branch protection signed commit: %s/%s (%s) - $s", orgName, repoName, branch) + _, err = client.Repositories.OptionalSignaturesOnProtectedBranch(ctx, orgName, repoName, branch) + } + return err +} + +func flattenAndSetRequiredPullRequestReviews(d *schema.ResourceData, protection *github.Protection) error { + rprr := protection.GetRequiredPullRequestReviews() + if rprr != nil { + var users, teams []interface{} + restrictions := rprr.GetDismissalRestrictions() + + if restrictions != nil { + users = make([]interface{}, 0, len(restrictions.Users)) + for _, u := range restrictions.Users { + if u.Login != nil { + users = append(users, *u.Login) + } + } + teams = make([]interface{}, 0, len(restrictions.Teams)) + for _, t := range restrictions.Teams { + if t.Slug != nil { + teams = append(teams, *t.Slug) + } + } + } + + return d.Set("required_pull_request_reviews", []interface{}{ + map[string]interface{}{ + "dismiss_stale_reviews": rprr.DismissStaleReviews, + "dismissal_users": schema.NewSet(schema.HashString, users), + "dismissal_teams": schema.NewSet(schema.HashString, teams), + "require_code_owner_reviews": rprr.RequireCodeOwnerReviews, + "required_approving_review_count": rprr.RequiredApprovingReviewCount, + }, + }) + } + + return d.Set("required_pull_request_reviews", []interface{}{}) +} + +func flattenAndSetRestrictions(d *schema.ResourceData, protection *github.Protection) error { + restrictions := protection.GetRestrictions() + if restrictions != nil { + users := make([]interface{}, 0, len(restrictions.Users)) + for _, u := range restrictions.Users { + if u.Login != nil { + users = append(users, *u.Login) + } + } + + teams := make([]interface{}, 0, len(restrictions.Teams)) + for _, t := range restrictions.Teams { + if t.Slug != nil { + teams = append(teams, *t.Slug) + } + } + + apps := make([]interface{}, 0, len(restrictions.Apps)) + for _, t := range restrictions.Apps { + if t.Slug != nil { + apps = append(apps, *t.Slug) + } + } + + return d.Set("restrictions", []interface{}{ + map[string]interface{}{ + "users": schema.NewSet(schema.HashString, users), + "teams": schema.NewSet(schema.HashString, teams), + "apps": schema.NewSet(schema.HashString, apps), + }, + }) + } + + return d.Set("restrictions", []interface{}{}) +} + +func expandRequiredStatusChecks(d *schema.ResourceData) (*github.RequiredStatusChecks, error) { + if v, ok := d.GetOk("required_status_checks"); ok { + vL := v.([]interface{}) + if len(vL) > 1 { + return nil, errors.New("cannot specify required_status_checks more than one time") + } + rsc := new(github.RequiredStatusChecks) + + for _, v := range vL { + // List can only have one item, safe to early return here + if v == nil { + return nil, nil + } + m := v.(map[string]interface{}) + rsc.Strict = m["strict"].(bool) + + contexts := expandNestedSet(m, "contexts") + rsc.Contexts = contexts + } + return rsc, nil + } + + return nil, nil +} + +func expandRequiredPullRequestReviews(d *schema.ResourceData) (*github.PullRequestReviewsEnforcementRequest, error) { + if v, ok := d.GetOk("required_pull_request_reviews"); ok { + vL := v.([]interface{}) + if len(vL) > 1 { + return nil, errors.New("cannot specify required_pull_request_reviews more than one time") + } + + rprr := new(github.PullRequestReviewsEnforcementRequest) + drr := new(github.DismissalRestrictionsRequest) + + for _, v := range vL { + // List can only have one item, safe to early return here + if v == nil { + return nil, nil + } + m := v.(map[string]interface{}) + + users := expandNestedSet(m, "dismissal_users") + if len(users) > 0 { + drr.Users = &users + } + teams := expandNestedSet(m, "dismissal_teams") + if len(teams) > 0 { + drr.Teams = &teams + } + + rprr.DismissalRestrictionsRequest = drr + rprr.DismissStaleReviews = m["dismiss_stale_reviews"].(bool) + rprr.RequireCodeOwnerReviews = m["require_code_owner_reviews"].(bool) + rprr.RequiredApprovingReviewCount = m["required_approving_review_count"].(int) + } + + return rprr, nil + } + + return nil, nil +} + +func expandRestrictions(d *schema.ResourceData) (*github.BranchRestrictionsRequest, error) { + if v, ok := d.GetOk("restrictions"); ok { + vL := v.([]interface{}) + if len(vL) > 1 { + return nil, errors.New("cannot specify restrictions more than one time") + } + restrictions := new(github.BranchRestrictionsRequest) + + for _, v := range vL { + // Restrictions only have set attributes nested, need to return nil values for these. + // The API won't initialize these as nil + if v == nil { + restrictions.Users = []string{} + restrictions.Teams = []string{} + restrictions.Apps = []string{} + return restrictions, nil + } + m := v.(map[string]interface{}) + + users := expandNestedSet(m, "users") + restrictions.Users = users + teams := expandNestedSet(m, "teams") + restrictions.Teams = teams + apps := expandNestedSet(m, "apps") + restrictions.Apps = apps + } + return restrictions, nil + } + + return nil, nil +} + +func checkBranchRestrictionsUsers(actual *github.BranchRestrictions, expected *github.BranchRestrictionsRequest) error { + if expected == nil { + return nil + } + + expectedUsers := expected.Users + + if actual == nil { + return fmt.Errorf("unable to add users in restrictions: %s", strings.Join(expectedUsers, ", ")) + } + + actualLoopUp := make(map[string]struct{}, len(actual.Users)) + for _, a := range actual.Users { + actualLoopUp[a.GetLogin()] = struct{}{} + } + + notFounds := make([]string, 0, len(actual.Users)) + + for _, e := range expectedUsers { + if _, ok := actualLoopUp[e]; !ok { + notFounds = append(notFounds, e) + } + } + + if len(notFounds) == 0 { + return nil + } + + return fmt.Errorf("unable to add users in restrictions: %s", strings.Join(notFounds, ", ")) +} diff --git a/go.mod b/go.mod index 39d945478e..90ec723d11 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.13 require ( github.com/client9/misspell v0.3.4 github.com/golangci/golangci-lint v1.25.1 - github.com/google/go-github/v31 v31.0.0 // indirect github.com/google/go-github/v32 v32.1.0 github.com/hashicorp/terraform v0.12.24 github.com/hashicorp/terraform-plugin-sdk v1.7.0 diff --git a/website/docs/r/branch_protection_v3.html.markdown b/website/docs/r/branch_protection_v3.html.markdown new file mode 100644 index 0000000000..c98cf15549 --- /dev/null +++ b/website/docs/r/branch_protection_v3.html.markdown @@ -0,0 +1,115 @@ +--- +layout: "github" +page_title: "GitHub: github_branch_protection_v3" +description: |- + Protects a GitHub branch using the v3 / REST implementation. The `github_branch_protection` resource has moved to the GraphQL API, while this resource will continue to leverage the REST API +--- + +# github\_branch\_protection + +Protects a GitHub branch. + +The `github_branch_protection` resource has moved to the GraphQL API, while this resource will continue to leverage the REST API. + +This resource allows you to configure branch protection for repositories in your organization. When applied, the branch will be protected from forced pushes and deletion. Additional constraints, such as required status checks or restrictions on users, teams, and apps, can also be configured. + +## Example Usage + +```hcl +# Protect the main branch of the foo repository. Only allow a specific user to merge to the branch. +resource " github_branch_protection_v3" "example" { + repository = "${github_repository.example.name}" + branch = "main" + restrictions { + users = ["foo-user"] + } +} +``` + +```hcl +# Protect the main branch of the foo repository. Additionally, require that +# the "ci/travis" context to be passing and only allow the engineers team merge +# to the branch. + +resource " github_branch_protection_v3" "example" { + repository = "${github_repository.example.name}" + branch = "main" + enforce_admins = true + + required_status_checks { + strict = false + contexts = ["ci/travis"] + } + + required_pull_request_reviews { + dismiss_stale_reviews = true + dismissal_users = ["foo-user"] + dismissal_teams = ["${github_team.example.slug}", "${github_team.second.slug}"] + } + + restrictions { + users = ["foo-user"] + teams = ["${github_team.example.slug}"] + apps = ["foo-app"] + } +} + +resource "github_team" "example" { + name = "Example Name" +} + +resource "github_team_repository" "example" { + team_id = "${github_team.example.id}" + repository = "${github_repository.example.name}" + permission = "pull" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `repository` - (Required) The GitHub repository name. +* `branch` - (Required) The Git branch to protect. +* `enforce_admins` - (Optional) Boolean, setting this to `true` enforces status checks for repository administrators. +* `require_signed_commits` - (Optional) Boolean, setting this to `true` requires all commits to be signed with GPG. +* `required_status_checks` - (Optional) Enforce restrictions for required status checks. See [Required Status Checks](#required-status-checks) below for details. +* `required_pull_request_reviews` - (Optional) Enforce restrictions for pull request reviews. See [Required Pull Request Reviews](#required-pull-request-reviews) below for details. +* `restrictions` - (Optional) Enforce restrictions for the users and teams that may push to the branch. See [Restrictions](#restrictions) below for details. + +### Required Status Checks + +`required_status_checks` supports the following arguments: + +* `strict`: (Optional) Require branches to be up to date before merging. Defaults to `false`. +* `contexts`: (Optional) The list of status checks to require in order to merge into this branch. No status checks are required by default. + +### Required Pull Request Reviews + +`required_pull_request_reviews` supports the following arguments: + +* `dismiss_stale_reviews`: (Optional) Dismiss approved reviews automatically when a new commit is pushed. Defaults to `false`. +* `dismissal_users`: (Optional) The list of user logins with dismissal access +* `dismissal_teams`: (Optional) The list of team slugs with dismissal access. + Always use `slug` of the team, **not** its name. Each team already **has** to have access to the repository. +* `require_code_owner_reviews`: (Optional) Require an approved review in pull requests including files with a designated code owner. Defaults to `false`. +* `required_approving_review_count`: (Optional) Require x number of approvals to satisfy branch protection requirements. If this is specified it must be a number between 1-6. This requirement matches Github's API, see the upstream [documentation](https://developer.github.com/v3/repos/branches/#parameters-1) for more information. + +### Restrictions + +`restrictions` supports the following arguments: + +* `users`: (Optional) The list of user logins with push access. +* `teams`: (Optional) The list of team slugs with push access. + Always use `slug` of the team, **not** its name. Each team already **has** to have access to the repository. +* `apps`: (Optional) The list of app slugs with push access. + +`restrictions` is only available for organization-owned repositories. + +## Import + +GitHub Branch Protection can be imported using an ID made up of `repository:branch`, e.g. + +``` +$ terraform import github_branch_protection_v3.terraform terraform:main +``` diff --git a/website/github.erb b/website/github.erb index 235fb61191..f46d0088ac 100644 --- a/website/github.erb +++ b/website/github.erb @@ -70,6 +70,9 @@
  • github_branch_protection
  • +
  • + github_branch_protection_v3 +
  • github_issue_label