diff --git a/github/provider.go b/github/provider.go index 1baf0263ad..e562bc451e 100644 --- a/github/provider.go +++ b/github/provider.go @@ -200,6 +200,7 @@ func Provider() *schema.Provider { "github_repository_milestone": resourceGithubRepositoryMilestone(), "github_repository_project": resourceGithubRepositoryProject(), "github_repository_pull_request": resourceGithubRepositoryPullRequest(), + "github_repository_pull_request_creation_policy": resourceGithubRepositoryPullRequestCreationPolicy(), "github_repository_ruleset": resourceGithubRepositoryRuleset(), "github_repository_topics": resourceGithubRepositoryTopics(), "github_repository_webhook": resourceGithubRepositoryWebhook(), diff --git a/github/resource_github_repository_pull_request_creation_policy.go b/github/resource_github_repository_pull_request_creation_policy.go new file mode 100644 index 0000000000..3b37e0cfc1 --- /dev/null +++ b/github/resource_github_repository_pull_request_creation_policy.go @@ -0,0 +1,120 @@ +package github + +import ( + "context" + "fmt" + + "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 resourceGithubRepositoryPullRequestCreationPolicy() *schema.Resource { + return &schema.Resource{ + Description: "Manages the pull request creation policy for a repository.", + CreateContext: resourceGithubRepositoryPullRequestCreationPolicyCreate, + ReadContext: resourceGithubRepositoryPullRequestCreationPolicyRead, + UpdateContext: resourceGithubRepositoryPullRequestCreationPolicyUpdate, + DeleteContext: resourceGithubRepositoryPullRequestCreationPolicyDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubRepositoryPullRequestCreationPolicyImport, + }, + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The name of the GitHub repository.", + }, + "policy": { + Type: schema.TypeString, + Required: true, + Description: "Controls who can create pull requests for the repository. Can be `all` or `collaborators_only`.", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"all", "collaborators_only"}, false)), + }, + }, + } +} + +func resourceGithubRepositoryPullRequestCreationPolicyCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + repoName := d.Get("repository").(string) + policy := d.Get("policy").(string) + + nodeID, err := getRepositoryID(repoName, meta) + if err != nil { + return diag.Errorf("error resolving repository node ID for %s: %s", repoName, err) + } + + if err := updateRepositoryPullRequestCreationPolicy(ctx, nodeID, policy, meta); err != nil { + return diag.Errorf("error setting pull request creation policy for %s: %s", repoName, err) + } + + d.SetId(repoName) + return resourceGithubRepositoryPullRequestCreationPolicyRead(ctx, d, meta) +} + +func resourceGithubRepositoryPullRequestCreationPolicyRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + owner := meta.(*Owner).name + repoName := d.Id() + + policy, err := getRepositoryPullRequestCreationPolicy(ctx, owner, repoName, meta) + if err != nil { + return diag.Errorf("error reading pull request creation policy for %s/%s: %s", owner, repoName, err) + } + + if err := d.Set("policy", policy); err != nil { + return diag.FromErr(err) + } + if err := d.Set("repository", repoName); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubRepositoryPullRequestCreationPolicyUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + repoName := d.Id() + policy := d.Get("policy").(string) + + nodeID, err := getRepositoryID(repoName, meta) + if err != nil { + return diag.Errorf("error resolving repository node ID for %s: %s", repoName, err) + } + + if err := updateRepositoryPullRequestCreationPolicy(ctx, nodeID, policy, meta); err != nil { + return diag.Errorf("error updating pull request creation policy for %s: %s", repoName, err) + } + + return resourceGithubRepositoryPullRequestCreationPolicyRead(ctx, d, meta) +} + +func resourceGithubRepositoryPullRequestCreationPolicyDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + repoName := d.Id() + + nodeID, err := getRepositoryID(repoName, meta) + if err != nil { + return diag.Errorf("error resolving repository node ID for %s: %s", repoName, err) + } + + if err := updateRepositoryPullRequestCreationPolicy(ctx, nodeID, "all", meta); err != nil { + return diag.Errorf("error resetting pull request creation policy for %s: %s", repoName, err) + } + + return nil +} + +func resourceGithubRepositoryPullRequestCreationPolicyImport(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { + repoName := d.Id() + + if err := d.Set("repository", repoName); err != nil { + return nil, err + } + + diags := resourceGithubRepositoryPullRequestCreationPolicyRead(ctx, d, meta) + if diags.HasError() { + return nil, fmt.Errorf("%s", diags[0].Summary) + } + + return []*schema.ResourceData{d}, nil +} diff --git a/github/resource_github_repository_pull_request_creation_policy_test.go b/github/resource_github_repository_pull_request_creation_policy_test.go new file mode 100644 index 0000000000..ed86001018 --- /dev/null +++ b/github/resource_github_repository_pull_request_creation_policy_test.go @@ -0,0 +1,100 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccGithubRepositoryPullRequestCreationPolicy(t *testing.T) { + t.Run("sets policy without error", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%srepo-pr-policy-%s", testResourcePrefix, randomID) + initial := `policy = "collaborators_only"` + updated := `policy = "all"` + + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "%s" + visibility = "private" + auto_init = true + } + + resource "github_repository_pull_request_creation_policy" "test" { + repository = github_repository.test.name + %%s + } + `, repoName) + + checks := map[string]resource.TestCheckFunc{ + "before": resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_repository_pull_request_creation_policy.test", "policy", + "collaborators_only", + ), + ), + "after": resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_repository_pull_request_creation_policy.test", "policy", + "all", + ), + ), + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, initial), + Check: checks["before"], + }, + { + Config: fmt.Sprintf(config, updated), + Check: checks["after"], + }, + }, + }) + }) + + t.Run("imports without error", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%srepo-pr-policy-%s", testResourcePrefix, randomID) + + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "%s" + visibility = "private" + auto_init = true + } + + resource "github_repository_pull_request_creation_policy" "test" { + repository = github_repository.test.name + policy = "collaborators_only" + } + `, repoName) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_repository_pull_request_creation_policy.test", "repository"), + resource.TestCheckResourceAttr("github_repository_pull_request_creation_policy.test", "policy", "collaborators_only"), + ) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + { + ResourceName: "github_repository_pull_request_creation_policy.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) +} diff --git a/github/util_v4_repository.go b/github/util_v4_repository.go index 4e4fa837f6..b083008391 100644 --- a/github/util_v4_repository.go +++ b/github/util_v4_repository.go @@ -4,10 +4,30 @@ import ( "context" "encoding/base64" "errors" + "fmt" "github.com/shurcooL/githubv4" ) +// PullRequestCreationPolicy mirrors the GitHub GraphQL enum type of the same +// name so we can query and mutate the field even when the vendored client +// model lags behind the live schema. +type PullRequestCreationPolicy string + +const ( + PullRequestCreationPolicyAll PullRequestCreationPolicy = "ALL" + PullRequestCreationPolicyCollaboratorsOnly PullRequestCreationPolicy = "COLLABORATORS_ONLY" +) + +// UpdateRepositoryInput intentionally mirrors the GitHub GraphQL input type +// name so the graphql client emits the correct variable type in mutations. +// We only model the fields needed for pullRequestCreationPolicy updates. +type UpdateRepositoryInput struct { + RepositoryID githubv4.ID `json:"repositoryId"` + PullRequestCreationPolicy *PullRequestCreationPolicy `json:"pullRequestCreationPolicy,omitempty"` + ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"` +} + func getRepositoryID(name string, meta any) (githubv4.ID, error) { // Interpret `name` as a node ID exists, nodeIDerr := repositoryNodeIDExists(name, meta) @@ -65,6 +85,73 @@ func repositoryNodeIDExists(name string, meta any) (bool, error) { return query.Node.ID.(string) == name, nil } +func flattenPullRequestCreationPolicy(policy PullRequestCreationPolicy) (string, error) { + switch policy { + case PullRequestCreationPolicyAll: + return "all", nil + case PullRequestCreationPolicyCollaboratorsOnly: + return "collaborators_only", nil + case "": + return "", nil + default: + return "", fmt.Errorf("unsupported GraphQL pull request creation policy %q", policy) + } +} + +func expandPullRequestCreationPolicy(policy string) (PullRequestCreationPolicy, error) { + switch policy { + case "all": + return PullRequestCreationPolicyAll, nil + case "collaborators_only": + return PullRequestCreationPolicyCollaboratorsOnly, nil + default: + return "", fmt.Errorf("unsupported Terraform pull request creation policy %q", policy) + } +} + +func getRepositoryPullRequestCreationPolicy(ctx context.Context, owner, name string, meta any) (string, error) { + var query struct { + Repository struct { + PullRequestCreationPolicy PullRequestCreationPolicy + } `graphql:"repository(owner:$owner, name:$name)"` + } + + variables := map[string]any{ + "owner": githubv4.String(owner), + "name": githubv4.String(name), + } + + client := meta.(*Owner).v4client + if err := client.Query(ctx, &query, variables); err != nil { + return "", err + } + + return flattenPullRequestCreationPolicy(query.Repository.PullRequestCreationPolicy) +} + +func updateRepositoryPullRequestCreationPolicy(ctx context.Context, repositoryID githubv4.ID, policy string, meta any) error { + expandedPolicy, err := expandPullRequestCreationPolicy(policy) + if err != nil { + return err + } + + input := UpdateRepositoryInput{ + RepositoryID: repositoryID, + PullRequestCreationPolicy: &expandedPolicy, + } + + var mutation struct { + UpdateRepository struct { + Repository struct { + ID githubv4.ID + } + } `graphql:"updateRepository(input:$input)"` + } + + client := meta.(*Owner).v4client + return client.Mutate(ctx, &mutation, input, nil) +} + // Maintain compatibility with deprecated Global ID format // https://github.blog/2021-02-10-new-global-id-format-coming-to-graphql/ func repositoryLegacyNodeIDExists(name string, meta any) (bool, error) { diff --git a/github/util_v4_repository_test.go b/github/util_v4_repository_test.go index 7e0a1daf40..2c04e6132f 100644 --- a/github/util_v4_repository_test.go +++ b/github/util_v4_repository_test.go @@ -2,6 +2,7 @@ package github import ( "bytes" + "context" "io" "net/http" "net/http/httptest" @@ -191,6 +192,106 @@ func TestGetRepositoryIDPositiveMatches(t *testing.T) { } } +func TestPullRequestCreationPolicyMapping(t *testing.T) { + t.Run("flatten GraphQL values", func(t *testing.T) { + cases := []struct { + name string + input PullRequestCreationPolicy + want string + wantErr bool + }{ + {name: "all", input: PullRequestCreationPolicyAll, want: "all"}, + {name: "collaborators_only", input: PullRequestCreationPolicyCollaboratorsOnly, want: "collaborators_only"}, + {name: "empty", input: "", want: ""}, + {name: "invalid", input: PullRequestCreationPolicy("NOPE"), wantErr: true}, + } + + for _, tc := range cases { + got, err := flattenPullRequestCreationPolicy(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("%s: expected error, got nil", tc.name) + } + continue + } + if err != nil { + t.Fatalf("%s: unexpected error: %v", tc.name, err) + } + if got != tc.want { + t.Fatalf("%s: got %q want %q", tc.name, got, tc.want) + } + } + }) + + t.Run("expand Terraform values", func(t *testing.T) { + cases := []struct { + name string + input string + want PullRequestCreationPolicy + wantErr bool + }{ + {name: "all", input: "all", want: PullRequestCreationPolicyAll}, + {name: "collaborators_only", input: "collaborators_only", want: PullRequestCreationPolicyCollaboratorsOnly}, + {name: "invalid", input: "everyone", wantErr: true}, + } + + for _, tc := range cases { + got, err := expandPullRequestCreationPolicy(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("%s: expected error, got nil", tc.name) + } + continue + } + if err != nil { + t.Fatalf("%s: unexpected error: %v", tc.name, err) + } + if got != tc.want { + t.Fatalf("%s: got %q want %q", tc.name, got, tc.want) + } + } + }) +} + +func TestRepositoryPullRequestCreationPolicyGraphQL(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) { + body := mustRead(req.Body) + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.Contains(body, "repository(owner:$owner, name:$name){pullRequestCreationPolicy}"): + if !strings.Contains(body, `"owner":"integrations"`) { + t.Fatalf("expected resolved owner in GraphQL body, got %s", body) + } + mustWrite(w, `{"data":{"repository":{"pullRequestCreationPolicy":"COLLABORATORS_ONLY"}}}`) + case strings.Contains(body, "mutation($input:UpdateRepositoryInput!){updateRepository(input:$input){repository{id}}}"): + mustWrite(w, `{"data":{"updateRepository":{"repository":{"id":"R_kgDOGGmaaw"}}}}`) + default: + t.Fatalf("unexpected GraphQL body: %s", body) + } + }) + + meta := Owner{ + v4client: githubv4.NewClient(&http.Client{Transport: localRoundTripper{handler: mux}}), + name: "integrations", + } + + ctx := context.Background() + + got, err := getRepositoryPullRequestCreationPolicy(ctx, "integrations", "terraform-provider-github", &meta) + if err != nil { + t.Fatalf("unexpected read error: %v", err) + } + if got != "collaborators_only" { + t.Fatalf("got %q want %q", got, "collaborators_only") + } + + if err := updateRepositoryPullRequestCreationPolicy(ctx, githubv4.ID("R_kgDOGGmaaw"), "all", &meta); err != nil { + t.Fatalf("unexpected update error: %v", err) + } +} + // localRoundTripper is an http.RoundTripper that executes HTTP transactions // by using handler directly, instead of going over an HTTP connection. type localRoundTripper struct { diff --git a/website/docs/r/repository_pull_request_creation_policy.html.markdown b/website/docs/r/repository_pull_request_creation_policy.html.markdown new file mode 100644 index 0000000000..4c2856fa64 --- /dev/null +++ b/website/docs/r/repository_pull_request_creation_policy.html.markdown @@ -0,0 +1,40 @@ +--- +layout: "github" +page_title: "GitHub: github_repository_pull_request_creation_policy" +description: |- + Manages the pull request creation policy for a repository +--- + +# github_repository_pull_request_creation_policy + +This resource allows you to manage the pull request creation policy for a repository. The policy controls who is allowed to create pull requests. + +## Example Usage + +```hcl +resource "github_repository" "example" { + name = "example-repo" + visibility = "private" +} + +resource "github_repository_pull_request_creation_policy" "example" { + repository = github_repository.example.name + policy = "collaborators_only" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `repository` - (Required) The name of the GitHub repository. Changing this forces a new resource. + +* `policy` - (Required) Controls who can create pull requests in the repository. Supported values are `all` and `collaborators_only`. + +## Import + +The pull request creation policy can be imported using the repository name. + +```sh +terraform import github_repository_pull_request_creation_policy.example my-repo +```