Skip to content

Commit 6f14748

Browse files
committed
Add pull_request_creation_policy to github_repository
1 parent e72c809 commit 6f14748

5 files changed

Lines changed: 313 additions & 0 deletions

File tree

github/resource_github_repository.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,13 @@ func resourceGithubRepository() *schema.Resource {
284284
Default: false,
285285
Description: "Automatically delete head branch after a pull request is merged. Defaults to 'false'.",
286286
},
287+
"pull_request_creation_policy": {
288+
Type: schema.TypeString,
289+
Optional: true,
290+
Computed: true,
291+
ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"all", "collaborators_only"}, false)),
292+
Description: "Controls who can create pull requests in the repository. Can be `all` or `collaborators_only`.",
293+
},
287294
"web_commit_signoff_required": {
288295
Type: schema.TypeBool,
289296
Optional: true,
@@ -870,6 +877,19 @@ func resourceGithubRepositoryRead(ctx context.Context, d *schema.ResourceData, m
870877
}
871878
}
872879

880+
pullRequestCreationPolicy, err := getRepositoryPullRequestCreationPolicy(ctx, owner, repoName, meta)
881+
if err != nil {
882+
if isUnsupportedPullRequestCreationPolicyError(err) {
883+
log.Printf("[DEBUG] Skipping pull_request_creation_policy read for %s/%s: %s", owner, repoName, err)
884+
} else {
885+
return diag.Errorf("error reading repository pull request creation policy: %s", err.Error())
886+
}
887+
} else {
888+
if err = d.Set("pull_request_creation_policy", pullRequestCreationPolicy); err != nil {
889+
return diag.FromErr(err)
890+
}
891+
}
892+
873893
// Set fork information if this is a fork
874894
if repo.GetFork() {
875895
_ = d.Set("fork", "true")
@@ -995,6 +1015,19 @@ func resourceGithubRepositoryUpdate(ctx context.Context, d *schema.ResourceData,
9951015
}
9961016
}
9971017

1018+
if d.IsNewResource() || d.HasChange("pull_request_creation_policy") {
1019+
if v, ok := d.GetOk("pull_request_creation_policy"); ok {
1020+
repositoryID, err := getRepositoryID(repo.GetName(), meta)
1021+
if err != nil {
1022+
return diag.Errorf("error resolving repository id for pull request creation policy update: %s", err.Error())
1023+
}
1024+
1025+
if err := updateRepositoryPullRequestCreationPolicy(ctx, repositoryID, v.(string), meta); err != nil {
1026+
return diag.Errorf("error updating repository pull request creation policy: %s", err.Error())
1027+
}
1028+
}
1029+
}
1030+
9981031
if d.IsNewResource() || d.HasChange("vulnerability_alerts") {
9991032
if v, ok := d.GetOkExists("vulnerability_alerts"); ok { //nolint:staticcheck // SA1019 // We sometimes need to use GetOkExists for booleans
10001033
if val, ok := v.(bool); ok {

github/resource_github_repository_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,6 +1334,43 @@ resource "github_repository" "test" {
13341334
})
13351335
})
13361336

1337+
t.Run("check_pull_request_creation_policy", func(t *testing.T) {
1338+
randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)
1339+
testRepoName := fmt.Sprintf("%spr-policy-%s", testResourcePrefix, randomID)
1340+
config := `
1341+
resource "github_repository" "test" {
1342+
name = "%s"
1343+
visibility = "%s"
1344+
pull_request_creation_policy = "%s"
1345+
}
1346+
`
1347+
1348+
resource.Test(t, resource.TestCase{
1349+
PreCheck: func() { skipUnauthenticated(t) },
1350+
ProviderFactories: providerFactories,
1351+
Steps: []resource.TestStep{
1352+
{
1353+
Config: fmt.Sprintf(config, testRepoName, testAccConf.testRepositoryVisibility, "collaborators_only"),
1354+
Check: resource.ComposeTestCheckFunc(
1355+
resource.TestCheckResourceAttr("github_repository.test", "pull_request_creation_policy", "collaborators_only"),
1356+
),
1357+
},
1358+
{
1359+
Config: fmt.Sprintf(config, testRepoName, testAccConf.testRepositoryVisibility, "all"),
1360+
Check: resource.ComposeTestCheckFunc(
1361+
resource.TestCheckResourceAttr("github_repository.test", "pull_request_creation_policy", "all"),
1362+
),
1363+
},
1364+
{
1365+
ResourceName: "github_repository.test",
1366+
ImportState: true,
1367+
ImportStateVerify: true,
1368+
ImportStateVerifyIgnore: []string{"auto_init", "vulnerability_alerts", "ignore_vulnerability_alerts_during_read"},
1369+
},
1370+
},
1371+
})
1372+
})
1373+
13371374
t.Run("check_web_commit_signoff_required_organization_enabled_but_not_set", func(t *testing.T) {
13381375
t.Skip("This test should be run manually after confirming that the test organization has 'Require contributors to sign off on web-based commits' enabled under Organizations -> Settings -> Repository -> Repository defaults.")
13391376

github/util_v4_repository.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,31 @@ import (
44
"context"
55
"encoding/base64"
66
"errors"
7+
"fmt"
8+
"strings"
79

810
"github.com/shurcooL/githubv4"
911
)
1012

13+
// PullRequestCreationPolicy mirrors the GitHub GraphQL enum type of the same
14+
// name so we can query and mutate the field even when the vendored client
15+
// model lags behind the live schema.
16+
type PullRequestCreationPolicy string
17+
18+
const (
19+
PullRequestCreationPolicyAll PullRequestCreationPolicy = "ALL"
20+
PullRequestCreationPolicyCollaboratorsOnly PullRequestCreationPolicy = "COLLABORATORS_ONLY"
21+
)
22+
23+
// UpdateRepositoryInput intentionally mirrors the GitHub GraphQL input type
24+
// name so the graphql client emits the correct variable type in mutations.
25+
// We only model the fields needed for pullRequestCreationPolicy updates.
26+
type UpdateRepositoryInput struct {
27+
RepositoryID githubv4.ID `json:"repositoryId"`
28+
PullRequestCreationPolicy *PullRequestCreationPolicy `json:"pullRequestCreationPolicy,omitempty"`
29+
ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"`
30+
}
31+
1132
func getRepositoryID(name string, meta any) (githubv4.ID, error) {
1233
// Interpret `name` as a node ID
1334
exists, nodeIDerr := repositoryNodeIDExists(name, meta)
@@ -65,6 +86,88 @@ func repositoryNodeIDExists(name string, meta any) (bool, error) {
6586
return query.Node.ID.(string) == name, nil
6687
}
6788

89+
func flattenPullRequestCreationPolicy(policy PullRequestCreationPolicy) (string, error) {
90+
switch policy {
91+
case PullRequestCreationPolicyAll:
92+
return "all", nil
93+
case PullRequestCreationPolicyCollaboratorsOnly:
94+
return "collaborators_only", nil
95+
case "":
96+
return "", nil
97+
default:
98+
return "", fmt.Errorf("unsupported GraphQL pull request creation policy %q", policy)
99+
}
100+
}
101+
102+
func expandPullRequestCreationPolicy(policy string) (PullRequestCreationPolicy, error) {
103+
switch policy {
104+
case "all":
105+
return PullRequestCreationPolicyAll, nil
106+
case "collaborators_only":
107+
return PullRequestCreationPolicyCollaboratorsOnly, nil
108+
default:
109+
return "", fmt.Errorf("unsupported Terraform pull request creation policy %q", policy)
110+
}
111+
}
112+
113+
func isUnsupportedPullRequestCreationPolicyError(err error) bool {
114+
if err == nil {
115+
return false
116+
}
117+
118+
message := strings.ToLower(err.Error())
119+
120+
return strings.Contains(message, "pullrequestcreationpolicy") &&
121+
(strings.Contains(message, "doesn't exist") ||
122+
strings.Contains(message, "does not exist") ||
123+
strings.Contains(message, "undefined field") ||
124+
strings.Contains(message, "unknown argument") ||
125+
strings.Contains(message, "cannot query field"))
126+
}
127+
128+
func getRepositoryPullRequestCreationPolicy(ctx context.Context, owner, name string, meta any) (string, error) {
129+
var query struct {
130+
Repository struct {
131+
PullRequestCreationPolicy PullRequestCreationPolicy
132+
} `graphql:"repository(owner:$owner, name:$name)"`
133+
}
134+
135+
variables := map[string]any{
136+
"owner": githubv4.String(owner),
137+
"name": githubv4.String(name),
138+
}
139+
140+
client := meta.(*Owner).v4client
141+
if err := client.Query(ctx, &query, variables); err != nil {
142+
return "", err
143+
}
144+
145+
return flattenPullRequestCreationPolicy(query.Repository.PullRequestCreationPolicy)
146+
}
147+
148+
func updateRepositoryPullRequestCreationPolicy(ctx context.Context, repositoryID githubv4.ID, policy string, meta any) error {
149+
expandedPolicy, err := expandPullRequestCreationPolicy(policy)
150+
if err != nil {
151+
return err
152+
}
153+
154+
input := UpdateRepositoryInput{
155+
RepositoryID: repositoryID,
156+
PullRequestCreationPolicy: &expandedPolicy,
157+
}
158+
159+
var mutation struct {
160+
UpdateRepository struct {
161+
Repository struct {
162+
ID githubv4.ID
163+
}
164+
} `graphql:"updateRepository(input:$input)"`
165+
}
166+
167+
client := meta.(*Owner).v4client
168+
return client.Mutate(ctx, &mutation, input, nil)
169+
}
170+
68171
// Maintain compatibility with deprecated Global ID format
69172
// https://github.blog/2021-02-10-new-global-id-format-coming-to-graphql/
70173
func repositoryLegacyNodeIDExists(name string, meta any) (bool, error) {

github/util_v4_repository_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package github
22

33
import (
44
"bytes"
5+
"context"
6+
"errors"
57
"io"
68
"net/http"
79
"net/http/httptest"
@@ -191,6 +193,142 @@ func TestGetRepositoryIDPositiveMatches(t *testing.T) {
191193
}
192194
}
193195

196+
func TestPullRequestCreationPolicyMapping(t *testing.T) {
197+
t.Run("flatten GraphQL values", func(t *testing.T) {
198+
cases := []struct {
199+
name string
200+
input PullRequestCreationPolicy
201+
want string
202+
wantErr bool
203+
}{
204+
{name: "all", input: PullRequestCreationPolicyAll, want: "all"},
205+
{name: "collaborators_only", input: PullRequestCreationPolicyCollaboratorsOnly, want: "collaborators_only"},
206+
{name: "empty", input: "", want: ""},
207+
{name: "invalid", input: PullRequestCreationPolicy("NOPE"), wantErr: true},
208+
}
209+
210+
for _, tc := range cases {
211+
got, err := flattenPullRequestCreationPolicy(tc.input)
212+
if tc.wantErr {
213+
if err == nil {
214+
t.Fatalf("%s: expected error, got nil", tc.name)
215+
}
216+
continue
217+
}
218+
if err != nil {
219+
t.Fatalf("%s: unexpected error: %v", tc.name, err)
220+
}
221+
if got != tc.want {
222+
t.Fatalf("%s: got %q want %q", tc.name, got, tc.want)
223+
}
224+
}
225+
})
226+
227+
t.Run("expand Terraform values", func(t *testing.T) {
228+
cases := []struct {
229+
name string
230+
input string
231+
want PullRequestCreationPolicy
232+
wantErr bool
233+
}{
234+
{name: "all", input: "all", want: PullRequestCreationPolicyAll},
235+
{name: "collaborators_only", input: "collaborators_only", want: PullRequestCreationPolicyCollaboratorsOnly},
236+
{name: "invalid", input: "everyone", wantErr: true},
237+
}
238+
239+
for _, tc := range cases {
240+
got, err := expandPullRequestCreationPolicy(tc.input)
241+
if tc.wantErr {
242+
if err == nil {
243+
t.Fatalf("%s: expected error, got nil", tc.name)
244+
}
245+
continue
246+
}
247+
if err != nil {
248+
t.Fatalf("%s: unexpected error: %v", tc.name, err)
249+
}
250+
if got != tc.want {
251+
t.Fatalf("%s: got %q want %q", tc.name, got, tc.want)
252+
}
253+
}
254+
})
255+
}
256+
257+
func TestRepositoryPullRequestCreationPolicyGraphQL(t *testing.T) {
258+
mux := http.NewServeMux()
259+
mux.HandleFunc("/graphql", func(w http.ResponseWriter, req *http.Request) {
260+
body := mustRead(req.Body)
261+
w.Header().Set("Content-Type", "application/json")
262+
263+
switch {
264+
case strings.Contains(body, "repository(owner:$owner, name:$name){pullRequestCreationPolicy}"):
265+
if !strings.Contains(body, `"owner":"integrations"`) {
266+
t.Fatalf("expected resolved owner in GraphQL body, got %s", body)
267+
}
268+
mustWrite(w, `{"data":{"repository":{"pullRequestCreationPolicy":"COLLABORATORS_ONLY"}}}`)
269+
case strings.Contains(body, "mutation($input:UpdateRepositoryInput!){updateRepository(input:$input){repository{id}}}"):
270+
mustWrite(w, `{"data":{"updateRepository":{"repository":{"id":"R_kgDOGGmaaw"}}}}`)
271+
default:
272+
t.Fatalf("unexpected GraphQL body: %s", body)
273+
}
274+
})
275+
276+
meta := Owner{
277+
v4client: githubv4.NewClient(&http.Client{Transport: localRoundTripper{handler: mux}}),
278+
name: "integrations",
279+
}
280+
281+
ctx := context.Background()
282+
283+
got, err := getRepositoryPullRequestCreationPolicy(ctx, "integrations", "terraform-provider-github", &meta)
284+
if err != nil {
285+
t.Fatalf("unexpected read error: %v", err)
286+
}
287+
if got != "collaborators_only" {
288+
t.Fatalf("got %q want %q", got, "collaborators_only")
289+
}
290+
291+
if err := updateRepositoryPullRequestCreationPolicy(ctx, githubv4.ID("R_kgDOGGmaaw"), "all", &meta); err != nil {
292+
t.Fatalf("unexpected update error: %v", err)
293+
}
294+
}
295+
296+
func TestIsUnsupportedPullRequestCreationPolicyError(t *testing.T) {
297+
cases := []struct {
298+
name string
299+
err error
300+
want bool
301+
}{
302+
{
303+
name: "missing field",
304+
err: errors.New(`Field 'pullRequestCreationPolicy' doesn't exist on type 'Repository'`),
305+
want: true,
306+
},
307+
{
308+
name: "cannot query field",
309+
err: errors.New(`Cannot query field "pullRequestCreationPolicy" on type "Repository".`),
310+
want: true,
311+
},
312+
{
313+
name: "unrelated graphql error",
314+
err: errors.New(`Could not resolve to a Repository with the name 'integrations/terraform-provider-github'.`),
315+
want: false,
316+
},
317+
{
318+
name: "nil",
319+
err: nil,
320+
want: false,
321+
},
322+
}
323+
324+
for _, tc := range cases {
325+
got := isUnsupportedPullRequestCreationPolicyError(tc.err)
326+
if got != tc.want {
327+
t.Fatalf("%s: got %t want %t", tc.name, got, tc.want)
328+
}
329+
}
330+
}
331+
194332
// localRoundTripper is an http.RoundTripper that executes HTTP transactions
195333
// by using handler directly, instead of going over an HTTP connection.
196334
type localRoundTripper struct {

website/docs/r/repository.html.markdown

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ The following arguments are supported:
112112

113113
- `delete_branch_on_merge` - (Optional) Automatically delete head branch after a pull request is merged. Defaults to `false`.
114114

115+
- `pull_request_creation_policy` - (Optional) Controls who can create pull requests in the repository. Can be `all` or `collaborators_only`. If omitted, the provider reads the current remote value and does not impose a provider-side default.
116+
115117
- `web_commit_signoff_required` - (Optional) Require contributors to sign off on web-based commits. See more in the [GitHub documentation](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/managing-repository-settings/managing-the-commit-signoff-policy-for-your-repository).
116118

117119
- `has_downloads` - (**DEPRECATED**) (Optional) Set to `true` to enable the (deprecated) downloads features on the repository. This attribute is no longer in use, but it hasn't been removed yet. It will be removed in a future version. See [this discussion](https://github.com/orgs/community/discussions/102145#discussioncomment-8351756).

0 commit comments

Comments
 (0)