diff --git a/RESOURCES.md b/RESOURCES.md index b1b1c3e2b0..494a7435c9 100644 --- a/RESOURCES.md +++ b/RESOURCES.md @@ -193,6 +193,7 @@ The overall status of each resource or data source is captured in this document | `github_repository_deployment_branch_policy` (🚫) | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_repository_environment` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_repository_environment_deployment_policy` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | +| `github_repository_environment_deployment_protection_rule` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_repository_file` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_repository_milestone` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_repository_pages` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | diff --git a/docs/resources/repository_environment_deployment_protection_rule.md b/docs/resources/repository_environment_deployment_protection_rule.md new file mode 100644 index 0000000000..5a18abb13e --- /dev/null +++ b/docs/resources/repository_environment_deployment_protection_rule.md @@ -0,0 +1,68 @@ +--- +page_title: "github_repository_environment_deployment_protection_rule (Resource) - GitHub" +description: |- + Creates and manages custom deployment protection rules for GitHub repository environments +--- + +# github_repository_environment_deployment_protection_rule (Resource) + +Manages a custom deployment protection rule for a GitHub repository environment. A custom deployment protection rule is a GitHub App that approves or rejects deployments to the environment. + +When a workflow job targets the environment, GitHub sends the App a `deployment_protection_rule` webhook and waits for the App to approve or reject the deployment. The App must be installed on the repository and available as a custom deployment protection rule for the environment before it can be enabled. + +## Example Usage + +```terraform +resource "github_repository" "example" { + name = "example" +} + +resource "github_repository_environment" "example" { + repository = github_repository.example.name + environment = "production" +} + +resource "github_repository_environment_deployment_protection_rule" "example" { + repository = github_repository.example.name + environment = github_repository_environment.example.environment + integration_id = 123456 # The ID of the GitHub App +} +``` + +## Argument Reference + +The following arguments are supported: + +- `repository` - (Required) The name of the GitHub repository. + +- `environment` - (Required) The name of the environment. Changing this forces a new resource. + +- `integration_id` - (Required) The ID of the GitHub App to enable as a custom deployment protection rule. Changing this forces a new resource. + +## Attributes Reference + +- `repository_id` - The ID of the repository. +- `protection_rule_id` - The ID of the custom deployment protection rule. + +## Import + +This resource can be imported using an ID made of the repository name, environment name (any `:` in the environment name needs to be escaped as `??`), and custom deployment protection rule ID, all separated by a `:`. + +### Import Block + +The following import block imports a custom deployment protection rule with the ID `123456` for the repo `myrepo` and environment `myenv` to a `github_repository_environment_deployment_protection_rule` resource named `example`. + +```terraform +import { + to = github_repository_environment_deployment_protection_rule.example + id = "myrepo:myenv:123456" +} +``` + +### Import Command + +The following command imports a custom deployment protection rule with the ID `123456` for the repo `myrepo` and environment `myenv` to a `github_repository_environment_deployment_protection_rule` resource named `example`. + +```shell +terraform import github_repository_environment_deployment_protection_rule.example myrepo:myenv:123456 +``` diff --git a/examples/resources/repository_environment_deployment_protection_rule/example_1.tf b/examples/resources/repository_environment_deployment_protection_rule/example_1.tf new file mode 100644 index 0000000000..3d51fc66bf --- /dev/null +++ b/examples/resources/repository_environment_deployment_protection_rule/example_1.tf @@ -0,0 +1,14 @@ +resource "github_repository" "example" { + name = "example" +} + +resource "github_repository_environment" "example" { + repository = github_repository.example.name + environment = "production" +} + +resource "github_repository_environment_deployment_protection_rule" "example" { + repository = github_repository.example.name + environment = github_repository_environment.example.environment + integration_id = 123456 # The ID of the GitHub App +} diff --git a/examples/resources/repository_environment_deployment_protection_rule/example_2.tf b/examples/resources/repository_environment_deployment_protection_rule/example_2.tf new file mode 100644 index 0000000000..5470683b24 --- /dev/null +++ b/examples/resources/repository_environment_deployment_protection_rule/example_2.tf @@ -0,0 +1,4 @@ +import { + to = github_repository_environment_deployment_protection_rule.example + id = "myrepo:myenv:123456" +} diff --git a/github/provider.go b/github/provider.go index c391d8454c..154c88fa21 100644 --- a/github/provider.go +++ b/github/provider.go @@ -235,6 +235,7 @@ func NewProvider(version, commit string) func() *schema.Provider { "github_repository_deployment_branch_policy": resourceGithubRepositoryDeploymentBranchPolicy(), "github_repository_environment": resourceGithubRepositoryEnvironment(), "github_repository_environment_deployment_policy": resourceGithubRepositoryEnvironmentDeploymentPolicy(), + "github_repository_environment_deployment_protection_rule": resourceGithubRepositoryEnvironmentDeploymentProtectionRule(), "github_repository_file": resourceGithubRepositoryFile(), "github_repository_milestone": resourceGithubRepositoryMilestone(), "github_repository_pages": resourceGithubRepositoryPages(), diff --git a/github/resource_github_repository_environment_deployment_protection_rule.go b/github/resource_github_repository_environment_deployment_protection_rule.go new file mode 100644 index 0000000000..0804e0984d --- /dev/null +++ b/github/resource_github_repository_environment_deployment_protection_rule.go @@ -0,0 +1,215 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/google/go-github/v92/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceGithubRepositoryEnvironmentDeploymentProtectionRule() *schema.Resource { + return &schema.Resource{ + Description: "Manages a custom deployment protection rule for a GitHub repository environment. A custom deployment protection rule is a GitHub App that approves or rejects deployments to the environment.", + + Schema: map[string]*schema.Schema{ + "repository": { + Description: "The name of the GitHub repository.", + Type: schema.TypeString, + Required: true, + }, + "repository_id": { + Description: "The ID of the GitHub repository.", + Type: schema.TypeInt, + Computed: true, + }, + "environment": { + Description: "The name of the environment.", + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "integration_id": { + Description: "The ID of the GitHub App to enable as a custom deployment protection rule. The App must be installed on the repository and available as a custom deployment protection rule for the environment.", + Type: schema.TypeInt, + Required: true, + ForceNew: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.IntAtLeast(1)), + }, + "protection_rule_id": { + Description: "The ID of the custom deployment protection rule.", + Type: schema.TypeInt, + Computed: true, + }, + }, + + CustomizeDiff: diffRepository, + + CreateContext: resourceGithubRepositoryEnvironmentDeploymentProtectionRuleCreate, + ReadContext: resourceGithubRepositoryEnvironmentDeploymentProtectionRuleRead, + UpdateContext: resourceGithubRepositoryEnvironmentDeploymentProtectionRuleUpdate, + DeleteContext: resourceGithubRepositoryEnvironmentDeploymentProtectionRuleDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubRepositoryEnvironmentDeploymentProtectionRuleImport, + }, + } +} + +func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName, _ := d.Get("repository").(string) + envName, _ := d.Get("environment").(string) + integrationID, _ := d.Get("integration_id").(int) + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return diag.FromErr(err) + } + + rule, _, err := client.Repositories.CreateCustomDeploymentProtectionRule(ctx, owner, repoName, envName, &github.CustomDeploymentProtectionRuleRequest{ + IntegrationID: new(int64(integrationID)), + }) + if err != nil { + return diag.FromErr(err) + } + + id, err := buildID(repoName, escapeIDPart(envName), strconv.FormatInt(rule.GetID(), 10)) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + + if err := d.Set("repository_id", int(repo.GetID())); err != nil { + return diag.FromErr(err) + } + if err := d.Set("protection_rule_id", int(rule.GetID())); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + ctx = tflog.SetField(ctx, "id", d.Id()) + + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName, _ := d.Get("repository").(string) + envName, _ := d.Get("environment").(string) + ruleID, _ := d.Get("protection_rule_id").(int) + + rule, _, err := client.Repositories.GetCustomDeploymentProtectionRule(ctx, owner, repoName, envName, int64(ruleID)) + if err != nil { + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Custom deployment protection rule not found, removing from state.", map[string]any{"repository": repoName, "environment": envName, "protection_rule_id": ruleID}) + d.SetId("") + return nil + } + return diag.FromErr(err) + } + + if err := d.Set("integration_id", int(rule.GetApp().GetID())); err != nil { + return diag.FromErr(err) + } + + return nil +} + +// Update only runs when repository changes without diffRepository forcing a new resource. That is a rename, or a +// repository diffRepository could not look up at plan time, so check the new name is still the same repository. +func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName, _ := d.Get("repository").(string) + envName, _ := d.Get("environment").(string) + ruleID, _ := d.Get("protection_rule_id").(int) + repoID, _ := d.Get("repository_id").(int) + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return diag.FromErr(err) + } + if repo.GetID() != int64(repoID) { + return diag.Errorf("repository %q (ID %d) is not the repository this protection rule was created in (ID %d); replace the resource to move the rule to another repository", repoName, repo.GetID(), repoID) + } + + id, err := buildID(repoName, escapeIDPart(envName), strconv.Itoa(ruleID)) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + + return nil +} + +func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + ctx = tflog.SetField(ctx, "id", d.Id()) + + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName, _ := d.Get("repository").(string) + envName, _ := d.Get("environment").(string) + ruleID, _ := d.Get("protection_rule_id").(int) + + _, err := client.Repositories.DisableCustomDeploymentProtectionRule(ctx, owner, repoName, envName, int64(ruleID)) + if err != nil { + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Custom deployment protection rule not found, treating it as deleted.", map[string]any{"repository": repoName, "environment": envName, "protection_rule_id": ruleID}) + return nil + } + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubRepositoryEnvironmentDeploymentProtectionRuleImport(ctx context.Context, d *schema.ResourceData, m any) ([]*schema.ResourceData, error) { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName, envNamePart, ruleIDStr, err := parseID3(d.Id()) + if err != nil { + return nil, fmt.Errorf("invalid id (%s), expected format ::", d.Id()) + } + + ruleID, err := strconv.Atoi(ruleIDStr) + if err != nil { + return nil, fmt.Errorf("invalid protection rule id: %s", ruleIDStr) + } + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return nil, fmt.Errorf("failed to retrieve repository %s: %w", repoName, err) + } + + if err := d.Set("repository", repoName); err != nil { + return nil, err + } + if err := d.Set("repository_id", int(repo.GetID())); err != nil { + return nil, err + } + if err := d.Set("environment", unescapeIDPart(envNamePart)); err != nil { + return nil, err + } + if err := d.Set("protection_rule_id", ruleID); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} diff --git a/github/resource_github_repository_environment_deployment_protection_rule_test.go b/github/resource_github_repository_environment_deployment_protection_rule_test.go new file mode 100644 index 0000000000..758f4d75bb --- /dev/null +++ b/github/resource_github_repository_environment_deployment_protection_rule_test.go @@ -0,0 +1,385 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "testing" + + "github.com/google/go-github/v92/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccGithubRepositoryEnvironmentDeploymentProtectionRule(t *testing.T) { + t.Parallel() + + skipUnauthenticated(t) + + config := ` +resource "github_repository_environment_deployment_protection_rule" "test" { + repository = "%s" + environment = "%s" + integration_id = %d +} +` + + t.Run("creates_and_deletes", func(t *testing.T) { + t.Parallel() + + repo := mustCreateTestRepository(t) + env := mustCreateTestRepositoryEnvironment(t, repo, withTestCreateName("environment/test")) + appID := mustFindDeploymentProtectionRuleApp(t, repo, env) + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGithubRepositoryEnvironmentDeploymentProtectionRuleDestroy(repo.GetName(), env.GetName()), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repo.GetName(), env.GetName(), appID), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_repository_environment_deployment_protection_rule.test", tfjsonpath.New("repository_id"), knownvalue.Int64Exact(repo.GetID())), + statecheck.ExpectKnownValue("github_repository_environment_deployment_protection_rule.test", tfjsonpath.New("protection_rule_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_repository_environment_deployment_protection_rule.test", tfjsonpath.New("integration_id"), knownvalue.Int64Exact(appID)), + }, + }, + }, + }) + }) + + t.Run("imports", func(t *testing.T) { + t.Parallel() + + repo := mustCreateTestRepository(t) + env := mustCreateTestRepositoryEnvironment(t, repo) + appID := mustFindDeploymentProtectionRuleApp(t, repo, env) + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repo.GetName(), env.GetName(), appID), + }, + { + ResourceName: "github_repository_environment_deployment_protection_rule.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) + + t.Run("updates_renamed_repo", func(t *testing.T) { + t.Parallel() + + repo := mustCreateTestRepository(t) + env := mustCreateTestRepositoryEnvironment(t, repo) + newRepoName := fmt.Sprintf("%s-updated", repo.GetName()) + appID := mustFindDeploymentProtectionRuleApp(t, repo, env) + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repo.GetName(), env.GetName(), appID), + }, + { + PreConfig: func() { + mustRenameTestRepository(t, repo, newRepoName) + }, + Config: fmt.Sprintf(config, newRepoName, env.GetName(), appID), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("github_repository_environment_deployment_protection_rule.test", plancheck.ResourceActionUpdate), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_repository_environment_deployment_protection_rule.test", tfjsonpath.New("repository_id"), knownvalue.Int64Exact(repo.GetID())), + }, + }, + }, + }) + }) + + t.Run("recreates_changed_repo", func(t *testing.T) { + t.Parallel() + + repo := mustCreateTestRepository(t) + env := mustCreateTestRepositoryEnvironment(t, repo) + repo2 := mustCreateTestRepository(t) + _ = mustCreateTestRepositoryEnvironment(t, repo2, withTestCreateName(env.GetName())) + appID := mustFindDeploymentProtectionRuleApp(t, repo, env) + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, repo.GetName(), env.GetName(), appID), + }, + { + Config: fmt.Sprintf(config, repo2.GetName(), env.GetName(), appID), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("github_repository_environment_deployment_protection_rule.test", plancheck.ResourceActionReplace), + }, + }, + }, + }, + }) + }) +} + +// mustFindDeploymentProtectionRuleApp returns the first app the test owner can use as a custom deployment +// protection rule on the environment, and skips the test when there is none. +func mustFindDeploymentProtectionRuleApp(t *testing.T, repo *github.Repository, env *github.Environment) int64 { + t.Helper() + + meta := testAccConf.meta + res, _, err := meta.v3client.Repositories.ListCustomDeploymentRuleIntegrations(t.Context(), meta.name, repo.GetName(), env.GetName(), nil) + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusForbidden { + t.Skip("Skipping as the test credentials cannot list custom deployment protection rule apps") + } + if err != nil { + t.Fatalf("failed to list custom deployment protection rule integrations: %v", err) + } + if len(res.AvailableIntegrations) == 0 { + t.Skip("Skipping as no app is available as a custom deployment protection rule") + } + + return res.AvailableIntegrations[0].GetID() +} + +func testAccCheckGithubRepositoryEnvironmentDeploymentProtectionRuleDestroy(repoName, envName string) resource.TestCheckFunc { + return func(_ *terraform.State) error { + meta := testAccConf.meta + rules, _, err := meta.v3client.Repositories.GetAllDeploymentProtectionRules(context.Background(), meta.name, repoName, envName) + if err != nil { + return err + } + if n := len(rules.ProtectionRules); n != 0 { + return fmt.Errorf("expected no custom deployment protection rules on %s/%s, found %d", repoName, envName, n) + } + return nil + } +} + +func Test_resourceGithubRepositoryEnvironmentDeploymentProtectionRuleRead(t *testing.T) { + t.Parallel() + + const ( + owner = "test-org" + repoName = "test-repo" + envName = "environment/test" + ruleID = 42 + ) + + ruleURI := fmt.Sprintf("/repos/%s/%s/environments/environment%%2Ftest/deployment_protection_rules/%d", owner, repoName, ruleID) + + for _, tt := range []struct { + name string + status int + body any + wantErr bool + wantRemoved bool + wantIntegrationID int + }{ + { + name: "sets integration_id from the rule's app", + status: http.StatusOK, + body: &github.CustomDeploymentProtectionRule{ID: new(int64(ruleID)), App: &github.CustomDeploymentProtectionRuleApp{ID: new(int64(1234))}}, + wantIntegrationID: 1234, + }, + { + name: "replaces a stale integration_id when the rule has no app", + status: http.StatusOK, + body: &github.CustomDeploymentProtectionRule{ID: new(int64(ruleID))}, + wantIntegrationID: 0, + }, + { + name: "removes the resource from state on 404", + status: http.StatusNotFound, + wantRemoved: true, + }, + { + name: "returns other errors", + status: http.StatusInternalServerError, + wantErr: true, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := githubApiMock([]*mockResponse{ + mustGetTestMockResponse(t, ruleURI, tt.status, tt.body), + }) + t.Cleanup(ts.Close) + + d := schema.TestResourceDataRaw(t, resourceGithubRepositoryEnvironmentDeploymentProtectionRule().Schema, map[string]any{ + "repository": repoName, + "environment": envName, + "integration_id": 9999, + "protection_rule_id": ruleID, + }) + d.SetId(fmt.Sprintf("%s:%s:%d", repoName, envName, ruleID)) + + meta := &Owner{name: owner, v3client: mustCreateTestGitHubClient(t, ts.URL)} + + diags := resourceGithubRepositoryEnvironmentDeploymentProtectionRuleRead(t.Context(), d, meta) + + if diags.HasError() != tt.wantErr { + t.Fatalf("expected error to be %v, got %v", tt.wantErr, diags) + } + if tt.wantErr { + return + } + if removed := d.Id() == ""; removed != tt.wantRemoved { + t.Fatalf("expected removed from state to be %v, got %v", tt.wantRemoved, removed) + } + if tt.wantRemoved { + return + } + if got, _ := d.Get("integration_id").(int); got != tt.wantIntegrationID { + t.Errorf("expected integration_id to be %d, got %d", tt.wantIntegrationID, got) + } + }) + } +} + +func Test_resourceGithubRepositoryEnvironmentDeploymentProtectionRuleDelete(t *testing.T) { + t.Parallel() + + const ( + owner = "test-org" + repoName = "test-repo" + envName = "environment/test" + ruleID = 42 + ) + + ruleURI := fmt.Sprintf("/repos/%s/%s/environments/environment%%2Ftest/deployment_protection_rules/%d", owner, repoName, ruleID) + + for _, tt := range []struct { + name string + status int + wantErr bool + }{ + { + name: "disables the rule", + status: http.StatusNoContent, + }, + { + name: "treats 404 as already deleted", + status: http.StatusNotFound, + }, + { + name: "returns other errors", + status: http.StatusInternalServerError, + wantErr: true, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + resp := mustGetTestMockResponse(t, ruleURI, tt.status, nil) + resp.ExpectedMethod = http.MethodDelete + ts := githubApiMock([]*mockResponse{resp}) + t.Cleanup(ts.Close) + + d := schema.TestResourceDataRaw(t, resourceGithubRepositoryEnvironmentDeploymentProtectionRule().Schema, map[string]any{ + "repository": repoName, + "environment": envName, + "integration_id": 1234, + "protection_rule_id": ruleID, + }) + d.SetId(fmt.Sprintf("%s:%s:%d", repoName, envName, ruleID)) + + meta := &Owner{name: owner, v3client: mustCreateTestGitHubClient(t, ts.URL)} + + diags := resourceGithubRepositoryEnvironmentDeploymentProtectionRuleDelete(t.Context(), d, meta) + + if diags.HasError() != tt.wantErr { + t.Fatalf("expected error to be %v, got %v", tt.wantErr, diags) + } + }) + } +} + +func Test_resourceGithubRepositoryEnvironmentDeploymentProtectionRuleUpdate(t *testing.T) { + t.Parallel() + + const ( + owner = "test-org" + oldRepoName = "test-repo" + newRepoName = "test-repo-renamed" + envName = "environment/test" + repoID = 100 + ruleID = 42 + ) + + repoURI := fmt.Sprintf("/repos/%s/%s", owner, newRepoName) + + for _, tt := range []struct { + name string + status int + body any + wantErr bool + }{ + { + name: "updates the ID after a rename", + status: http.StatusOK, + body: &github.Repository{ID: new(int64(repoID)), Name: new(newRepoName)}, + }, + { + name: "errors when the name now belongs to another repository", + status: http.StatusOK, + body: &github.Repository{ID: new(int64(repoID + 1)), Name: new(newRepoName)}, + wantErr: true, + }, + { + name: "errors when the repository does not exist", + status: http.StatusNotFound, + wantErr: true, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := githubApiMock([]*mockResponse{ + mustGetTestMockResponse(t, repoURI, tt.status, tt.body), + }) + t.Cleanup(ts.Close) + + d := schema.TestResourceDataRaw(t, resourceGithubRepositoryEnvironmentDeploymentProtectionRule().Schema, map[string]any{ + "repository": newRepoName, + "repository_id": repoID, + "environment": envName, + "integration_id": 1234, + "protection_rule_id": ruleID, + }) + oldID := fmt.Sprintf("%s:%s:%d", oldRepoName, envName, ruleID) + d.SetId(oldID) + + meta := &Owner{name: owner, v3client: mustCreateTestGitHubClient(t, ts.URL)} + + diags := resourceGithubRepositoryEnvironmentDeploymentProtectionRuleUpdate(t.Context(), d, meta) + + if diags.HasError() != tt.wantErr { + t.Fatalf("expected error to be %v, got %v", tt.wantErr, diags) + } + if tt.wantErr { + if d.Id() != oldID { + t.Errorf("expected ID to stay %q on error, got %q", oldID, d.Id()) + } + return + } + if want := fmt.Sprintf("%s:%s:%d", newRepoName, escapeIDPart(envName), ruleID); d.Id() != want { + t.Errorf("expected ID to be %q, got %q", want, d.Id()) + } + }) + } +} diff --git a/templates/resources/repository_environment_deployment_protection_rule.md.tmpl b/templates/resources/repository_environment_deployment_protection_rule.md.tmpl new file mode 100644 index 0000000000..c5fa14629b --- /dev/null +++ b/templates/resources/repository_environment_deployment_protection_rule.md.tmpl @@ -0,0 +1,48 @@ +--- +page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +description: |- + Creates and manages custom deployment protection rules for GitHub repository environments +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +When a workflow job targets the environment, GitHub sends the App a `deployment_protection_rule` webhook and waits for the App to approve or reject the deployment. The App must be installed on the repository and available as a custom deployment protection rule for the environment before it can be enabled. + +## Example Usage + +{{ tffile "examples/resources/repository_environment_deployment_protection_rule/example_1.tf" }} + +## Argument Reference + +The following arguments are supported: + +- `repository` - (Required) The name of the GitHub repository. + +- `environment` - (Required) The name of the environment. Changing this forces a new resource. + +- `integration_id` - (Required) The ID of the GitHub App to enable as a custom deployment protection rule. Changing this forces a new resource. + +## Attributes Reference + +- `repository_id` - The ID of the repository. +- `protection_rule_id` - The ID of the custom deployment protection rule. + +## Import + +This resource can be imported using an ID made of the repository name, environment name (any `:` in the environment name needs to be escaped as `??`), and custom deployment protection rule ID, all separated by a `:`. + +### Import Block + +The following import block imports a custom deployment protection rule with the ID `123456` for the repo `myrepo` and environment `myenv` to a `github_repository_environment_deployment_protection_rule` resource named `example`. + +{{tffile "examples/resources/repository_environment_deployment_protection_rule/example_2.tf"}} + +### Import Command + +The following command imports a custom deployment protection rule with the ID `123456` for the repo `myrepo` and environment `myenv` to a `github_repository_environment_deployment_protection_rule` resource named `example`. + +```shell +terraform import github_repository_environment_deployment_protection_rule.example myrepo:myenv:123456 +```