diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8875deb388..5981096384 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -178,7 +178,10 @@ func resourceGithubExample() *schema.Resource { StateContext: resourceGithubExampleImport, }, - // Include SchemaVersion and StateUpgraders if state migrations exist + // Only if required. + CustomizeDiff: diffExample, + + // Include SchemaVersion and StateUpgraders if state migrations exist. SchemaVersion: 1, StateUpgraders: []schema.StateUpgrader{ { @@ -188,8 +191,10 @@ func resourceGithubExample() *schema.Resource { }, }, + Description: "Manages an example GitHub resource.", + Schema: map[string]*schema.Schema{ - // Schema definition + // Schema definition. }, } } diff --git a/docs/resources/repository_custom_property.md b/docs/resources/repository_custom_property.md index 3c492eba00..af783a12a9 100644 --- a/docs/resources/repository_custom_property.md +++ b/docs/resources/repository_custom_property.md @@ -1,45 +1,62 @@ --- page_title: "github_repository_custom_property (Resource) - GitHub" +subcategory: "" description: |- - Creates and a specific custom property for a GitHub repository + Resource to manage GitHub repository custom properties. --- # github_repository_custom_property (Resource) -This resource allows you to create and manage a specific custom property for a GitHub repository. +Resource to manage GitHub repository custom properties. +For more information, see the [GitHub API documentation](https://docs.github.com/rest/metadata/custom-properties#create-or-update-repository-custom-property). ## Example Usage -> Note that this assumes there already is a custom property defined on the org level called `my-cool-property` of type `string` - ```terraform +# NOTE: This assumes there already is a custom property defined on the org level called `my-cool-string` of type `string` + resource "github_repository" "example" { name = "example" description = "My awesome codebase" } -resource "github_repository_custom_property" "string" { + +resource "github_repository_custom_property" "example" { repository = github_repository.example.name - property_name = "my-cool-property" + property_name = "my-cool-string" property_type = "string" property_value = ["test"] } ``` -## Argument Reference + +## Schema -The following arguments are supported: +### Required -- `repository` - (Required) The repository of the environment. +- `property_name` (String) Name of the custom property. +- `property_type` (String) Type of the custom property. Valid values are `string`, `single_select`, `multi_select`, `true_false`, and `url`. +- `property_value` (Set of String) Value of the custom property. For `string`, `single_select`, `true_false`, and `url` property types, this should be a single value. For `multi_select` property types, this can be multiple values. +- `repository` (String) Name of the repository. -- `property_type` - (Required) Type of the custom property. Can be one of `single_select`, `multi_select`, `string`, or `true_false` +### Read-Only -- `property_name` - (Required) Name of the custom property. Note that a pre-requisiste for this resource is that a custom property of this name has already been defined on the organization level - -- `property_value` - (Required) Value of the custom property in the form of an array. Properties of type `single_select`, `string`, and `true_false` are represented as a string array of length 1 +- `id` (String) The ID of this resource. +- `repository_id` (Number) ID of the repository. ## Import -GitHub Repository Custom Property can be imported using an ID made up of a combination of the names of the organization, repository, custom property separated by a `:` character, e.g. +Import is supported using the following syntax: + +In Terraform v1.5.0 and later, the [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used with the `id` attribute, for example: + +```terraform +import { + to = github_repository_custom_property.example + id = "organization-name:repo-name:custom-property-name" +} +``` + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: ```shell terraform import github_repository_custom_property.example organization-name:repo-name:custom-property-name diff --git a/examples/resources/github_repository_custom_property/import-by-string-id.tf b/examples/resources/github_repository_custom_property/import-by-string-id.tf new file mode 100644 index 0000000000..b65f685043 --- /dev/null +++ b/examples/resources/github_repository_custom_property/import-by-string-id.tf @@ -0,0 +1,4 @@ +import { + to = github_repository_custom_property.example + id = "organization-name:repo-name:custom-property-name" +} diff --git a/examples/resources/github_repository_custom_property/import.sh b/examples/resources/github_repository_custom_property/import.sh new file mode 100644 index 0000000000..4f3c81dfaa --- /dev/null +++ b/examples/resources/github_repository_custom_property/import.sh @@ -0,0 +1 @@ +terraform import github_repository_custom_property.example organization-name:repo-name:custom-property-name diff --git a/examples/resources/github_repository_custom_property/resource_1.tf b/examples/resources/github_repository_custom_property/resource_1.tf new file mode 100644 index 0000000000..11921d7303 --- /dev/null +++ b/examples/resources/github_repository_custom_property/resource_1.tf @@ -0,0 +1,13 @@ +# NOTE: This assumes there already is a custom property defined on the org level called `my-cool-string` of type `string` + +resource "github_repository" "example" { + name = "example" + description = "My awesome codebase" +} + +resource "github_repository_custom_property" "example" { + repository = github_repository.example.name + property_name = "my-cool-string" + property_type = "string" + property_value = ["test"] +} diff --git a/examples/resources/repository_custom_property/example_1.tf b/examples/resources/repository_custom_property/example_1.tf deleted file mode 100644 index be5eb8fb8c..0000000000 --- a/examples/resources/repository_custom_property/example_1.tf +++ /dev/null @@ -1,10 +0,0 @@ -resource "github_repository" "example" { - name = "example" - description = "My awesome codebase" -} -resource "github_repository_custom_property" "string" { - repository = github_repository.example.name - property_name = "my-cool-property" - property_type = "string" - property_value = ["test"] -} diff --git a/github/acc_helpers_test.go b/github/acc_helpers_test.go new file mode 100644 index 0000000000..607cccd8b9 --- /dev/null +++ b/github/acc_helpers_test.go @@ -0,0 +1,106 @@ +package github + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "testing" + + "github.com/google/go-github/v88/github" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" +) + +const testRandomIDLength = 5 + +func mustGetTestMockResponse(t *testing.T, uri string, statusCode int, body any) *mockResponse { + resp := &mockResponse{ + ExpectedUri: uri, + StatusCode: statusCode, + } + + if body != nil { + bodyBytes, err := json.Marshal(body) + if err != nil { + t.Fatalf("failed to marshal mock response body: %v", err) + } + resp.ResponseBody = string(bodyBytes) + } + + return resp +} + +func mustCreateTestGitHubClient(t *testing.T, baseURL string, opts ...github.ClientOptionsFunc) *github.Client { + client, err := github.NewClient(append([]github.ClientOptionsFunc{github.WithURLs(&baseURL, nil)}, opts...)...) + if err != nil { + t.Fatalf("failed to create GitHub client: %s", err) + } + return client +} + +func mustCreateTestOrganizationRepositoryCustomProperty(t *testing.T, valType string, allowed []string) *github.CustomProperty { + t.Helper() + + meta, err := getTestMeta() + if err != nil { + t.Fatalf("failed to get test meta: %v", err) + } + + randomID := acctest.RandString(testRandomIDLength) + name := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + + req := &github.CustomProperty{ + PropertyName: &name, + ValueType: github.PropertyValueType(valType), + AllowedValues: allowed, + } + + prop, _, err := meta.v3client.Organizations.CreateOrUpdateCustomProperty(t.Context(), meta.name, name, req) + if err != nil { + t.Fatalf("failed to create test organization repository custom property: %v", err) + } + + t.Cleanup(func() { + if _, err := meta.v3client.Organizations.RemoveCustomProperty(context.Background(), meta.name, name); err != nil { + if err, ok := errors.AsType[*github.ErrorResponse](err); ok && err.Response.StatusCode == 404 { + return + } + t.Logf("failed to delete test organization repository custom property %s: %v", name, err) + } + }) + + return prop +} + +func mustCreateTestRepository(t *testing.T) *github.Repository { + t.Helper() + + meta, err := getTestMeta() + if err != nil { + t.Fatalf("failed to get test meta: %v", err) + } + + randomID := acctest.RandString(testRandomIDLength) + name := fmt.Sprintf("%s%s", testResourcePrefix, randomID) + + req := &github.Repository{ + Name: &name, + AutoInit: new(true), + } + + repo, _, err := meta.v3client.Repositories.Create(t.Context(), meta.name, req) + if err != nil { + t.Fatalf("failed to create test repository: %v", err) + } + + t.Cleanup(func() { + if _, err := meta.v3client.Repositories.Delete(context.Background(), meta.name, name); err != nil { + if err, ok := errors.AsType[*github.ErrorResponse](err); ok && err.Response.StatusCode == 404 { + return + } + t.Logf("failed to delete test repository %s: %v", name, err) + } + }) + + return repo +} diff --git a/github/data_source_github_app_token_test.go b/github/data_source_github_app_token_test.go index 89253e7048..a9099c077a 100644 --- a/github/data_source_github_app_token_test.go +++ b/github/data_source_github_app_token_test.go @@ -32,7 +32,7 @@ func TestAccGithubAppTokenDataSource(t *testing.T) { }) defer ts.Close() - client := mustGitHubClient(t, ts.URL) + client := mustCreateTestGitHubClient(t, ts.URL) meta := &Owner{ name: owner, diff --git a/github/helpers_test.go b/github/helpers_test.go deleted file mode 100644 index 9d473eb99a..0000000000 --- a/github/helpers_test.go +++ /dev/null @@ -1,15 +0,0 @@ -package github - -import ( - "testing" - - "github.com/google/go-github/v88/github" -) - -func mustGitHubClient(t *testing.T, baseURL string, opts ...github.ClientOptionsFunc) *github.Client { - client, err := github.NewClient(append([]github.ClientOptionsFunc{github.WithURLs(&baseURL, nil)}, opts...)...) - if err != nil { - t.Fatalf("failed to create GitHub client: %s", err) - } - return client -} diff --git a/github/resource_github_repository_custom_property.go b/github/resource_github_repository_custom_property.go index ae0f0fb8ff..66489657ae 100644 --- a/github/resource_github_repository_custom_property.go +++ b/github/resource_github_repository_custom_property.go @@ -2,33 +2,56 @@ package github import ( "context" + "errors" "fmt" + "strings" "github.com/google/go-github/v88/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) func resourceGithubRepositoryCustomProperty() *schema.Resource { return &schema.Resource{ - Create: resourceGithubRepositoryCustomPropertyCreate, - Read: resourceGithubRepositoryCustomPropertyRead, - Delete: resourceGithubRepositoryCustomPropertyDelete, + CreateContext: resourceGithubRepositoryCustomPropertyCreate, + ReadContext: resourceGithubRepositoryCustomPropertyRead, + UpdateContext: resourceGithubRepositoryCustomPropertyUpdate, + DeleteContext: resourceGithubRepositoryCustomPropertyDelete, Importer: &schema.ResourceImporter{ - StateContext: schema.ImportStatePassthroughContext, + StateContext: resourceGithubRepositoryCustomPropertyImport, }, + CustomizeDiff: customdiff.All(diffRepository, resourceGithubRepositoryCustomPropertyDiff), + + SchemaVersion: 1, + StateUpgraders: []schema.StateUpgrader{ + { + Type: resourceGithubRepositoryCustomPropertyV0().CoreConfigSchema().ImpliedType(), + Upgrade: resourceGithubRepositoryCustomPropertyStateUpgradeV0, + Version: 0, + }, + }, + + Description: "Resource to manage GitHub repository custom properties.", + Schema: map[string]*schema.Schema{ "repository": { Type: schema.TypeString, Required: true, - Description: "Name of the repository which the custom properties should be on.", - ForceNew: true, + Description: "Name of the repository.", + }, + "repository_id": { + Type: schema.TypeInt, + Computed: true, + Description: "ID of the repository.", }, "property_type": { Type: schema.TypeString, Required: true, - Description: "Type of the custom property", + Description: "Type of the custom property. Valid values are `string`, `single_select`, `multi_select`, `true_false`, and `url`.", ForceNew: true, ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{string(github.PropertyValueTypeString), string(github.PropertyValueTypeSingleSelect), string(github.PropertyValueTypeMultiSelect), string(github.PropertyValueTypeTrueFalse), string(github.PropertyValueTypeURL)}, false)), }, @@ -42,25 +65,55 @@ func resourceGithubRepositoryCustomProperty() *schema.Resource { Type: schema.TypeSet, MinItems: 1, Required: true, - Description: "Value of the custom property.", + Description: "Value of the custom property. For `string`, `single_select`, `true_false`, and `url` property types, this should be a single value. For `multi_select` property types, this can be multiple values.", Elem: &schema.Schema{ - Type: schema.TypeString, + Type: schema.TypeString, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringIsNotEmpty), }, - ForceNew: true, }, }, } } -func resourceGithubRepositoryCustomPropertyCreate(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - ctx := context.Background() +func resourceGithubRepositoryCustomPropertyDiff(ctx context.Context, d *schema.ResourceDiff, _ any) error { + tflog.Debug(ctx, "Diffing GitHub repository custom property") + + if !d.NewValueKnown("property_type") || !d.NewValueKnown("property_value") { + return nil + } + + propertyTypeVal, _ := d.Get("property_type").(string) + propertyType := github.PropertyValueType(propertyTypeVal) + propertyValueVal, _ := d.Get("property_value").(*schema.Set) + propertyValue := expandStringList(propertyValueVal.List()) + propertyCount := len(propertyValue) - owner := meta.(*Owner).name - repoName := d.Get("repository").(string) - propertyName := d.Get("property_name").(string) - propertyType := github.PropertyValueType(d.Get("property_type").(string)) - propertyValue := expandStringList(d.Get("property_value").(*schema.Set).List()) + // The propertyValue can either be a list of strings or a string + switch propertyType { + case github.PropertyValueTypeMultiSelect: + if propertyCount < 1 { + return fmt.Errorf("custom property type %v requires at least one value", propertyType) + } + default: + if propertyCount != 1 { + return fmt.Errorf("custom property type %v requires exactly one value", propertyType) + } + } + + return nil +} + +func resourceGithubRepositoryCustomPropertyCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName, _ := d.Get("repository").(string) + propertyName, _ := d.Get("property_name").(string) + propertyTypeVal, _ := d.Get("property_type").(string) + propertyType := github.PropertyValueType(propertyTypeVal) + propertyValueVal, _ := d.Get("property_value").(*schema.Set) + propertyValue := expandStringList(propertyValueVal.List()) customProperty := github.CustomPropertyValue{ PropertyName: propertyName, @@ -68,94 +121,176 @@ func resourceGithubRepositoryCustomPropertyCreate(d *schema.ResourceData, meta a // The propertyValue can either be a list of strings or a string switch propertyType { - case github.PropertyValueTypeString, github.PropertyValueTypeSingleSelect, github.PropertyValueTypeURL, github.PropertyValueTypeTrueFalse: - customProperty.Value = propertyValue[0] case github.PropertyValueTypeMultiSelect: customProperty.Value = propertyValue default: - return fmt.Errorf("custom property type is not valid: %v", propertyType) + customProperty.Value = propertyValue[0] } _, err := client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repoName, []*github.CustomPropertyValue{&customProperty}) if err != nil { - return err + return diag.FromErr(err) + } + + id, err := buildID(owner, repoName, propertyName) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return diag.FromErr(err) } + repoID := int(repo.GetID()) - d.SetId(buildThreePartID(owner, repoName, propertyName)) + if err := d.Set("repository_id", repoID); err != nil { + return diag.FromErr(err) + } - return resourceGithubRepositoryCustomPropertyRead(d, meta) + return nil } -func resourceGithubRepositoryCustomPropertyRead(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - ctx := context.Background() +func resourceGithubRepositoryCustomPropertyRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name - owner, repoName, propertyName, err := parseID3(d.Id()) + repoName, _ := d.Get("repository").(string) + propertyName, _ := d.Get("property_name").(string) + + props, _, err := client.Repositories.GetAllCustomPropertyValues(ctx, owner, repoName) if err != nil { - return err + if err, ok := errors.AsType[*github.ErrorResponse](err); ok && err.Response.StatusCode == 404 { + d.SetId("") + return nil + } + return diag.FromErr(err) } - wantedCustomPropertyValue, err := readRepositoryCustomPropertyValue(ctx, client, owner, repoName, propertyName) - if err != nil { - return err + var property *github.CustomPropertyValue + for _, prop := range props { + if prop.PropertyName == propertyName { + property = prop + break + } } - if wantedCustomPropertyValue == nil { + if property == nil { d.SetId("") return nil } - d.SetId(buildThreePartID(owner, repoName, propertyName)) - _ = d.Set("repository", repoName) - _ = d.Set("property_name", propertyName) - _ = d.Set("property_value", wantedCustomPropertyValue) + propertyValue, err := parseRepositoryCustomPropertyValueToStringSlice(property) + if err != nil { + return diag.FromErr(err) + } + + if err := d.Set("property_value", propertyValue); err != nil { + return diag.FromErr(err) + } return nil } -func resourceGithubRepositoryCustomPropertyDelete(d *schema.ResourceData, meta any) error { - client := meta.(*Owner).v3client - ctx := context.Background() +func resourceGithubRepositoryCustomPropertyUpdate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name - owner, repoName, propertyName, err := parseID3(d.Id()) + repoName, _ := d.Get("repository").(string) + propertyName, _ := d.Get("property_name").(string) + propertyTypeVal, _ := d.Get("property_type").(string) + propertyType := github.PropertyValueType(propertyTypeVal) + propertyValueVal, _ := d.Get("property_value").(*schema.Set) + propertyValue := expandStringList(propertyValueVal.List()) + + customProperty := github.CustomPropertyValue{ + PropertyName: propertyName, + } + + // The propertyValue can either be a list of strings or a string + switch propertyType { + case github.PropertyValueTypeMultiSelect: + customProperty.Value = propertyValue + default: + customProperty.Value = propertyValue[0] + } + + _, err := client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repoName, []*github.CustomPropertyValue{&customProperty}) if err != nil { - return err + return diag.FromErr(err) } + id, err := buildID(owner, repoName, propertyName) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + + return nil +} + +func resourceGithubRepositoryCustomPropertyDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + + repoName, _ := d.Get("repository").(string) + propertyName, _ := d.Get("property_name").(string) + customProperty := github.CustomPropertyValue{ PropertyName: propertyName, Value: nil, } - _, err = client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repoName, []*github.CustomPropertyValue{&customProperty}) + _, err := client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repoName, []*github.CustomPropertyValue{&customProperty}) if err != nil { - return err + if err, ok := errors.AsType[*github.ErrorResponse](err); ok && err.Response.StatusCode == 404 { + return nil + } + return diag.FromErr(err) } return nil } -func readRepositoryCustomPropertyValue(ctx context.Context, client *github.Client, owner, repoName, propertyName string) ([]string, error) { - allCustomProperties, _, err := client.Repositories.GetAllCustomPropertyValues(ctx, owner, repoName) +func resourceGithubRepositoryCustomPropertyImport(ctx context.Context, d *schema.ResourceData, m any) ([]*schema.ResourceData, error) { + meta, _ := m.(*Owner) + client := meta.v3client + + owner, repoName, propertyName, err := parseID3(d.Id()) if err != nil { return nil, err } - var wantedCustomProperty *github.CustomPropertyValue - for _, customProperty := range allCustomProperties { - if customProperty.PropertyName == propertyName { - wantedCustomProperty = customProperty - } + if !strings.EqualFold(owner, meta.name) { + return nil, errors.New("owner in id must match authenticated owner") } - if wantedCustomProperty == nil { - return nil, nil + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return nil, err } + repoID := int(repo.GetID()) - wantedPropertyValue, err := parseRepositoryCustomPropertyValueToStringSlice(wantedCustomProperty) + cp, _, err := client.Organizations.GetCustomProperty(ctx, owner, propertyName) if err != nil { return nil, err } - return wantedPropertyValue, nil + if err := d.Set("repository", repoName); err != nil { + return nil, err + } + if err := d.Set("repository_id", repoID); err != nil { + return nil, err + } + if err := d.Set("property_type", cp.GetValueType()); err != nil { + return nil, err + } + if err := d.Set("property_name", propertyName); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil } diff --git a/github/resource_github_repository_custom_property_migration.go b/github/resource_github_repository_custom_property_migration.go new file mode 100644 index 0000000000..9c411a3882 --- /dev/null +++ b/github/resource_github_repository_custom_property_migration.go @@ -0,0 +1,74 @@ +package github + +import ( + "context" + "fmt" + + "github.com/google/go-github/v88/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceGithubRepositoryCustomPropertyV0() *schema.Resource { + return &schema.Resource{ + SchemaVersion: 0, + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + Description: "Name of the repository which the custom properties should be on.", + ForceNew: true, + }, + "property_type": { + Type: schema.TypeString, + Required: true, + Description: "Type of the custom property", + ForceNew: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{string(github.PropertyValueTypeString), string(github.PropertyValueTypeSingleSelect), string(github.PropertyValueTypeMultiSelect), string(github.PropertyValueTypeTrueFalse), string(github.PropertyValueTypeURL)}, false)), + }, + "property_name": { + Type: schema.TypeString, + Required: true, + Description: "Name of the custom property.", + ForceNew: true, + }, + "property_value": { + Type: schema.TypeSet, + MinItems: 1, + Required: true, + Description: "Value of the custom property.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + ForceNew: true, + }, + }, + } +} + +func resourceGithubRepositoryCustomPropertyStateUpgradeV0(ctx context.Context, rawState map[string]any, m any) (map[string]any, error) { + meta, _ := m.(*Owner) + client := meta.v3client + owner := meta.name + + tflog.Debug(ctx, "GitHub Repository Custom Property Attributes before migration: %#v", rawState) + + repoName, ok := rawState["repository"].(string) + if !ok { + return nil, fmt.Errorf("repository not found or is not a string") + } + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return nil, fmt.Errorf("failed to retrieve repository %s: %w", repoName, err) + } + + repoID := int(repo.GetID()) + rawState["repository_id"] = repoID + + tflog.Debug(ctx, "GitHub Repository Custom Property Attributes after migration: %#v", rawState) + + return rawState, nil +} diff --git a/github/resource_github_repository_custom_property_migration_test.go b/github/resource_github_repository_custom_property_migration_test.go new file mode 100644 index 0000000000..851dfebcd3 --- /dev/null +++ b/github/resource_github_repository_custom_property_migration_test.go @@ -0,0 +1,84 @@ +package github + +import ( + "regexp" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-github/v88/github" +) + +func Test_resourceGithubCustomPropertyStateUpgradeV0(t *testing.T) { + for _, tt := range []struct { + testName string + statusCode int + body *github.Repository + rawState map[string]any + want map[string]any + wantErr *string + }{ + { + testName: "succeeds_if_repo_found", + statusCode: 200, + body: &github.Repository{ + ID: new(int64(123456)), + }, + rawState: map[string]any{ + "id": "my-org:my-repo:my-property", + "repository": "my-repo", + "property_name": "my-property", + "property_value": "my-value", + }, + want: map[string]any{ + "id": "my-org:my-repo:my-property", + "repository": "my-repo", + "repository_id": 123456, + "property_name": "my-property", + "property_value": "my-value", + }, + }, + { + testName: "fails_if_repo_not_found", + statusCode: 404, + body: nil, + rawState: map[string]any{ + "id": "my-org:my-repo:my-property", + "repository": "my-repo", + "property_name": "my-property", + "property_value": "my-value", + }, + wantErr: new("failed to retrieve repository"), + }, + } { + t.Run(tt.testName, func(t *testing.T) { + ts := githubApiMock([]*mockResponse{mustGetTestMockResponse(t, "/repos/my-org/my-repo", tt.statusCode, tt.body)}) + defer ts.Close() + + meta := &Owner{ + name: "my-org", + v3client: mustCreateTestGitHubClient(t, ts.URL), + } + + got, err := resourceGithubRepositoryCustomPropertyStateUpgradeV0(t.Context(), tt.rawState, meta) + if err != nil { + if tt.wantErr == nil { + t.Fatalf("unexpected error: %s", err) + } + + if !regexp.MustCompile(regexp.QuoteMeta(*tt.wantErr)).MatchString(err.Error()) { + t.Fatalf("unexpected error: %s", err) + } + + return + } + + if tt.wantErr != nil { + t.Fatalf("expected error: %s", *tt.wantErr) + } + + if diff := cmp.Diff(got, tt.want); diff != "" { + t.Fatalf("got %+v, want %+v: %s", got, tt.want, diff) + } + }) + } +} diff --git a/github/resource_github_repository_custom_property_test.go b/github/resource_github_repository_custom_property_test.go index 9fcb28b496..667e0e43f6 100644 --- a/github/resource_github_repository_custom_property_test.go +++ b/github/resource_github_repository_custom_property_test.go @@ -2,175 +2,272 @@ package github import ( "fmt" + "regexp" "testing" - "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "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/tfjsonpath" ) func TestAccGithubRepositoryCustomProperty(t *testing.T) { - t.Run("creates custom property of type single_select without error", func(t *testing.T) { - randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-custom-prop-%s", testResourcePrefix, randomID) - propertyName := fmt.Sprintf("tf-acc-test-property-%s", randomID) + t.Parallel() + + t.Run("single_select", func(t *testing.T) { + t.Parallel() + + prop := mustCreateTestOrganizationRepositoryCustomProperty(t, "single_select", []string{"option1", "option2"}) + repo := mustCreateTestRepository(t) + allowed := prop.GetAllowedValues() + + config := fmt.Sprintf(` +resource "github_repository_custom_property" "test" { + repository = "%s" + property_name = "%s" + property_type = "%s" + property_value = %%s +} +`, repo.GetName(), prop.GetPropertyName(), prop.GetValueType()) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(config, `[]`), + ExpectError: regexp.MustCompile(`Not enough list items`), + PlanOnly: true, + }, + { + Config: fmt.Sprintf(config, fmt.Sprintf(`["%s"]`, allowed[0])), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_repository_custom_property.test", tfjsonpath.New("repository_id"), knownvalue.NotNull()), + }, + }, + { + Config: fmt.Sprintf(config, `["invalid_option"]`), + ExpectError: regexp.MustCompile(`is not allowed for property`), + }, + { + Config: fmt.Sprintf(config, fmt.Sprintf(`["%s"]`, allowed[1])), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("github_repository_custom_property.test", plancheck.ResourceActionUpdate), + }, + }, + }, + { + ResourceName: "github_repository_custom_property.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) + + t.Run("multi_select", func(t *testing.T) { + t.Parallel() + + prop := mustCreateTestOrganizationRepositoryCustomProperty(t, "multi_select", []string{"option1", "option2", "option3"}) + repo := mustCreateTestRepository(t) + allowed := prop.GetAllowedValues() config := fmt.Sprintf(` - resource "github_organization_custom_properties" "test" { - allowed_values = ["option1", "option2"] - description = "Test Description" - property_name = "%s" - value_type = "single_select" - } - resource "github_repository" "test" { - name = "%s" - auto_init = true - } - resource "github_repository_custom_property" "test" { - repository = github_repository.test.name - property_name = github_organization_custom_properties.test.property_name - property_type = github_organization_custom_properties.test.value_type - property_value = ["option1"] - } - `, propertyName, repoName) - - check := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_repository_custom_property.test", "property_name", propertyName), - resource.TestCheckResourceAttr("github_repository_custom_property.test", "property_value.#", "1"), - resource.TestCheckResourceAttr("github_repository_custom_property.test", "property_value.0", "option1"), - ) +resource "github_repository_custom_property" "test" { + repository = "%s" + property_name = "%s" + property_type = "%s" + property_value = %%s +} +`, repo.GetName(), prop.GetPropertyName(), prop.GetValueType()) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasOrgs(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: config, - Check: check, + Config: fmt.Sprintf(config, `[]`), + ExpectError: regexp.MustCompile(`Not enough list items`), + PlanOnly: true, + }, + { + Config: fmt.Sprintf(config, fmt.Sprintf(`["%s", "%s"]`, allowed[0], allowed[1])), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_repository_custom_property.test", tfjsonpath.New("repository_id"), knownvalue.NotNull()), + }, + }, + { + Config: fmt.Sprintf(config, `["invalid_option"]`), + ExpectError: regexp.MustCompile(`is not allowed for property`), + }, + { + Config: fmt.Sprintf(config, fmt.Sprintf(`["%s"]`, allowed[2])), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("github_repository_custom_property.test", plancheck.ResourceActionUpdate), + }, + }, + }, + { + ResourceName: "github_repository_custom_property.test", + ImportState: true, + ImportStateVerify: true, }, }, }) }) - t.Run("creates custom property of type multi_select without error", func(t *testing.T) { - randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-custom-prop-%s", testResourcePrefix, randomID) - propertyName := fmt.Sprintf("tf-acc-test-property-%s", randomID) + t.Run("true_false", func(t *testing.T) { + t.Parallel() + + prop := mustCreateTestOrganizationRepositoryCustomProperty(t, "true_false", nil) + repo := mustCreateTestRepository(t) config := fmt.Sprintf(` - resource "github_organization_custom_properties" "test" { - allowed_values = ["option1", "option2"] - description = "Test Description" - property_name = "%s" - value_type = "multi_select" - } - resource "github_repository" "test" { - name = "%s" - auto_init = true - } - resource "github_repository_custom_property" "test" { - repository = github_repository.test.name - property_name = github_organization_custom_properties.test.property_name - property_type = github_organization_custom_properties.test.value_type - property_value = ["option1", "option2"] - } - `, propertyName, repoName) - - checkWithOwner := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_repository_custom_property.test", "property_name", propertyName), - resource.TestCheckResourceAttr("github_repository_custom_property.test", "property_value.#", "2"), - resource.TestCheckResourceAttr("github_repository_custom_property.test", "property_value.0", "option1"), - resource.TestCheckResourceAttr("github_repository_custom_property.test", "property_value.1", "option2"), - ) +resource "github_repository_custom_property" "test" { + repository = "%s" + property_name = "%s" + property_type = "%s" + property_value = %%s +} +`, repo.GetName(), prop.GetPropertyName(), prop.GetValueType()) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasOrgs(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: config, - Check: checkWithOwner, + Config: fmt.Sprintf(config, `[]`), + ExpectError: regexp.MustCompile(`Not enough list items`), + PlanOnly: true, + }, + { + Config: fmt.Sprintf(config, `["true"]`), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_repository_custom_property.test", tfjsonpath.New("repository_id"), knownvalue.NotNull()), + }, + }, + { + Config: fmt.Sprintf(config, `["invalid_option"]`), + ExpectError: regexp.MustCompile(`is not allowed for property`), + }, + { + Config: fmt.Sprintf(config, `["false"]`), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("github_repository_custom_property.test", plancheck.ResourceActionUpdate), + }, + }, + }, + { + ResourceName: "github_repository_custom_property.test", + ImportState: true, + ImportStateVerify: true, }, }, }) }) - t.Run("creates custom property of type true-false without error", func(t *testing.T) { - randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-custom-prop-%s", testResourcePrefix, randomID) - propertyName := fmt.Sprintf("tf-acc-test-property-%s", randomID) + t.Run("url", func(t *testing.T) { + t.Parallel() + + prop := mustCreateTestOrganizationRepositoryCustomProperty(t, "url", nil) + repo := mustCreateTestRepository(t) config := fmt.Sprintf(` - resource "github_organization_custom_properties" "test" { - description = "Test Description" - property_name = "%s" - value_type = "true_false" - } - resource "github_repository" "test" { - name = "%s" - auto_init = true - } - resource "github_repository_custom_property" "test" { - repository = github_repository.test.name - property_name = github_organization_custom_properties.test.property_name - property_type = github_organization_custom_properties.test.value_type - property_value = ["true"] - } - `, propertyName, repoName) - - checkWithOwner := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_repository_custom_property.test", "property_name", propertyName), - resource.TestCheckResourceAttr("github_repository_custom_property.test", "property_value.#", "1"), - resource.TestCheckResourceAttr("github_repository_custom_property.test", "property_value.0", "true"), - ) +resource "github_repository_custom_property" "test" { + repository = "%s" + property_name = "%s" + property_type = "%s" + property_value = %%s +} +`, repo.GetName(), prop.GetPropertyName(), prop.GetValueType()) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasOrgs(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: config, - Check: checkWithOwner, + Config: fmt.Sprintf(config, `[]`), + ExpectError: regexp.MustCompile(`Not enough list items`), + PlanOnly: true, + }, + { + Config: fmt.Sprintf(config, `["https://example.com"]`), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_repository_custom_property.test", tfjsonpath.New("repository_id"), knownvalue.NotNull()), + }, + }, + { + Config: fmt.Sprintf(config, `["xxxx"]`), + ExpectError: regexp.MustCompile(`URL must be absolute`), + }, + { + Config: fmt.Sprintf(config, `["https://example.com/test"]`), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("github_repository_custom_property.test", plancheck.ResourceActionUpdate), + }, + }, + }, + { + ResourceName: "github_repository_custom_property.test", + ImportState: true, + ImportStateVerify: true, }, }, }) }) - t.Run("creates custom property of type string without error", func(t *testing.T) { - randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) - repoName := fmt.Sprintf("%srepo-custom-prop-%s", testResourcePrefix, randomID) - propertyName := fmt.Sprintf("tf-acc-test-property-%s", randomID) + t.Run("string", func(t *testing.T) { + t.Parallel() + + prop := mustCreateTestOrganizationRepositoryCustomProperty(t, "string", nil) + repo := mustCreateTestRepository(t) config := fmt.Sprintf(` - resource "github_organization_custom_properties" "test" { - description = "Test Description" - property_name = "%s" - value_type = "string" - } - resource "github_repository" "test" { - name = "%s" - auto_init = true - } - resource "github_repository_custom_property" "test" { - repository = github_repository.test.name - property_name = github_organization_custom_properties.test.property_name - property_type = github_organization_custom_properties.test.value_type - property_value = ["text"] - } - `, propertyName, repoName) - - checkWithOwner := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_repository_custom_property.test", "property_name", propertyName), - resource.TestCheckResourceAttr("github_repository_custom_property.test", "property_value.#", "1"), - resource.TestCheckResourceAttr("github_repository_custom_property.test", "property_value.0", "text"), - ) +resource "github_repository_custom_property" "test" { + repository = "%s" + property_name = "%s" + property_type = "%s" + property_value = %%s +} +`, repo.GetName(), prop.GetPropertyName(), prop.GetValueType()) resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessHasOrgs(t) }, ProviderFactories: providerFactories, Steps: []resource.TestStep{ { - Config: config, - Check: checkWithOwner, + Config: fmt.Sprintf(config, `[]`), + ExpectError: regexp.MustCompile(`Not enough list items`), + PlanOnly: true, + }, + { + Config: fmt.Sprintf(config, `[""]`), + ExpectError: regexp.MustCompile(`to not be an empty string`), + PlanOnly: true, + }, + { + Config: fmt.Sprintf(config, `["text"]`), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_repository_custom_property.test", tfjsonpath.New("repository_id"), knownvalue.NotNull()), + }, + }, + { + Config: fmt.Sprintf(config, `["new text"]`), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("github_repository_custom_property.test", plancheck.ResourceActionUpdate), + }, + }, + }, + { + ResourceName: "github_repository_custom_property.test", + ImportState: true, + ImportStateVerify: true, }, }, }) diff --git a/github/resource_github_repository_file_migration_test.go b/github/resource_github_repository_file_migration_test.go index a9081639cc..65cc041ecb 100644 --- a/github/resource_github_repository_file_migration_test.go +++ b/github/resource_github_repository_file_migration_test.go @@ -165,7 +165,7 @@ func Test_resourceGithubRepositoryFileStateUpgradeV0toV1(t *testing.T) { ts := githubApiMock(buildMockResponsesForRepositoryFileMigrationV0toV1(meta.name, wantRepositoryName, wantRepositoryID)) defer ts.Close() - client := mustGitHubClient(t, ts.URL) + client := mustCreateTestGitHubClient(t, ts.URL) meta.v3client = client got, err := resourceGithubRepositoryFileStateUpgradeV0(t.Context(), d.rawState, meta) diff --git a/github/transport_test.go b/github/transport_test.go index 55fb6b98ca..8409470700 100644 --- a/github/transport_test.go +++ b/github/transport_test.go @@ -28,7 +28,7 @@ func TestEtagTransport(t *testing.T) { }) defer ts.Close() - client := mustGitHubClient(t, ts.URL, github.WithTransport(NewEtagTransport(http.DefaultTransport))) + client := mustCreateTestGitHubClient(t, ts.URL, github.WithTransport(NewEtagTransport(http.DefaultTransport))) ctx := context.WithValue(t.Context(), ctxEtag, "something") r, _, err := client.Repositories.Get(ctx, "test", "blah") if err != nil { @@ -135,7 +135,7 @@ func TestRateLimitTransport_abuseLimit_get(t *testing.T) { }) defer ts.Close() - client := mustGitHubClient(t, ts.URL, github.WithTransport(NewRateLimitTransport(http.DefaultTransport))) + client := mustCreateTestGitHubClient(t, ts.URL, github.WithTransport(NewRateLimitTransport(http.DefaultTransport))) ctx := context.WithValue(t.Context(), ctxId, t.Name()) r, _, err := client.Repositories.Get(ctx, "test", "blah") @@ -164,7 +164,7 @@ func TestRateLimitTransport_abuseLimit_get_cancelled(t *testing.T) { }) defer ts.Close() - client := mustGitHubClient(t, ts.URL, github.WithTransport(NewRateLimitTransport(http.DefaultTransport))) + client := mustCreateTestGitHubClient(t, ts.URL, github.WithTransport(NewRateLimitTransport(http.DefaultTransport))) ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) defer cancel() @@ -206,7 +206,7 @@ func TestRateLimitTransport_abuseLimit_post(t *testing.T) { }) defer ts.Close() - client := mustGitHubClient(t, ts.URL, github.WithTransport(NewRateLimitTransport(http.DefaultTransport))) + client := mustCreateTestGitHubClient(t, ts.URL, github.WithTransport(NewRateLimitTransport(http.DefaultTransport))) ctx := context.WithValue(t.Context(), ctxId, t.Name()) r, _, err := client.Repositories.Create(ctx, "tada", &github.Repository{ @@ -261,7 +261,7 @@ func TestRateLimitTransport_abuseLimit_post_error(t *testing.T) { }) defer ts.Close() - client := mustGitHubClient(t, ts.URL, github.WithTransport(NewRateLimitTransport(http.DefaultTransport))) + client := mustCreateTestGitHubClient(t, ts.URL, github.WithTransport(NewRateLimitTransport(http.DefaultTransport))) ctx := context.WithValue(t.Context(), ctxId, t.Name()) _, _, err := client.Repositories.Create(ctx, "tada", &github.Repository{ @@ -389,7 +389,7 @@ func TestRetryTransport_retry_post_error(t *testing.T) { }) defer ts.Close() - client := mustGitHubClient(t, ts.URL, github.WithTransport(NewRetryTransport(http.DefaultTransport, WithMaxRetries(1)))) + client := mustCreateTestGitHubClient(t, ts.URL, github.WithTransport(NewRetryTransport(http.DefaultTransport, WithMaxRetries(1)))) ctx := context.WithValue(t.Context(), ctxId, t.Name()) _, _, err := client.Repositories.Create(ctx, "tada", &github.Repository{ @@ -447,7 +447,7 @@ func TestRetryTransport_retry_post_success(t *testing.T) { }) defer ts.Close() - client := mustGitHubClient(t, ts.URL, github.WithTransport(NewRetryTransport(http.DefaultTransport, WithMaxRetries(2), WithRetryDelay(time.Second)))) + client := mustCreateTestGitHubClient(t, ts.URL, github.WithTransport(NewRetryTransport(http.DefaultTransport, WithMaxRetries(2), WithRetryDelay(time.Second)))) ctx := context.WithValue(t.Context(), ctxId, t.Name()) _, _, err := client.Repositories.Create(ctx, "tada", &github.Repository{ diff --git a/templates/resources/repository_custom_property.md.tmpl b/templates/resources/repository_custom_property.md.tmpl index a09187b434..266c960778 100644 --- a/templates/resources/repository_custom_property.md.tmpl +++ b/templates/resources/repository_custom_property.md.tmpl @@ -1,35 +1,48 @@ --- page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +subcategory: "" description: |- - Creates and a specific custom property for a GitHub repository +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} --- # {{.Name}} ({{.Type}}) -This resource allows you to create and manage a specific custom property for a GitHub repository. +{{ .Description | trimspace }} +For more information, see the [GitHub API documentation](https://docs.github.com/rest/metadata/custom-properties#create-or-update-repository-custom-property). +{{ if .HasExamples -}} ## Example Usage -> Note that this assumes there already is a custom property defined on the org level called `my-cool-property` of type `string` +{{- range .ExampleFiles }} -{{ tffile "examples/resources/repository_custom_property/example_1.tf" }} +{{ tffile . }} +{{- end }} +{{- end }} -## Argument Reference +{{ .SchemaMarkdown | trimspace }} +{{- if or .HasImport .HasImportIDConfig .HasImportIdentityConfig }} -The following arguments are supported: +## Import -- `repository` - (Required) The repository of the environment. +Import is supported using the following syntax: +{{- end }} +{{- if .HasImportIdentityConfig }} -- `property_type` - (Required) Type of the custom property. Can be one of `single_select`, `multi_select`, `string`, or `true_false` +In Terraform v1.12.0 and later, the [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used with the `identity` attribute, for example: -- `property_name` - (Required) Name of the custom property. Note that a pre-requisiste for this resource is that a custom property of this name has already been defined on the organization level +{{tffile .ImportIdentityConfigFile }} -- `property_value` - (Required) Value of the custom property in the form of an array. Properties of type `single_select`, `string`, and `true_false` are represented as a string array of length 1 +{{ .IdentitySchemaMarkdown | trimspace }} +{{- end }} +{{- if .HasImportIDConfig }} -## Import +In Terraform v1.5.0 and later, the [`import` block](https://developer.hashicorp.com/terraform/language/import) can be used with the `id` attribute, for example: + +{{tffile .ImportIDConfigFile }} +{{- end }} +{{- if .HasImport }} -GitHub Repository Custom Property can be imported using an ID made up of a combination of the names of the organization, repository, custom property separated by a `:` character, e.g. +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: -```shell -terraform import github_repository_custom_property.example organization-name:repo-name:custom-property-name -``` +{{codefile "shell" .ImportFile }} +{{- end }}