diff --git a/github/data_source_github_repository_code_scanning.go b/github/data_source_github_repository_code_scanning.go new file mode 100644 index 0000000000..500d321020 --- /dev/null +++ b/github/data_source_github_repository_code_scanning.go @@ -0,0 +1,73 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v55/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func dataSourceGithubRepositoryCodeScanning() *schema.Resource { + return &schema.Resource{ + Read: dataSourceGithubRepositoryCodeScanningRead, + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + }, + "owner": { + Type: schema.TypeString, + Required: true, + }, + "languages": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "query_suite": { + Type: schema.TypeString, + Computed: true, + }, + "state": { + Type: schema.TypeString, + Computed: true, + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func dataSourceGithubRepositoryCodeScanningRead(d *schema.ResourceData, meta interface{}) error { + repository := d.Get("repository").(string) + owner := meta.(*Owner).name + + client := meta.(*Owner).v3client + ctx := context.Background() + + config := &github.DefaultSetupConfiguration{} + config, _, err := client.CodeScanning.GetDefaultSetupConfiguration( + ctx, + owner, + repository, + ) + if err != nil { + return err + } + + timeString := "" + + if config.UpdatedAt != nil { + timeString = config.UpdatedAt.String() + } + + d.SetId(buildTwoPartID(owner, repository)) + d.Set("languages", config.Languages) + d.Set("query_suite", config.GetQuerySuite()) + d.Set("state", config.GetState()) + d.Set("updated_at", timeString) + + return nil +} diff --git a/github/data_source_github_repository_code_scanning_test.go b/github/data_source_github_repository_code_scanning_test.go new file mode 100644 index 0000000000..cee83f816b --- /dev/null +++ b/github/data_source_github_repository_code_scanning_test.go @@ -0,0 +1,188 @@ +package github + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubRepositoryCodeScanningDataSource(t *testing.T) { + owner := os.Getenv("GITHUB_ORGANIZATION") + if owner == "" { + t.FailNow() + } + + randomId := acctest.RandStringFromCharSet(6, acctest.CharSetAlphaNum) + t.Run("manages the code scanning setup for a repository", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-cs-%s" + auto_init = true + } + + resource "github_repository_file" "test_py" { + repository = github_repository.test.name + branch = "main" + file = "main.py" + content = <<-EOT + if __name__ == "__main__": + print ("This is a test") + EOT + commit_message = "Managed by Terraform" + commit_author = "Terraform User" + commit_email = "terraform@example.com" + overwrite_on_create = true + } + + resource "github_repository_code_scanning" "test" { + repository = github_repository.test.name + owner = "%s" + + state = "configured" + query_suite = "default" + + depends_on = ["github_repository_file.test_py"] + } + `, randomId, owner) + + config2 := config + fmt.Sprintf(` + data "github_repository_code_scanning" "test" { + repository = github_repository.test.name + owner = "%s" + } + `, owner) + + const resourceName = "data.github_repository_code_scanning.test" + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "languages.#", "1"), + resource.TestCheckResourceAttr(resourceName, "languages.0", "python"), + resource.TestCheckResourceAttr(resourceName, "state", "configured"), + resource.TestCheckResourceAttr(resourceName, "query_suite", "default"), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc(), + }, + { + Config: config2, + Check: check, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("manages the code scanning setup for a repository with multiple languages", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-cs-%s" + auto_init = true + } + + resource "github_repository_file" "test_py" { + repository = github_repository.test.name + branch = "main" + file = "main.py" + content = <<-EOT + if __name__ == "__main__": + print ("This is a test") + EOT + commit_message = "Managed by Terraform" + commit_author = "Terraform User" + commit_email = "terraform@example.com" + overwrite_on_create = true + } + + resource "github_repository_file" "test_js" { + repository = github_repository.test.name + branch = "main" + file = "main.js" + content = <<-EOT + function main() { + console.log("This is a test"); + } + EOT + commit_message = "Managed by Terraform" + commit_author = "Terraform User" + commit_email = "terraform@example.com" + overwrite_on_create = true + } + + resource "github_repository_code_scanning" "test" { + repository = github_repository.test.name + owner = "%s" + + state = "configured" + query_suite = "extended" + + depends_on = ["github_repository_file.test_js", "github_repository_file.test_py"] + } + `, randomId, owner) + + config2 := config + fmt.Sprintf(` + data "github_repository_code_scanning" "test" { + repository = github_repository.test.name + owner = "%s" + } + `, owner) + + const resourceName = "data.github_repository_code_scanning.test" + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "languages.#", "2"), + resource.TestCheckResourceAttr(resourceName, "languages.0", "python"), + resource.TestCheckResourceAttr(resourceName, "languages.1", "javascript-typescript"), + resource.TestCheckResourceAttr(resourceName, "state", "configured"), + resource.TestCheckResourceAttr(resourceName, "query_suite", "extended"), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc(), + }, + { + Config: config2, + Check: check, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) +} diff --git a/github/provider.go b/github/provider.go index 70441a0349..45bfe28f57 100644 --- a/github/provider.go +++ b/github/provider.go @@ -142,6 +142,7 @@ func Provider() terraform.ResourceProvider { "github_repository": resourceGithubRepository(), "github_repository_autolink_reference": resourceGithubRepositoryAutolinkReference(), "github_repository_dependabot_security_updates": resourceGithubRepositoryDependabotSecurityUpdates(), + "github_repository_code_scanning": resourceGithubRepositoryCodeScanning(), "github_repository_collaborator": resourceGithubRepositoryCollaborator(), "github_repository_collaborators": resourceGithubRepositoryCollaborators(), "github_repository_deploy_key": resourceGithubRepositoryDeployKey(), @@ -213,6 +214,7 @@ func Provider() terraform.ResourceProvider { "github_repository": dataSourceGithubRepository(), "github_repository_autolink_references": dataSourceGithubRepositoryAutolinkReferences(), "github_repository_branches": dataSourceGithubRepositoryBranches(), + "github_repository_code_scanning": dataSourceGithubRepositoryCodeScanning(), "github_repository_environments": dataSourceGithubRepositoryEnvironments(), "github_repository_deploy_keys": dataSourceGithubRepositoryDeployKeys(), "github_repository_deployment_branch_policies": dataSourceGithubRepositoryDeploymentBranchPolicies(), diff --git a/github/resource_github_issue_labels.go b/github/resource_github_issue_labels.go index 92c8c4e7f5..3f0a7e53f0 100644 --- a/github/resource_github_issue_labels.go +++ b/github/resource_github_issue_labels.go @@ -5,7 +5,7 @@ import ( "log" "strings" - "github.com/google/go-github/v52/github" + "github.com/google/go-github/v55/github" "github.com/hashicorp/terraform-plugin-sdk/helper/schema" ) diff --git a/github/resource_github_repository_code_scanning.go b/github/resource_github_repository_code_scanning.go new file mode 100644 index 0000000000..7156a6906d --- /dev/null +++ b/github/resource_github_repository_code_scanning.go @@ -0,0 +1,230 @@ +package github + +import ( + "context" + "encoding/json" + "errors" + "io" + + "github.com/google/go-github/v55/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/helper/validation" +) + +const ( + codeQLWorkflowRunFailure = "codeql setup workflow failed for repository" + codeQLWorkflowRunInFlight = "codeql setup for repository still in progress" +) + +type DefaultSetupConfigurationResponse struct { + RunId int64 `json:"run_id"` + RunUrl string `json:"run_url"` +} + +func resourceGithubRepositoryCodeScanning() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubRepositoryCodeScanningCreate, + Read: resourceGithubRepositoryCodeScanningRead, + Update: resourceGithubRepositoryCodeScanningUpdate, + Delete: resourceGithubRepositoryCodeScanningDelete, + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + Description: "The GitHub repository", + }, + "owner": { + Type: schema.TypeString, + Required: true, + }, + "languages": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "query_suite": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice([]string{ + "default", + "extended", + }, false), + }, + "state": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{ + "configured", + "not-configured", + }, false), + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + }, + "wait": { + Type: schema.TypeBool, + Default: true, + Optional: true, + }, + }, + } +} + +func resourceGithubRepositoryCodeScanningCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + + owner := meta.(*Owner).name + repoName := d.Get("repository").(string) + + createUpdateOpts := createUpdateCodeScanning(d, meta) + ctx := context.Background() + + _, response, err := client.CodeScanning.UpdateDefaultSetupConfiguration(ctx, + owner, + repoName, + &createUpdateOpts, + ) + if err != nil { + return err + } + + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + return err + } + + responseData := &DefaultSetupConfigurationResponse{} + if err = json.Unmarshal(body, responseData); err != nil { + return err + } + + wait := d.Get("wait") + + if wait.(bool) { + err = resource.Retry(d.Timeout(schema.TimeoutCreate), + waitForCodeQLActionCompleteFunc(ctx, client, d.Id(), responseData.RunId)) + if err != nil { + return err + } + } + + d.SetId(buildTwoPartID(owner, repoName)) + + return resourceGithubRepositoryCodeScanningRead(d, meta) +} + +func resourceGithubRepositoryCodeScanningDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + + owner, repoName, err := parseTwoPartID(d.Id(), "owner", "repository") + if err != nil { + return err + } + + createUpdateOpts := createUpdateCodeScanning(d, meta) + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + _, _, err = client.CodeScanning.UpdateDefaultSetupConfiguration(ctx, owner, repoName, &createUpdateOpts) + return err +} + +func resourceGithubRepositoryCodeScanningRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + + owner, repoName, err := parseTwoPartID(d.Id(), "owner", "repository") + if err != nil { + return err + } + + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + config, _, err := client.CodeScanning.GetDefaultSetupConfiguration(ctx, owner, repoName) + if err != nil { + return err + } + + timeString := "" + + if config.UpdatedAt != nil { + timeString = config.UpdatedAt.String() + } + + d.Set("repository", repoName) + d.Set("owner", owner) + d.Set("state", config.GetState()) + d.Set("query_suite", config.GetQuerySuite()) + d.Set("languages", config.Languages) + d.Set("updated_at", timeString) + + return nil +} + +func resourceGithubRepositoryCodeScanningUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + + owner, repoName, err := parseTwoPartID(d.Id(), "owner", "repository") + if err != nil { + return err + } + + createUpdateOpts := createUpdateCodeScanning(d, meta) + ctx := context.Background() + + _, _, err = client.CodeScanning.UpdateDefaultSetupConfiguration(ctx, + owner, + repoName, + &createUpdateOpts, + ) + if err != nil { + return err + } + + d.SetId(buildTwoPartID(owner, repoName)) + + return resourceGithubRepositoryCodeScanningRead(d, meta) +} + +func createUpdateCodeScanning(d *schema.ResourceData, meta interface{}) github.UpdateDefaultSetupConfigurationOptions { + data := github.UpdateDefaultSetupConfigurationOptions{} + + if v, ok := d.GetOk("query_suite"); ok { + querySuite := v.(string) + data.QuerySuite = &querySuite + } + + data.State = d.Get("state").(string) + + return data +} + +func waitForCodeQLActionCompleteFunc(ctx context.Context, client *github.Client, resourceId string, runId int64) resource.RetryFunc { + return func() *resource.RetryError { + owner, repoName, err := parseTwoPartID(resourceId, "owner", "repository") + if err != nil { + return resource.NonRetryableError(err) + } + + workflowRun, _, err := client.Actions.GetWorkflowRunByID(ctx, owner, repoName, runId) + if err != nil { + return resource.NonRetryableError(err) + } + + switch *workflowRun.Status { + case "success": + return nil + case "failure", "timed out", "cancelled": + return resource.NonRetryableError(errors.New(codeQLWorkflowRunFailure)) + case "queued", "in progress", "waiting": + return resource.RetryableError(errors.New(codeQLWorkflowRunInFlight)) + } + + return nil + } +} diff --git a/github/resource_github_repository_code_scanning_test.go b/github/resource_github_repository_code_scanning_test.go new file mode 100644 index 0000000000..9a613e2824 --- /dev/null +++ b/github/resource_github_repository_code_scanning_test.go @@ -0,0 +1,211 @@ +package github + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubRepositoryCodeScanning(t *testing.T) { + owner := os.Getenv("GITHUB_ORGANIZATION") + if owner == "" { + t.FailNow() + } + + t.Run("enables the code scanning setup for a repository", func(t *testing.T) { + repoName := fmt.Sprintf("tf-acc-test-code-scanning-%s", acctest.RandString(5)) + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "%s" + auto_init = true + } + + resource "github_repository_file" "test_py" { + repository = github_repository.test.name + branch = "main" + file = "main.py" + content = <<-EOT + if __name__ == "__main__": + print ("This is a test") + EOT + commit_message = "Managed by Terraform" + commit_author = "Terraform User" + commit_email = "terraform@example.com" + overwrite_on_create = true + } + + resource "github_repository_code_scanning" "test" { + repository = github_repository.test.name + owner = "%s" + + state = "configured" + query_suite = "default" + + depends_on = ["github_repository_file.test_py"] + } + `, repoName, owner) + + const resourceName = "resource.github_repository_code_scanning.test" + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "state", "configured"), + resource.TestCheckResourceAttr(resourceName, "query_suite", "default"), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("enables the code scanning setup for a repository with multiple languages", func(t *testing.T) { + repoName := fmt.Sprintf("tf-acc-test-code-scanning-%s", acctest.RandString(5)) + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "%s" + auto_init = true + } + + resource "github_repository_file" "test_py" { + repository = github_repository.test.name + branch = "main" + file = "main.py" + content = <<-EOT + if __name__ == "__main__": + print ("This is a test") + EOT + commit_message = "Managed by Terraform" + commit_author = "Terraform User" + commit_email = "terraform@example.com" + overwrite_on_create = true + } + + resource "github_repository_file" "test_js" { + repository = github_repository.test.name + branch = "main" + file = "main.js" + content = <<-EOT + function main() { + console.log("This is a test"); + } + EOT + commit_message = "Managed by Terraform" + commit_author = "Terraform User" + commit_email = "terraform@example.com" + overwrite_on_create = true + } + + resource "github_repository_code_scanning" "test" { + repository = github_repository.test.name + owner = "%s" + + state = "configured" + query_suite = "extended" + + depends_on = ["github_repository_file.test_js", "github_repository_file.test_py"] + } + `, repoName, owner) + + const resourceName = "resource.github_repository_code_scanning.test" + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "state", "configured"), + resource.TestCheckResourceAttr(resourceName, "query_suite", "extended"), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("disables the code scanning setup for a repository", func(t *testing.T) { + repoName := fmt.Sprintf("tf-acc-test-code-scanning-%s", acctest.RandString(5)) + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "%s" + auto_init = true + } + + resource "github_repository_code_scanning" "test" { + repository = github_repository.test.name + owner = "%s" + + state = "not-configured" + query_suite = "extended" + } + `, repoName, owner) + + const resourceName = "resource.github_repository_code_scanning.test" + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "state", "not-configured"), + resource.TestCheckResourceAttr(resourceName, "query_suite", "extended"), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) +}