diff --git a/github/provider.go b/github/provider.go index 2d5d6dc33a..c51a040b4d 100644 --- a/github/provider.go +++ b/github/provider.go @@ -188,8 +188,9 @@ func Provider() *schema.Provider { "github_release": resourceGithubRelease(), "github_repository": resourceGithubRepository(), "github_repository_autolink_reference": resourceGithubRepositoryAutolinkReference(), - "github_repository_dependabot_security_updates": resourceGithubRepositoryDependabotSecurityUpdates(), + "github_repository_code_scanning_default_setup": resourceGithubRepositoryCodeScanningDefaultSetup(), "github_repository_collaborator": resourceGithubRepositoryCollaborator(), + "github_repository_dependabot_security_updates": resourceGithubRepositoryDependabotSecurityUpdates(), "github_repository_collaborators": resourceGithubRepositoryCollaborators(), "github_repository_custom_property": resourceGithubRepositoryCustomProperty(), "github_repository_deploy_key": resourceGithubRepositoryDeployKey(), diff --git a/github/resource_github_repository_code_scanning_default_setup.go b/github/resource_github_repository_code_scanning_default_setup.go new file mode 100644 index 0000000000..eca684daba --- /dev/null +++ b/github/resource_github_repository_code_scanning_default_setup.go @@ -0,0 +1,246 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/google/go-github/v84/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceGithubRepositoryCodeScanningDefaultSetup() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceGithubRepositoryCodeScanningDefaultSetupCreateOrUpdate, + ReadContext: resourceGithubRepositoryCodeScanningDefaultSetupRead, + UpdateContext: resourceGithubRepositoryCodeScanningDefaultSetupCreateOrUpdate, + DeleteContext: resourceGithubRepositoryCodeScanningDefaultSetupDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGithubRepositoryCodeScanningDefaultSetupImport, + }, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(5 * time.Minute), + Update: schema.DefaultTimeout(5 * time.Minute), + Delete: schema.DefaultTimeout(5 * time.Minute), + }, + + CustomizeDiff: diffRepository, + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + Description: "The GitHub repository name.", + }, + "repository_id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the GitHub repository.", + }, + "state": { + Type: schema.TypeString, + Required: true, + Description: "The desired state of code scanning default setup. Must be `configured` or `not-configured`.", + ValidateDiagFunc: validation.ToDiagFunc( + validation.StringInSlice([]string{"configured", "not-configured"}, false), + ), + }, + "query_suite": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "The query suite to use. Must be `default` or `extended`.", + ValidateDiagFunc: validation.ToDiagFunc( + validation.StringInSlice([]string{"default", "extended"}, false), + ), + }, + "languages": { + Type: schema.TypeSet, + Optional: true, + Computed: true, + Description: "The languages to enable for code scanning. Supported values include `actions`, `c-cpp`, `csharp`, `go`, `java-kotlin`, `javascript-typescript`, `python`, `ruby`, `swift`.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + } +} + +func resourceGithubRepositoryCodeScanningDefaultSetupCreateOrUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + repoName := d.Get("repository").(string) + state := d.Get("state").(string) + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return diag.Errorf("error reading repository %s/%s: %s", owner, repoName, err) + } + if repo.GetArchived() { + return diag.Errorf("repository %s/%s is archived", owner, repoName) + } + + options := &github.UpdateDefaultSetupConfigurationOptions{ + State: state, + } + + if v, ok := d.GetOk("query_suite"); ok { + qs := v.(string) + options.QuerySuite = &qs + } + + if v, ok := d.GetOk("languages"); ok { + options.Languages = expandStringList(v.(*schema.Set).List()) + } + + _, _, err = client.CodeScanning.UpdateDefaultSetupConfiguration(ctx, owner, repoName, options) + if err != nil { + // 202 Accepted is expected — go-github surfaces it as AcceptedError + var acceptedErr *github.AcceptedError + if !errors.As(err, &acceptedErr) { + return diag.Errorf("error updating code scanning default setup for %s/%s: %s", owner, repoName, err) + } + } + + d.SetId(repoName) + + var timeout time.Duration + if d.IsNewResource() { + timeout = d.Timeout(schema.TimeoutCreate) + } else { + timeout = d.Timeout(schema.TimeoutUpdate) + } + + config, err := waitForCodeScanningState(ctx, client, owner, repoName, state, timeout) + if err != nil { + return diag.Errorf("error waiting for code scanning default setup state for %s/%s: %s", owner, repoName, err) + } + + return setCodeScanningDefaultSetupState(d, config) +} + +func resourceGithubRepositoryCodeScanningDefaultSetupRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + repoName := d.Get("repository").(string) + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Repository not found, removing from state", map[string]any{ + "owner": owner, + "repository": repoName, + }) + d.SetId("") + return nil + } + return diag.Errorf("error reading repository %s/%s: %s", owner, repoName, err) + } + if err := d.Set("repository_id", int(repo.GetID())); err != nil { + return diag.Errorf("error setting repository_id: %s", err) + } + + config, _, err := client.CodeScanning.GetDefaultSetupConfiguration(ctx, owner, repoName) + if err != nil { + return diag.Errorf("error reading code scanning default setup for %s/%s: %s", owner, repoName, err) + } + + return setCodeScanningDefaultSetupState(d, config) +} + +func resourceGithubRepositoryCodeScanningDefaultSetupDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + repoName := d.Get("repository").(string) + + options := &github.UpdateDefaultSetupConfigurationOptions{ + State: "not-configured", + } + + _, _, err := client.CodeScanning.UpdateDefaultSetupConfiguration(ctx, owner, repoName, options) + if err != nil { + var acceptedErr *github.AcceptedError + var ghErr *github.ErrorResponse + switch { + case errors.As(err, &acceptedErr): + // 202 Accepted is expected + case errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound: + // repository already gone + return nil + default: + return diag.Errorf("error disabling code scanning default setup for %s/%s: %s", owner, repoName, err) + } + } + + tflog.Info(ctx, "Code scanning default setup disabled", map[string]any{ + "owner": owner, + "repository": repoName, + }) + return nil +} + +func resourceGithubRepositoryCodeScanningDefaultSetupImport(_ context.Context, d *schema.ResourceData, _ any) ([]*schema.ResourceData, error) { + repoName := d.Id() + if repoName == "" { + return nil, fmt.Errorf("repository name must not be empty") + } + + if err := d.Set("repository", repoName); err != nil { + return nil, err + } + + return []*schema.ResourceData{d}, nil +} + +func setCodeScanningDefaultSetupState(d *schema.ResourceData, config *github.DefaultSetupConfiguration) diag.Diagnostics { + if err := d.Set("state", config.GetState()); err != nil { + return diag.Errorf("error setting state: %s", err) + } + if err := d.Set("query_suite", config.GetQuerySuite()); err != nil { + return diag.Errorf("error setting query_suite: %s", err) + } + if err := d.Set("languages", config.Languages); err != nil { + return diag.Errorf("error setting languages: %s", err) + } + return nil +} + +func waitForCodeScanningState(ctx context.Context, client *github.Client, owner, repo, targetState string, timeout time.Duration) (*github.DefaultSetupConfiguration, error) { + conf := &retry.StateChangeConf{ + Pending: []string{"pending"}, + Target: []string{targetState}, + Timeout: timeout, + Delay: 1 * time.Second, + MinTimeout: 1 * time.Second, + Refresh: func() (any, string, error) { + config, _, err := client.CodeScanning.GetDefaultSetupConfiguration(ctx, owner, repo) + if err != nil { + return nil, "", err + } + state := config.GetState() + if state == targetState { + return config, state, nil + } + return config, "pending", nil + }, + } + + result, err := conf.WaitForStateContext(ctx) + if err != nil { + return nil, err + } + if result == nil { + return nil, fmt.Errorf("code scanning default setup returned nil result for %s/%s", owner, repo) + } + + return result.(*github.DefaultSetupConfiguration), nil +} diff --git a/github/resource_github_repository_code_scanning_default_setup_test.go b/github/resource_github_repository_code_scanning_default_setup_test.go new file mode 100644 index 0000000000..657d290d65 --- /dev/null +++ b/github/resource_github_repository_code_scanning_default_setup_test.go @@ -0,0 +1,213 @@ +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/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func testAccCodeScanningDefaultSetupConfig(repoName, extraAttrs string) string { + return fmt.Sprintf(` + resource "github_repository" "test" { + name = "%s" + visibility = "public" + auto_init = true + } + + resource "github_repository_code_scanning_default_setup" "test" { + repository = github_repository.test.name + %s + } + `, repoName, extraAttrs) +} + +func TestAccGithubRepositoryCodeScanningDefaultSetup(t *testing.T) { + t.Run("configures with explicit query suite and languages", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%srepo-code-scanning-%s", testResourcePrefix, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: testAccCodeScanningDefaultSetupConfig(repoName, ` + state = "configured" + query_suite = "extended" + languages = ["javascript-typescript", "python"] + `), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_repository_code_scanning_default_setup.test", + tfjsonpath.New("state"), knownvalue.StringExact("configured")), + statecheck.ExpectKnownValue("github_repository_code_scanning_default_setup.test", + tfjsonpath.New("query_suite"), knownvalue.StringExact("extended")), + statecheck.ExpectKnownValue("github_repository_code_scanning_default_setup.test", + tfjsonpath.New("languages"), knownvalue.SetSizeExact(2)), + }, + }, + }, + }) + }) + + t.Run("is idempotent when already not-configured", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%srepo-code-scanning-%s", testResourcePrefix, randomID) + config := testAccCodeScanningDefaultSetupConfig(repoName, `state = "not-configured"`) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_repository_code_scanning_default_setup.test", + tfjsonpath.New("state"), knownvalue.StringExact("not-configured")), + }, + }, + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_repository_code_scanning_default_setup.test", + tfjsonpath.New("state"), knownvalue.StringExact("not-configured")), + }, + }, + }, + }) + }) + + t.Run("imports code scanning default setup", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%srepo-code-scanning-%s", testResourcePrefix, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: testAccCodeScanningDefaultSetupConfig(repoName, `state = "configured"`), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_repository_code_scanning_default_setup.test", + tfjsonpath.New("repository"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_repository_code_scanning_default_setup.test", + tfjsonpath.New("repository_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_repository_code_scanning_default_setup.test", + tfjsonpath.New("state"), knownvalue.StringExact("configured")), + statecheck.ExpectKnownValue("github_repository_code_scanning_default_setup.test", + tfjsonpath.New("query_suite"), knownvalue.NotNull()), + }, + }, + { + ResourceName: "github_repository_code_scanning_default_setup.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) + + t.Run("specifies languages not present in repo without error", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%srepo-code-scanning-%s", testResourcePrefix, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: testAccCodeScanningDefaultSetupConfig(repoName, ` + state = "configured" + languages = ["go", "java-kotlin"] + `), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_repository_code_scanning_default_setup.test", + tfjsonpath.New("state"), knownvalue.StringExact("configured")), + }, + }, + }, + }) + }) + + t.Run("prevents configuring on archived repository", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%srepo-code-scanning-%s", testResourcePrefix, randomID) + repoConfig := ` + resource "github_repository" "test" { + name = "%s" + visibility = "public" + auto_init = true + archived = %t + } + %s + ` + codeScanningConfig := ` + resource "github_repository_code_scanning_default_setup" "test" { + repository = github_repository.test.name + state = "configured" + } + ` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(repoConfig, repoName, false, ""), + }, + { + Config: fmt.Sprintf(repoConfig, repoName, true, codeScanningConfig), + ExpectError: regexp.MustCompile("is archived"), + }, + }, + }) + }) + + t.Run("full lifecycle: configure, update query suite, unconfigure", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("%srepo-code-scanning-%s", testResourcePrefix, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnauthenticated(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: testAccCodeScanningDefaultSetupConfig(repoName, ` + state = "configured" + query_suite = "default" + `), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_repository_code_scanning_default_setup.test", + tfjsonpath.New("state"), knownvalue.StringExact("configured")), + statecheck.ExpectKnownValue("github_repository_code_scanning_default_setup.test", + tfjsonpath.New("query_suite"), knownvalue.StringExact("default")), + }, + }, + { + Config: testAccCodeScanningDefaultSetupConfig(repoName, ` + state = "configured" + query_suite = "extended" + `), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_repository_code_scanning_default_setup.test", + tfjsonpath.New("state"), knownvalue.StringExact("configured")), + statecheck.ExpectKnownValue("github_repository_code_scanning_default_setup.test", + tfjsonpath.New("query_suite"), knownvalue.StringExact("extended")), + }, + }, + { + Config: testAccCodeScanningDefaultSetupConfig(repoName, `state = "not-configured"`), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_repository_code_scanning_default_setup.test", + tfjsonpath.New("state"), knownvalue.StringExact("not-configured")), + }, + }, + }, + }) + }) +} diff --git a/website/docs/r/repository_code_scanning_default_setup.html.markdown b/website/docs/r/repository_code_scanning_default_setup.html.markdown new file mode 100644 index 0000000000..50c31db3ce --- /dev/null +++ b/website/docs/r/repository_code_scanning_default_setup.html.markdown @@ -0,0 +1,68 @@ +--- +layout: "github" +page_title: "GitHub: github_repository_code_scanning_default_setup" +description: |- + Manages code scanning default setup for a repository +--- + +# github_repository_code_scanning_default_setup + +This resource allows you to manage the code scanning default setup configuration for a GitHub repository. +When enabled, GitHub automatically configures CodeQL analysis for supported languages in the repository. + +See the [documentation](https://docs.github.com/en/code-security/code-scanning/enabling-code-scanning/configuring-default-setup-for-code-scanning) +for details of usage and how this will impact your repository. + +## Example Usage + +### Basic usage + +```hcl +resource "github_repository" "example" { + name = "my-repo" + visibility = "public" + auto_init = true +} + +resource "github_repository_code_scanning_default_setup" "example" { + repository = github_repository.example.name + state = "configured" +} +``` + +### With query suite and languages + +```hcl +resource "github_repository" "example" { + name = "my-repo" + visibility = "public" + auto_init = true +} + +resource "github_repository_code_scanning_default_setup" "example" { + repository = github_repository.example.name + state = "configured" + query_suite = "extended" + languages = ["javascript-typescript", "python"] +} +``` + +## Argument Reference + +The following arguments are supported: + +* `repository` - (Required) The name of the GitHub repository. + +* `state` - (Required) The desired state of code scanning default setup. Must be `configured` or `not-configured`. See the [REST API docs](https://docs.github.com/en/rest/code-scanning/code-scanning#update-a-code-scanning-default-setup-configuration). + +* `query_suite` - (Optional) The [query suite](https://docs.github.com/en/code-security/code-scanning/managing-your-code-scanning-configuration/codeql-query-suites) to use. Must be `default` or `extended`. + +* `languages` - (Optional) The CodeQL languages to be analyzed. If not specified, default setup [automatically includes all supported languages](https://github.blog/changelog/2023-10-23-code-scanning-default-setup-automatically-includes-all-codeql-supported-languages/). Supported values: `actions`, `c-cpp`, `csharp`, `go`, `java-kotlin`, `javascript-typescript`, `python`, `ruby`, `swift`. See the [REST API docs](https://docs.github.com/en/rest/code-scanning/code-scanning#update-a-code-scanning-default-setup-configuration). + +## Import + +Code scanning default setup can be imported using the repository name: + +```sh +terraform import github_repository_code_scanning_default_setup.example my-repo +```