diff --git a/github/data_source_github_repository_milestone.go b/github/data_source_github_repository_milestone.go new file mode 100644 index 0000000000..54c4628845 --- /dev/null +++ b/github/data_source_github_repository_milestone.go @@ -0,0 +1,66 @@ +package github + +import ( + "context" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "strconv" +) + +func dataSourceGithubRepositoryMilestone() *schema.Resource { + return &schema.Resource{ + Read: dataSourceGithubRepositoryMilestoneRead, + + Schema: map[string]*schema.Schema{ + "owner": { + Type: schema.TypeString, + Required: true, + }, + "repository": { + Type: schema.TypeString, + Required: true, + }, + "number": { + Type: schema.TypeInt, + Required: true, + }, + "description": { + Type: schema.TypeString, + Computed: true, + }, + "due_date": { + Type: schema.TypeString, + Computed: true, + }, + "state": { + Type: schema.TypeString, + Computed: true, + }, + "title": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func dataSourceGithubRepositoryMilestoneRead(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*Owner).v3client + ctx := context.Background() + + owner := d.Get("owner").(string) + repoName := d.Get("repository").(string) + + number := d.Get("number").(int) + milestone, _, err := conn.Issues.GetMilestone(ctx, owner, repoName, number) + if err != nil { + return err + } + + d.SetId(strconv.FormatInt(milestone.GetID(), 10)) + d.Set("description", milestone.GetDescription()) + d.Set("due_date", milestone.GetDueOn().Format(layoutISO)) + d.Set("state", milestone.GetState()) + d.Set("title", milestone.GetTitle()) + + return nil +} diff --git a/github/data_source_github_repository_milestone_test.go b/github/data_source_github_repository_milestone_test.go new file mode 100644 index 0000000000..577bd9b426 --- /dev/null +++ b/github/data_source_github_repository_milestone_test.go @@ -0,0 +1,74 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubRepositoryMilestoneDataSource(t *testing.T) { + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + t.Run("queries a repository milestone", func(t *testing.T) { + + config := fmt.Sprintf(` + + resource "github_repository" "test" { + name = "tf-acc-test-%s" + } + + 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 = "2020-11-22" + state = "closed" + } + + data "github_repository_milestone" "test" { + owner = github_repository_milestone.test.owner + repository = github_repository_milestone.test.repository + number = github_repository_milestone.test.number + } + + `, randomID) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_repository_milestone.test", "state", + "closed", + ), + ) + + 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) + }) + + }) +} diff --git a/github/provider.go b/github/provider.go index 5b1a6d7a14..6f96245316 100644 --- a/github/provider.go +++ b/github/provider.go @@ -54,6 +54,7 @@ func Provider() terraform.ResourceProvider { "github_repository_collaborator": resourceGithubRepositoryCollaborator(), "github_repository_deploy_key": resourceGithubRepositoryDeployKey(), "github_repository_file": resourceGithubRepositoryFile(), + "github_repository_milestone": resourceGithubRepositoryMilestone(), "github_repository_project": resourceGithubRepositoryProject(), "github_repository_webhook": resourceGithubRepositoryWebhook(), "github_repository": resourceGithubRepository(), @@ -77,6 +78,7 @@ func Provider() terraform.ResourceProvider { "github_release": dataSourceGithubRelease(), "github_repositories": dataSourceGithubRepositories(), "github_repository": dataSourceGithubRepository(), + "github_repository_milestone": dataSourceGithubRepositoryMilestone(), "github_team": dataSourceGithubTeam(), "github_user": dataSourceGithubUser(), }, diff --git a/github/resource_github_repository_milestone.go b/github/resource_github_repository_milestone.go new file mode 100644 index 0000000000..7c3c4f6466 --- /dev/null +++ b/github/resource_github_repository_milestone.go @@ -0,0 +1,233 @@ +package github + +import ( + "context" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/google/go-github/v32/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/helper/validation" +) + +func resourceGithubRepositoryMilestone() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubRepositoryMilestoneCreate, + Read: resourceGithubRepositoryMilestoneRead, + Update: resourceGithubRepositoryMilestoneUpdate, + Delete: resourceGithubRepositoryMilestoneDelete, + Importer: &schema.ResourceImporter{ + State: func(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + parts := strings.Split(d.Id(), "/") + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return nil, fmt.Errorf("Invalid ID format, must be provided as OWNER/REPOSITORY/NUMBER") + } + d.Set("owner", parts[0]) + d.Set("repository", parts[1]) + number, err := strconv.Atoi(parts[2]) + if err != nil { + return nil, err + } + d.Set("number", number) + d.SetId(fmt.Sprintf("%s/%s/%d", parts[0], parts[1], number)) + + return []*schema.ResourceData{d}, nil + }, + }, + + Schema: map[string]*schema.Schema{ + "title": { + Type: schema.TypeString, + Required: true, + }, + "owner": { + Type: schema.TypeString, + Required: true, + }, + "repository": { + Type: schema.TypeString, + Required: true, + }, + "description": { + Type: schema.TypeString, + Optional: true, + }, + "due_date": { + Type: schema.TypeString, + Optional: true, + Description: "in yyyy-mm-dd format", + }, + "state": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice([]string{ + "open", "closed", + }, true), + Default: "open", + }, + "number": { + Type: schema.TypeInt, + Computed: true, + }, + }, + } +} + +const ( + layoutISO = "2006-01-02" +) + +func resourceGithubRepositoryMilestoneCreate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*Owner).v3client + ctx := context.Background() + owner := d.Get("owner").(string) + repoName := d.Get("repository").(string) + + milestone := &github.Milestone{ + Title: github.String(d.Get("title").(string)), + } + + if v, ok := d.GetOk("description"); ok && len(v.(string)) > 0 { + milestone.Description = github.String(v.(string)) + } + if v, ok := d.GetOk("due_date"); ok && len(v.(string)) > 0 { + dueDate, err := time.Parse(layoutISO, v.(string)) + if err != nil { + return err + } + date := time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 23, 39, 0, 0, time.UTC) + milestone.DueOn = &date + } + if v, ok := d.GetOk("state"); ok && len(v.(string)) > 0 { + milestone.State = github.String(v.(string)) + } + + log.Printf("[DEBUG] Creating milestone for repository: %s/%s", owner, repoName) + milestone, _, err := conn.Issues.CreateMilestone(ctx, owner, repoName, milestone) + if err != nil { + return err + } + + d.SetId(fmt.Sprintf("%s/%s/%d", owner, repoName, milestone.GetNumber())) + + return resourceGithubRepositoryMilestoneRead(d, meta) +} + +func resourceGithubRepositoryMilestoneRead(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*Owner).v3client + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + + owner := d.Get("owner").(string) + repoName := d.Get("repository").(string) + number, err := parseMilestoneNumber(d.Id()) + if err != nil { + return err + } + + log.Printf("[DEBUG] Reading milestone for repository: %s/%s", owner, repoName) + milestone, _, err := conn.Issues.GetMilestone(ctx, owner, 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 milestone for %s/%s from state because it no longer exists in GitHub", + owner, repoName) + d.SetId("") + return nil + } + } + return err + } + + d.Set("title", milestone.GetTitle()) + d.Set("description", milestone.GetDescription()) + d.Set("number", milestone.GetNumber()) + d.Set("state", milestone.GetState()) + if dueOn := milestone.GetDueOn(); !dueOn.IsZero() { + d.Set("due_date", milestone.GetDueOn().Format(layoutISO)) + } + + return nil +} + +func resourceGithubRepositoryMilestoneUpdate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*Owner).v3client + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + owner := d.Get("owner").(string) + repoName := d.Get("repository").(string) + number, err := parseMilestoneNumber(d.Id()) + if err != nil { + return err + } + + milestone := &github.Milestone{} + if d.HasChanges("title") { + _, n := d.GetChange("title") + milestone.Title = github.String(n.(string)) + } + + if d.HasChanges("description") { + _, n := d.GetChange("description") + milestone.Description = github.String(n.(string)) + } + + if d.HasChanges("due_date") { + _, n := d.GetChange("due_date") + dueDate, err := time.Parse(layoutISO, n.(string)) + if err != nil { + return err + } + date := time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 7, 0, 0, 0, time.UTC) + milestone.DueOn = &date + } + + if d.HasChanges("state") { + _, n := d.GetChange("state") + milestone.State = github.String(n.(string)) + } + + log.Printf("[DEBUG] Updating milestone for repository: %s/%s", owner, repoName) + _, _, err = conn.Issues.EditMilestone(ctx, owner, repoName, number, milestone) + if err != nil { + return err + } + + return resourceGithubRepositoryMilestoneRead(d, meta) +} + +func resourceGithubRepositoryMilestoneDelete(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*Owner).v3client + ctx := context.WithValue(context.Background(), ctxId, d.Id()) + owner := d.Get("owner").(string) + repoName := d.Get("repository").(string) + number, err := parseMilestoneNumber(d.Id()) + if err != nil { + return err + } + + log.Printf("[DEBUG] Deleting milestone for repository: %s/%s", owner, repoName) + _, err = conn.Issues.DeleteMilestone(ctx, owner, repoName, number) + if err != nil { + return err + } + + return nil +} + +func parseMilestoneNumber(id string) (int, error) { + parts := strings.Split(id, "/") + if len(parts) != 3 { + return -1, fmt.Errorf("ID not properly formatted: %s", id) + } + number, err := strconv.Atoi(parts[2]) + if err != nil { + return -1, err + } + return number, nil +} diff --git a/github/resource_github_repository_milestone_test.go b/github/resource_github_repository_milestone_test.go new file mode 100644 index 0000000000..08e9a12693 --- /dev/null +++ b/github/resource_github_repository_milestone_test.go @@ -0,0 +1,67 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubRepositoryMilestone(t *testing.T) { + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + t.Run("creates a repository milestone", func(t *testing.T) { + + config := fmt.Sprintf(` + + resource "github_repository" "test" { + name = "tf-acc-test-%s" + } + + 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 = "2020-11-22" + state = "closed" + } + + `, randomID) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_repository_milestone.test", "state", + "closed", + ), + ) + + 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) + }) + + }) +} diff --git a/go.mod b/go.mod index 439d7644fa..7598170951 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.13 require ( github.com/client9/misspell v0.3.4 github.com/golangci/golangci-lint v1.25.1 + github.com/google/go-github/v31 v31.0.0 github.com/google/go-github/v32 v32.1.0 github.com/hashicorp/terraform v0.12.24 github.com/hashicorp/terraform-plugin-sdk v1.7.0 diff --git a/go.sum b/go.sum index e860703a7b..0c7bf1d69d 100644 --- a/go.sum +++ b/go.sum @@ -204,6 +204,9 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= +github.com/google/go-github/v31 v31.0.0 h1:JJUxlP9lFK+ziXKimTCprajMApV1ecWD4NB6CCb0plo= +github.com/google/go-github/v31 v31.0.0/go.mod h1:NQPZol8/1sMoWYGN2yaALIBytu17gAWfhbweiEed3pM= github.com/google/go-github/v32 v31.0.0 h1:JJUxlP9lFK+ziXKimTCprajMApV1ecWD4NB6CCb0plo= github.com/google/go-github/v32 v31.0.0/go.mod h1:NQPZol8/1sMoWYGN2yaALIBytu17gAWfhbweiEed3pM= github.com/google/go-github/v32 v32.1.0 h1:GWkQOdXqviCPx7Q7Fj+KyPoGm4SwHRh8rheoPhd27II= diff --git a/website/docs/d/repository_milestone.html.markdown b/website/docs/d/repository_milestone.html.markdown new file mode 100644 index 0000000000..fe177f4b68 --- /dev/null +++ b/website/docs/d/repository_milestone.html.markdown @@ -0,0 +1,35 @@ +--- +layout: "github" +page_title: "GitHub: github_repository_milestone" +description: |- + Get information on a GitHub Repository Milestone. +--- + +# github_repository_milestone + +Use this data source to retrieve information about a specific GitHub milestone in a repository. + +## Example Usage + +```hcl +data "github_repository_milestone" "example" { + owner = "example-owner" + repository = "example-repository" + number = 1 +} +``` + +## Argument Reference + + * `owner` - (Required) Owner of the repository. + + * `repository` - (Required) Name of the repository to retrieve the milestone from. + + * `number` - (Required) The number of the milestone. + +## Attributes Reference + + * `description` - Description of the milestone. + * `due_date` - The milestone due date (in ISO-8601 `yyyy-mm-dd` format). + * `state` - State of the milestone. + * `title` - Title of the milestone. diff --git a/website/docs/r/repository_milestone.html.markdown b/website/docs/r/repository_milestone.html.markdown new file mode 100644 index 0000000000..f012d81f04 --- /dev/null +++ b/website/docs/r/repository_milestone.html.markdown @@ -0,0 +1,54 @@ +--- +layout: "github" +page_title: "GitHub: github_repository_milestone" +description: |- + Provides a GitHub repository milestone resource. +--- + +# github_repository_milestone + +Provides a GitHub repository milestone resource. + +This resource allows you to create and manage milestones for a Github Repository within an organization or user account. + +## Example Usage + +```hcl +# Create a milestone for a repository +resource "github_repository_milestone" "example" { + owner = "example-owner" + repository = "example-repository" + title = "v1.1.0" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `owner` - (Required) The owner of the Github Repository. + +* `repository` - (Required) The name of the Github Repository. + +* `title` - (Required) The title of the milestone. + +* `description` - (Optional) A description of the milestone. + +* `due_date` - (Optional) The milestone due date. In `yyyy-mm-dd` format. + +* `state` - (Optional) The state of the milestone. Either `open` or `closed`. Default: `open` + + +## Attributes Reference + +The following additional attributes are exported: + +* `number` - The number of the milestone. + +## Import + +A GitHub Repository Milestone can be imported using an ID made up of `owner/repository/number`, e.g. + +``` +$ terraform import github_repository_milestone.example example-owner/example-repository/1 +``` diff --git a/website/github.erb b/website/github.erb index 653560070d..e6c09f5d08 100644 --- a/website/github.erb +++ b/website/github.erb @@ -43,6 +43,9 @@