From 94669cfae9270f67ab3a125e5555c21a6dec6292 Mon Sep 17 00:00:00 2001 From: Trent Millar Date: Tue, 6 Dec 2022 15:05:32 -0700 Subject: [PATCH] feat(github_release): adding github_release resource and tests (#1122) * feat(github_release): adding github_release resource and tests * feat(docs) adding github_release page to website docs * chore: update changelog with this pr's new resource * fix: adding node_id and release_id to resource attributes * Update CHANGELOG.md * Fix broken merge/build Co-authored-by: Keegan Campbell --- github/provider.go | 1 + github/resource_github_release.go | 239 +++++++++++++++++++++++++ github/resource_github_release_test.go | 208 +++++++++++++++++++++ website/docs/r/release.html.markdown | 104 +++++++++++ website/github.erb | 3 + 5 files changed, 555 insertions(+) create mode 100644 github/resource_github_release.go create mode 100644 github/resource_github_release_test.go create mode 100644 website/docs/r/release.html.markdown diff --git a/github/provider.go b/github/provider.go index ff739710b0..405e3eb2ab 100644 --- a/github/provider.go +++ b/github/provider.go @@ -116,6 +116,7 @@ func Provider() terraform.ResourceProvider { "github_organization_webhook": resourceGithubOrganizationWebhook(), "github_project_card": resourceGithubProjectCard(), "github_project_column": resourceGithubProjectColumn(), + "github_release": resourceGithubRelease(), "github_repository": resourceGithubRepository(), "github_repository_autolink_reference": resourceGithubRepositoryAutolinkReference(), "github_repository_collaborator": resourceGithubRepositoryCollaborator(), diff --git a/github/resource_github_release.go b/github/resource_github_release.go new file mode 100644 index 0000000000..e1062499aa --- /dev/null +++ b/github/resource_github_release.go @@ -0,0 +1,239 @@ +package github + +import ( + "context" + "fmt" + "log" + "strconv" + + "github.com/google/go-github/v48/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func resourceGithubRelease() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubReleaseCreateUpdate, + Update: resourceGithubReleaseCreateUpdate, + Read: resourceGithubReleaseRead, + Delete: resourceGithubReleaseDelete, + Importer: &schema.ResourceImporter{ + State: resourceGithubReleaseImport, + }, + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "tag_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "target_commitish": { + Type: schema.TypeString, + Default: "main", + Optional: true, + ForceNew: true, + }, + "name": { + Type: schema.TypeString, + Optional: true, + ForceNew: false, + }, + "body": { + Type: schema.TypeString, + Optional: true, + ForceNew: false, + }, + "draft": { + Type: schema.TypeBool, + Default: true, + Optional: true, + ForceNew: true, + }, + "prerelease": { + Type: schema.TypeBool, + Default: true, + Optional: true, + }, + "generate_release_notes": { + Type: schema.TypeBool, + Default: false, + Optional: true, + }, + "discussion_category_name": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "etag": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func resourceGithubReleaseCreateUpdate(d *schema.ResourceData, meta interface{}) error { + ctx := context.Background() + if !d.IsNewResource() { + ctx = context.WithValue(ctx, ctxId, d.Id()) + } + + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + repoName := d.Get("repository").(string) + tagName := d.Get("tag_name").(string) + targetCommitish := d.Get("target_commitish").(string) + draft := d.Get("draft").(bool) + prerelease := d.Get("prerelease").(bool) + generateReleaseNotes := d.Get("generate_release_notes").(bool) + + req := &github.RepositoryRelease{ + TagName: github.String(tagName), + TargetCommitish: github.String(targetCommitish), + Draft: github.Bool(draft), + Prerelease: github.Bool(prerelease), + GenerateReleaseNotes: github.Bool(generateReleaseNotes), + } + + if v, ok := d.GetOk("body"); ok { + req.Body = github.String(v.(string)) + } + + if v, ok := d.GetOk("name"); ok { + req.Name = github.String(v.(string)) + } + + if v, ok := d.GetOk("discussion_category_name"); ok { + req.DiscussionCategoryName = github.String(v.(string)) + } + + var release *github.RepositoryRelease + var resp *github.Response + var err error + if d.IsNewResource() { + log.Printf("[DEBUG] Creating release: %s (%s/%s)", + targetCommitish, owner, repoName) + release, resp, err = client.Repositories.CreateRelease(ctx, owner, repoName, req) + if resp != nil { + log.Printf("[DEBUG] Response from creating release: %#v", *resp) + } + } else { + number := d.Get("number").(int64) + log.Printf("[DEBUG] Updating release: %d:%s (%s/%s)", + number, targetCommitish, owner, repoName) + release, resp, err = client.Repositories.EditRelease(ctx, owner, repoName, number, req) + if resp != nil { + log.Printf("[DEBUG] Response from updating release: %#v", *resp) + } + } + + if err != nil { + return err + } + transformResponseToResourceData(d, release, repoName) + return nil +} + +func resourceGithubReleaseRead(d *schema.ResourceData, meta interface{}) error { + repository := d.Get("repository").(string) + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + releaseID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return err + } + if releaseID == 0 { + return fmt.Errorf("`release_id` must be present") + } + + release, _, err := client.Repositories.GetRelease(ctx, owner, repository, releaseID) + if err != nil { + return err + } + transformResponseToResourceData(d, release, repository) + return nil +} + +func resourceGithubReleaseDelete(d *schema.ResourceData, meta interface{}) error { + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + repository := d.Get("repository").(string) + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + + releaseIDStr := d.Id() + releaseID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return unconvertibleIdErr(releaseIDStr, err) + } + if releaseID == 0 { + return fmt.Errorf("`release_id` must be present") + } + + _, err = client.Repositories.DeleteRelease(ctx, owner, repository, releaseID) + if err != nil { + return fmt.Errorf("error deleting GitHub release reference %s/%s (%s): %s", + fmt.Sprint(releaseID), repository, owner, err) + } + return nil +} + +func resourceGithubReleaseImport(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + repoName, releaseIDStr, err := parseTwoPartID(d.Id(), "repository", "release") + if err != nil { + return []*schema.ResourceData{d}, err + } + + releaseID, err := strconv.ParseInt(releaseIDStr, 10, 64) + if err != nil { + return []*schema.ResourceData{d}, unconvertibleIdErr(releaseIDStr, err) + } + if releaseID == 0 { + return []*schema.ResourceData{d}, fmt.Errorf("`release_id` must be present") + } + log.Printf("[DEBUG] Importing release with ID: %d, for repository: %s", releaseID, repoName) + + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + ctx := context.Background() + repository, _, err := client.Repositories.Get(ctx, owner, repoName) + if repository == nil || err != nil { + return []*schema.ResourceData{d}, err + } + d.Set("repository", *repository.Name) + + release, _, err := client.Repositories.GetRelease(ctx, owner, *repository.Name, releaseID) + if release == nil || err != nil { + return []*schema.ResourceData{d}, err + } + d.SetId(strconv.FormatInt(release.GetID(), 10)) + + return []*schema.ResourceData{d}, nil +} + +func transformResponseToResourceData(d *schema.ResourceData, release *github.RepositoryRelease, repository string) { + d.SetId(strconv.FormatInt(release.GetID(), 10)) + d.Set("release_id", release.GetID()) + d.Set("node_id", release.GetNodeID()) + d.Set("repository", repository) + d.Set("tag_name", release.GetTagName()) + d.Set("target_commitish", release.GetTargetCommitish()) + d.Set("name", release.GetName()) + d.Set("body", release.GetBody()) + d.Set("draft", release.GetDraft()) + d.Set("generate_release_notes", release.GetGenerateReleaseNotes()) + d.Set("prerelease", release.GetPrerelease()) + d.Set("discussion_category_name", release.GetDiscussionCategoryName()) + d.Set("created_at", release.GetCreatedAt()) + d.Set("published_at", release.GetPublishedAt()) + d.Set("url", release.GetURL()) + d.Set("html_url", release.GetHTMLURL()) + d.Set("assets_url", release.GetAssetsURL()) + d.Set("upload_url", release.GetUploadURL()) + d.Set("zipball_url", release.GetZipballURL()) + d.Set("tarball_url", release.GetTarballURL()) +} diff --git a/github/resource_github_release_test.go b/github/resource_github_release_test.go new file mode 100644 index 0000000000..57ba6437b1 --- /dev/null +++ b/github/resource_github_release_test.go @@ -0,0 +1,208 @@ +package github + +import ( + "fmt" + "github.com/hashicorp/terraform-plugin-sdk/terraform" + "log" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubReleaseResource(t *testing.T) { + + t.Run("create a release with defaults", func(t *testing.T) { + + randomRepoPart := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + randomVersion := fmt.Sprintf("v1.0.%d", acctest.RandIntRange(0, 9999)) + + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + auto_init = true + } + + resource "github_release" "test" { + repository = github_repository.test.name + tag_name = "%s" + } + `, randomRepoPart, randomVersion) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_release.test", "tag_name", randomVersion, + ), + resource.TestCheckResourceAttr( + "github_release.test", "target_commitish", "main", + ), + resource.TestCheckResourceAttr( + "github_release.test", "name", "", + ), + resource.TestCheckResourceAttr( + "github_release.test", "body", "", + ), + resource.TestCheckResourceAttr( + "github_release.test", "draft", "true", + ), + resource.TestCheckResourceAttr( + "github_release.test", "prerelease", "true", + ), + resource.TestCheckResourceAttr( + "github_release.test", "generate_release_notes", "false", + ), + resource.TestCheckResourceAttr( + "github_release.test", "discussion_category_name", "", + ), + ) + + 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, + }, + { + ResourceName: "github_release.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importReleaseByResourcePaths( + "github_repository.test", "github_release.test"), + }, + }, + }) + } + + 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("create a release on branch", func(t *testing.T) { + + randomRepoPart := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + randomVersion := fmt.Sprintf("v1.0.%d", acctest.RandIntRange(0, 9999)) + testBranchName := "test" + + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + auto_init = true + } + + resource "github_branch" "test" { + repository = github_repository.test.name + branch = "%s" + source_branch = github_repository.test.default_branch + } + + resource "github_release" "test" { + repository = github_repository.test.name + tag_name = "%s" + target_commitish = github_branch.test.branch + draft = false + prerelease = false + } + `, randomRepoPart, testBranchName, randomVersion) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_release.test", "tag_name", randomVersion, + ), + resource.TestCheckResourceAttr( + "github_release.test", "target_commitish", testBranchName, + ), + resource.TestCheckResourceAttr( + "github_release.test", "name", "", + ), + resource.TestCheckResourceAttr( + "github_release.test", "body", "", + ), + resource.TestCheckResourceAttr( + "github_release.test", "draft", "false", + ), + resource.TestCheckResourceAttr( + "github_release.test", "prerelease", "false", + ), + resource.TestCheckResourceAttr( + "github_release.test", "generate_release_notes", "false", + ), + resource.TestCheckResourceAttr( + "github_release.test", "discussion_category_name", "", + ), + ) + + 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, + }, + { + ResourceName: "github_release.test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: importReleaseByResourcePaths( + "github_repository.test", "github_release.test"), + }, + }, + }) + } + + 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) + }) + + }) + +} + +func importReleaseByResourcePaths(repoLogicalName, releaseLogicalName string) resource.ImportStateIdFunc { + // test importing using an ID of the form : + // by retrieving the GraphQL ID from the terraform.State + return func(s *terraform.State) (string, error) { + log.Printf("[DEBUG] Looking up tf state ") + repo := s.RootModule().Resources[repoLogicalName] + if repo == nil { + return "", fmt.Errorf("Cannot find %s in terraform state", repoLogicalName) + } + repoID := repo.Primary.ID + if repoID == "" { + return "", fmt.Errorf("repository %s does not have an id in terraform state", repoLogicalName) + } + + release := s.RootModule().Resources[releaseLogicalName] + if release == nil { + return "", fmt.Errorf("Cannot find %s in terraform state", releaseLogicalName) + } + releaseID := release.Primary.ID + if releaseID == "" { + return "", fmt.Errorf("release %s does not have an id in terraform state", releaseLogicalName) + } + + return fmt.Sprintf("%s:%s", repoID, releaseID), nil + } +} diff --git a/website/docs/r/release.html.markdown b/website/docs/r/release.html.markdown new file mode 100644 index 0000000000..b03067a596 --- /dev/null +++ b/website/docs/r/release.html.markdown @@ -0,0 +1,104 @@ +--- +layout: "github" +page_title: "GitHub: github_release" +description: |- + Creates and manages releases within a single GitHub repository +--- + +# github_release + +This resource allows you to create and manage a release in a specific +GitHub repository. + +## Example Usage + +```hcl +resource "github_repository" "repo" { + name = "repo" + description = "GitHub repo managed by Terraform" + + private = false +} + +resource "github_release" "example" { + repository = github_repository.repo.name + tag_name = "v1.0.0" +} +``` + +## Example Usage on Non-Default Branch + +```hcl +resource "github_repository" "example" { + name = "repo" + auto_init = true +} + +resource "github_branch" "example" { + repository = github_repository.example.name + branch = "branch_name" + source_branch = github_repository.example.default_branch +} + +resource "github_release" "example" { + repository = github_repository.example.name + tag_name = "v1.0.0" + target_commitish = github_branch.example.branch + draft = false + prerelease = false +} +``` + +## Argument Reference + +The following arguments are supported: + +* `repository` - (Required) The name of the repository. + +* `tag_name` - (Required) The name of the tag. + +* `target_commitish` - (Optional) The branch name or commit SHA the tag is created from. Defaults to the default branch of the repository. + +* `name` - (Optional) The name of the release. + +* `body` - (Optional) Text describing the contents of the tag. + +* `draft` - (Optional) Set to `false` to create a published release. + +* `prerelease` - (Optional) Set to `false` to identify the release as a full release. + +* `generate_release_notes` - (Optional) Set to `true` to automatically generate the name and body for this release. If `name` is specified, the specified `name` will be used; otherwise, a name will be automatically generated. If `body` is specified, the `body` will be pre-pended to the automatically generated notes. + +* `discussion_category_name` - (Optional) If specified, a discussion of the specified category is created and linked to the release. The value must be a category that already exists in the repository. For more information, see [Managing categories for discussions in your repository](https://docs.github.com/discussions/managing-discussions-for-your-community/managing-categories-for-discussions-in-your-repository). + +## Attributes Reference + +The following additional attributes are exported: + +* `release_id` - The ID of the release. + +* `created_at` - This is the date of the commit used for the release, and not the date when the release was drafted or published. + +* `published_at` - This is the date when the release was published. This will be empty if the release is a draft. + +* `html_url` - URL of the release in GitHub. + +* `url` - URL that can be provided to API calls that reference this release. + +* `assets_url` - URL that can be provided to API calls displaying the attached assets to this release. + +* `upload_url` - URL that can be provided to API calls to upload assets. + +* `zipball_url` - URL that can be provided to API calls to fetch the release ZIP archive. + +* `tarball_url` - URL that can be provided to API calls to fetch the release TAR archive. + +* `node_id` - GraphQL global node id for use with v4 API + +## Import + +This resource can be imported using the `name` of the repository, combined with the `id` of the release, and a `:` character for separating components, e.g. + +```sh +$ terraform import github_release.example repo:12345678 +``` diff --git a/website/github.erb b/website/github.erb index 749b2d8ce8..72fdd07f0e 100644 --- a/website/github.erb +++ b/website/github.erb @@ -178,6 +178,9 @@
  • github_project_column
  • +
  • + github_release +
  • github_repository