diff --git a/github/provider.go b/github/provider.go index 1188c2be3b..70441a0349 100644 --- a/github/provider.go +++ b/github/provider.go @@ -127,6 +127,7 @@ func Provider() terraform.ResourceProvider { "github_emu_group_mapping": resourceGithubEMUGroupMapping(), "github_issue": resourceGithubIssue(), "github_issue_label": resourceGithubIssueLabel(), + "github_issue_labels": resourceGithubIssueLabels(), "github_membership": resourceGithubMembership(), "github_organization_block": resourceOrganizationBlock(), "github_organization_custom_role": resourceGithubOrganizationCustomRole(), diff --git a/github/resource_github_issue_labels.go b/github/resource_github_issue_labels.go new file mode 100644 index 0000000000..92c8c4e7f5 --- /dev/null +++ b/github/resource_github_issue_labels.go @@ -0,0 +1,247 @@ +package github + +import ( + "context" + "log" + "strings" + + "github.com/google/go-github/v52/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func resourceGithubIssueLabels() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubIssueLabelsCreateOrUpdate, + Read: resourceGithubIssueLabelsRead, + Update: resourceGithubIssueLabelsCreateOrUpdate, + Delete: resourceGithubIssueLabelsDelete, + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The GitHub repository.", + }, + "label": { + Type: schema.TypeSet, + Optional: true, + Description: "List of labels", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "The name of the label.", + }, + "color": { + Type: schema.TypeString, + Required: true, + Description: "A 6 character hex code, without the leading '#', identifying the color of the label.", + }, + "description": { + Type: schema.TypeString, + Optional: true, + Description: "A short description of the label.", + }, + "url": { + Type: schema.TypeString, + Computed: true, + Description: "The URL to the issue label.", + }, + }, + }, + }, + }, + } +} + +func resourceGithubIssueLabelsRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + + owner := meta.(*Owner).name + repository := d.Id() + + log.Printf("[DEBUG] Reading GitHub issue labels for %s/%s", owner, repository) + + ctx := context.WithValue(context.Background(), ctxId, repository) + + options := &github.ListOptions{ + PerPage: maxPerPage, + } + + labels := make([]map[string]interface{}, 0) + + for { + ls, resp, err := client.Issues.ListLabels(ctx, owner, repository, options) + if err != nil { + return err + } + for _, l := range ls { + labels = append(labels, map[string]interface{}{ + "name": l.GetName(), + "color": l.GetColor(), + "description": l.GetDescription(), + "url": l.GetURL(), + }) + } + + if resp.NextPage == 0 { + break + } + options.Page = resp.NextPage + } + + log.Printf("[DEBUG] Found %d GitHub issue labels for %s/%s", len(labels), owner, repository) + log.Printf("[DEBUG] Labels: %v", labels) + + err := d.Set("repository", repository) + if err != nil { + return err + } + + err = d.Set("label", labels) + if err != nil { + return err + } + + return nil +} + +func resourceGithubIssueLabelsCreateOrUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + + owner := meta.(*Owner).name + repository := d.Get("repository").(string) + ctx := context.WithValue(context.Background(), ctxId, repository) + + o, n := d.GetChange("label") + + log.Printf("[DEBUG] Updating GitHub issue labels for %s/%s", owner, repository) + log.Printf("[DEBUG] Old labels: %v", o) + log.Printf("[DEBUG] New labels: %v", n) + + oMap := make(map[string]map[string]interface{}) + nMap := make(map[string]map[string]interface{}) + for _, raw := range o.(*schema.Set).List() { + m := raw.(map[string]interface{}) + name := strings.ToLower(m["name"].(string)) + oMap[name] = m + } + for _, raw := range n.(*schema.Set).List() { + m := raw.(map[string]interface{}) + name := strings.ToLower(m["name"].(string)) + nMap[name] = m + } + + labels := make([]map[string]interface{}, 0) + + // create + for name, n := range nMap { + if _, ok := oMap[name]; !ok { + log.Printf("[DEBUG] Creating GitHub issue label %s/%s/%s", owner, repository, name) + + label, _, err := client.Issues.CreateLabel(ctx, owner, repository, &github.Label{ + Name: github.String(n["name"].(string)), + Color: github.String(n["color"].(string)), + Description: github.String(n["description"].(string)), + }) + if err != nil { + return err + } + + labels = append(labels, map[string]interface{}{ + "name": label.GetName(), + "color": label.GetColor(), + "description": label.GetDescription(), + "url": label.GetURL(), + }) + } + } + + // delete + for name, o := range oMap { + if _, ok := nMap[name]; !ok { + log.Printf("[DEBUG] Deleting GitHub issue label %s/%s/%s", owner, repository, name) + + _, err := client.Issues.DeleteLabel(ctx, owner, repository, o["name"].(string)) + if err != nil { + return err + } + } + } + + // update + for name, n := range nMap { + if o, ok := oMap[name]; ok { + if o["name"] != n["name"] || o["color"] != n["color"] || o["description"] != n["description"] { + log.Printf("[DEBUG] Updating GitHub issue label %s/%s/%s", owner, repository, name) + + label, _, err := client.Issues.EditLabel(ctx, owner, repository, name, &github.Label{ + Name: github.String(n["name"].(string)), + Color: github.String(n["color"].(string)), + Description: github.String(n["description"].(string)), + }) + if err != nil { + return err + } + + labels = append(labels, map[string]interface{}{ + "name": label.GetName(), + "color": label.GetColor(), + "description": label.GetDescription(), + "url": label.GetURL(), + }) + } else { + labels = append(labels, o) + } + } + } + + d.SetId(repository) + + err := d.Set("label", labels) + if err != nil { + return err + } + + return nil +} + +func resourceGithubIssueLabelsDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + + owner := meta.(*Owner).name + repository := d.Get("repository").(string) + ctx := context.WithValue(context.Background(), ctxId, repository) + + labels := d.Get("label").(*schema.Set).List() + + log.Printf("[DEBUG] Deleting GitHub issue labels for %s/%s", owner, repository) + log.Printf("[DEBUG] Labels: %v", labels) + + // delete + for _, raw := range labels { + label := raw.(map[string]interface{}) + name := label["name"].(string) + + log.Printf("[DEBUG] Deleting GitHub issue label %s/%s/%s", owner, repository, name) + + _, err := client.Issues.DeleteLabel(ctx, owner, repository, name) + if err != nil { + return err + } + } + + d.SetId(repository) + + err := d.Set("label", make([]map[string]interface{}, 0)) + if err != nil { + return err + } + + return nil +} diff --git a/github/resource_github_issue_labels_test.go b/github/resource_github_issue_labels_test.go new file mode 100644 index 0000000000..b113e118cf --- /dev/null +++ b/github/resource_github_issue_labels_test.go @@ -0,0 +1,126 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubIssueLabels(t *testing.T) { + t.Run("authoritatively overtakes existing labels", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + empty := []map[string]interface{}{} + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + // 0. Check if some labels already exist (indicated by non-empty plan) + { + Config: testAccGithubIssueLabelsConfig(randomID, empty), + ExpectNonEmptyPlan: true, + }, + // 1. Check if all the labels are destroyed when the resource is added + { + Config: testAccGithubIssueLabelsConfig(randomID, empty), + Check: resource.TestCheckResourceAttr("github_issue_labels.test", "label.#", "0"), + }, + // 2. Check if a label can be created + { + Config: testAccGithubIssueLabelsConfig(randomID, append(empty, map[string]interface{}{ + "name": "foo", + "color": "000000", + "description": "foo", + })), + Check: resource.TestCheckResourceAttr("github_issue_labels.test", "label.#", "1"), + }, + // 3. Check if a label can be recreated + { + Config: testAccGithubIssueLabelsConfig(randomID, append(empty, map[string]interface{}{ + "name": "Foo", + "color": "000000", + "description": "foo", + })), + Check: resource.TestCheckResourceAttr("github_issue_labels.test", "label.#", "1"), + }, + // 4. Check if multiple labels can be created + { + Config: testAccGithubIssueLabelsConfig(randomID, append(empty, + map[string]interface{}{ + "name": "Foo", + "color": "000000", + "description": "foo", + }, + map[string]interface{}{ + "name": "bar", + "color": "000000", + "description": "bar", + }, map[string]interface{}{ + "name": "baz", + "color": "000000", + "description": "baz", + })), + Check: resource.TestCheckResourceAttr("github_issue_labels.test", "label.#", "3"), + }, + // 5. Check if labels can be destroyed + { + Config: testAccGithubIssueLabelsConfig(randomID, nil), + }, + // 6. Check if labels were actually destroyed + { + Config: testAccGithubIssueLabelsConfig(randomID, empty), + Check: resource.TestCheckResourceAttr("github_issue_labels.test", "label.#", "0"), + }, + }, + }) + } + + 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 testAccGithubIssueLabelsConfig(randomId string, labels []map[string]interface{}) string { + resource := "" + if labels != nil { + dynamic := "" + for _, label := range labels { + dynamic += fmt.Sprintf(` + label { + name = "%s" + color = "%s" + description = "%s" + } + `, label["name"], label["color"], label["description"]) + } + + resource = fmt.Sprintf(` + resource "github_issue_labels" "test" { + repository = github_repository.test.id + + %s + } + `, dynamic) + } + + return fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + auto_init = true + } + + %s + `, randomId, resource) +} diff --git a/website/docs/r/issue_labels.html.markdown b/website/docs/r/issue_labels.html.markdown new file mode 100644 index 0000000000..b912fd4d13 --- /dev/null +++ b/website/docs/r/issue_labels.html.markdown @@ -0,0 +1,60 @@ +--- +layout: "github" +page_title: "GitHub: github_issue_labels" +description: |- + Provides GitHub issue labels resource. +--- + +# github_issue_labels + +Provides GitHub issue labels resource. + +This resource allows you to create and manage issue labels within your +GitHub organization. + +~> Note: github_issue_labels cannot be used in conjunction with github_issue_label or they will fight over what your policy should be. + +This resource is authoritative. For adding a label to a repo in a non-authoritative manner, use github_issue_label instead. + +If you change the case of a label's name, its' color, or description, this resource will edit the existing label to match the new values. However, if you change the name of a label, this resource will create a new label with the new name and delete the old label. Beware that this will remove the label from any issues it was previously attached to. + +## Example Usage + +```hcl +# Create a new, red colored label +resource "github_issue_labels" "test_repo" { + repository = "test-repo" + + label { + name = "Urgent" + color = "FF0000" + } + + label { + name = "Critical" + color = "FF0000" + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `repository` - (Required) The GitHub repository + +* `name` - (Required) The name of the label. + +* `color` - (Required) A 6 character hex code, **without the leading #**, identifying the color of the label. + +* `description` - (Optional) A short description of the label. + +* `url` - (Computed) The URL to the issue label + +## Import + +GitHub Issue Labels can be imported using the repository `name`, e.g. + +``` +$ terraform import github_issue_labels.test_repo test_repo +``` diff --git a/website/github.erb b/website/github.erb index defacd98ba..23b579bbc7 100644 --- a/website/github.erb +++ b/website/github.erb @@ -262,6 +262,9 @@