Skip to content

Commit

Permalink
feat: support for issues using github_issue resource
Browse files Browse the repository at this point in the history
Introduce support for managing github issues using the terraform provider.

Closes: #958

Signed-off-by: Edward Wilde <[email protected]>
  • Loading branch information
ewilde-form3 committed Jan 24, 2022
1 parent f9508c5 commit 7877ec4
Show file tree
Hide file tree
Showing 8 changed files with 630 additions and 6 deletions.
1 change: 1 addition & 0 deletions github/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ func Provider() terraform.ResourceProvider {
"github_branch": resourceGithubBranch(),
"github_branch_protection": resourceGithubBranchProtection(),
"github_branch_protection_v3": resourceGithubBranchProtectionV3(),
"github_issue": resourceGithubIssue(),
"github_issue_label": resourceGithubIssueLabel(),
"github_membership": resourceGithubMembership(),
"github_organization_block": resourceOrganizationBlock(),
Expand Down
202 changes: 202 additions & 0 deletions github/resource_github_issue.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package github

import (
"context"
"github.com/google/go-github/v42/github"
"github.com/hashicorp/terraform-plugin-sdk/helper/schema"
"log"
"net/http"
"strconv"
)

func resourceGithubIssue() *schema.Resource {
return &schema.Resource{
Create: resourceGithubIssueCreateOrUpdate,
Read: resourceGithubIssueRead,
Update: resourceGithubIssueCreateOrUpdate,
Delete: resourceGithubIssueDelete,
Importer: &schema.ResourceImporter{
State: schema.ImportStatePassthrough,
},
Schema: map[string]*schema.Schema{
"repository": {
Type: schema.TypeString,
Required: true,
ForceNew: true,
},
"number": {
Type: schema.TypeInt,
Required: false,
Computed: true,
},
"title": {
Type: schema.TypeString,
Required: true,
},
"body": {
Type: schema.TypeString,
Optional: true,
},
"labels": {
Type: schema.TypeSet,
Elem: &schema.Schema{Type: schema.TypeString},
Set: schema.HashString,
Optional: true,
Description: "List of names of labels on the issue",
},
"assignees": {
Type: schema.TypeSet,
Elem: &schema.Schema{Type: schema.TypeString},
Set: schema.HashString,
Optional: true,
Description: "List of Logins for Users to assign to this issue",
},
"milestone_number": {
Type: schema.TypeInt,
Optional: true,
},
"issue_id": {
Type: schema.TypeInt,
Computed: true,
},
"etag": {
Type: schema.TypeString,
Computed: true,
},
},
}
}

func resourceGithubIssueCreateOrUpdate(d *schema.ResourceData, meta interface{}) error {
ctx := context.Background()
client := meta.(*Owner).v3client
orgName := meta.(*Owner).name
repoName := d.Get("repository").(string)
title := d.Get("title").(string)
milestone := d.Get("milestone_number").(int)

req := &github.IssueRequest{
Title: github.String(title),
}

if v, ok := d.GetOk("body"); ok {
req.Body = github.String(v.(string))
}

labels := expandStringList(d.Get("labels").(*schema.Set).List())
req.Labels = &labels

assignees := expandStringList(d.Get("assignees").(*schema.Set).List())
req.Assignees = &assignees

if milestone > 0 {
req.Milestone = intPtr(milestone)
}

var issue *github.Issue
var resp *github.Response
var err error
if d.IsNewResource() {
log.Printf("[DEBUG] Creating issue: %s (%s/%s)",
title, orgName, repoName)
issue, resp, err = client.Issues.Create(ctx, orgName, repoName, req)
if resp != nil {
log.Printf("[DEBUG] Response from creating issue: %#v", *resp)
}
} else {
number := d.Get("number").(int)
log.Printf("[DEBUG] Updating issue: %d:%s (%s/%s)",
number, title, orgName, repoName)
issue, resp, err = client.Issues.Edit(ctx, orgName, repoName, number , req)
if resp != nil {
log.Printf("[DEBUG] Response from updating issue: %#v", *resp)
}
}
if err != nil {
return err
}

d.SetId(buildTwoPartID(repoName, strconv.Itoa(issue.GetNumber())))
d.Set("issue_id", issue.GetID())
return resourceGithubIssueRead(d, meta)
}

func resourceGithubIssueRead(d *schema.ResourceData, meta interface{}) error {
client := meta.(*Owner).v3client
repoName, idNumber, err := parseTwoPartID(d.Id(), "repository", "issue_number")
if err != nil {
return err
}

number, err := strconv.Atoi(idNumber)
if err != nil {
return err
}

orgName := meta.(*Owner).name
ctx := context.WithValue(context.Background(), ctxId, d.Id())
if !d.IsNewResource() {
ctx = context.WithValue(ctx, ctxEtag, d.Get("etag").(string))
}

log.Printf("[DEBUG] Reading issue: %d (%s/%s)", number, orgName, repoName)
issue, resp, err := client.Issues.Get(ctx,
orgName, repoName, number)
if err != nil {
if ghErr, ok := err.(*github.ErrorResponse); ok {
if ghErr.Response.StatusCode == http.StatusNotModified {
return nil
}
if ghErr.Response.StatusCode == http.StatusNotFound {
log.Printf("[WARN] Removing issue %d (%s/%s) from state because it no longer exists in GitHub",
number, orgName, repoName)
d.SetId("")
return nil
}
}
return err
}

d.Set("etag", resp.Header.Get("ETag"))
d.Set("repository", repoName)
d.Set("number", number)
d.Set("title", issue.GetTitle())
d.Set("body", issue.GetBody())
d.Set("milestone_number", issue.GetMilestone().GetNumber())

var labels []string
for _, v := range issue.Labels {
labels = append(labels, v.GetName())
}
d.Set("labels", flattenStringList(labels))

var assignees []string
for _, v := range issue.Assignees {
assignees = append(assignees, v.GetLogin())
}
d.Set("assignees", flattenStringList(assignees))

d.Set("issue_id", issue.GetID())
return nil
}

func resourceGithubIssueDelete(d *schema.ResourceData, meta interface{}) error {
client := meta.(*Owner).v3client

orgName := meta.(*Owner).name
repoName := d.Get("repository").(string)
number := d.Get("number").(int)
ctx := context.WithValue(context.Background(), ctxId, d.Id())

log.Printf("[DEBUG] Deleting issue by closing: %d (%s/%s)", number, orgName, repoName)

request := &github.IssueRequest{State: github.String("closed")}

_, _, err := client.Issues.Edit(ctx, orgName, repoName, number, request)

return err
}

func intPtr(i int) *int {
return &i
}
176 changes: 176 additions & 0 deletions github/resource_github_issue_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package github

import (
"fmt"
"github.com/hashicorp/terraform-plugin-sdk/terraform"
"testing"

"github.com/hashicorp/terraform-plugin-sdk/helper/acctest"
"github.com/hashicorp/terraform-plugin-sdk/helper/resource"
)

func TestAccGithubIssue(t *testing.T) {

randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)

t.Run("creates an issue without error", func(t *testing.T) {

title := "issue_title"
body := "issue_body"
labels := "\"bug\", \"enhancement\""
updatedTitle := "updated_issue_title"
updatedBody := "update_issue_body"
updatedLabels := "\"documentation\""

issueHCL := `
resource "github_repository" "test" {
name = "tf-acc-test-%s"
auto_init = true
has_issues = true
}
resource "github_repository_milestone" "test" {
owner = split("/", "${github_repository.test.full_name}")[0]
repository = github_repository.test.name
title = "v1.0.0"
description = "General Availability"
due_date = "2022-11-22"
state = "open"
}
resource "github_issue" "test" {
repository = github_repository.test.name
title = "%s"
body = "%s"
labels = [%s]
assignees = ["%s"]
milestone_number = github_repository_milestone.test.number
}
`
config := fmt.Sprintf(issueHCL, randomID, title, body, labels, testOwnerFunc())

checks := map[string]resource.TestCheckFunc{
"before": resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(
"github_issue.test", "title",
title,
),
resource.TestCheckResourceAttr(
"github_issue.test", "body",
body,
),
resource.TestCheckResourceAttr(
"github_issue.test", "labels.#",
"2",
),
func(state *terraform.State) error {
issue := state.RootModule().Resources["github_issue.test"].Primary
issueMilestone := issue.Attributes["milestone_number"]

milestone := state.RootModule().Resources["github_repository_milestone.test"].Primary
milestoneNumber := milestone.Attributes["number"]
if issueMilestone != milestoneNumber {
return fmt.Errorf("issue milestone number %s not the same as repository milestone number %s",
issueMilestone, milestoneNumber)
}
return nil
},
),
"after": resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(
"github_issue.test", "title",
updatedTitle,
),
resource.TestCheckResourceAttr(
"github_issue.test", "body",
updatedBody,
), resource.TestCheckResourceAttr(
"github_issue.test", "labels.#",
"1",
), resource.TestCheckResourceAttr(
"github_issue.test", "assignees.#",
"1",
),
),
}

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: checks["before"],
},
{
Config: fmt.Sprintf(issueHCL, randomID, updatedTitle, updatedBody, updatedLabels, testOwnerFunc()),
Check: checks["after"],
},
},
})
}

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("imports a issue without error", func(t *testing.T) {
config := fmt.Sprintf(`
resource "github_repository" "test" {
name = "tf-acc-test-%s"
has_issues = true
}
resource "github_issue" "test" {
repository = github_repository.test.name
title = github_repository.test.name
}
`, randomID)

check := resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("github_issue.test", "title"),
resource.TestCheckResourceAttr("github_issue.test", "title", fmt.Sprintf(`tf-acc-test-%s`, randomID)),
)

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_issue.test",
ImportState: true,
ImportStateVerify: true,
},
},
})
}

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)
})
})

}
Loading

0 comments on commit 7877ec4

Please sign in to comment.