From 36b9d27e19f6b57e3b6fcf0cdd0754fa29e5adac Mon Sep 17 00:00:00 2001 From: Marcin Wyszynski Date: Sat, 17 Apr 2021 17:39:20 +0200 Subject: [PATCH] Support Pull Requests (#739) * Support Pull Requests * fixup! typo in description Co-authored-by: Jeremy Udit --- ...a_source_github_repository_pull_request.go | 150 +++++++++ ...rce_github_repository_pull_request_test.go | 96 ++++++ ..._source_github_repository_pull_requests.go | 227 ++++++++++++++ ...ce_github_repository_pull_requests_test.go | 106 +++++++ github/provider.go | 3 + ...resource_github_repository_pull_request.go | 294 ++++++++++++++++++ ...rce_github_repository_pull_request_test.go | 96 ++++++ github/util.go | 15 + .../d/repository_pull_request.html.markdown | 58 ++++ .../d/repository_pull_requests.html.markdown | 72 +++++ .../r/repository_pull_request.html.markdown | 58 ++++ 11 files changed, 1175 insertions(+) create mode 100644 github/data_source_github_repository_pull_request.go create mode 100644 github/data_source_github_repository_pull_request_test.go create mode 100644 github/data_source_github_repository_pull_requests.go create mode 100644 github/data_source_github_repository_pull_requests_test.go create mode 100644 github/resource_github_repository_pull_request.go create mode 100644 github/resource_github_repository_pull_request_test.go create mode 100644 website/docs/d/repository_pull_request.html.markdown create mode 100644 website/docs/d/repository_pull_requests.html.markdown create mode 100644 website/docs/r/repository_pull_request.html.markdown diff --git a/github/data_source_github_repository_pull_request.go b/github/data_source_github_repository_pull_request.go new file mode 100644 index 0000000000..34db730f43 --- /dev/null +++ b/github/data_source_github_repository_pull_request.go @@ -0,0 +1,150 @@ +package github + +import ( + "context" + "strconv" + + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func dataSourceGithubRepositoryPullRequest() *schema.Resource { + return &schema.Resource{ + Read: dataSourceGithubRepositoryPullRequestRead, + Schema: map[string]*schema.Schema{ + "owner": { + Type: schema.TypeString, + Optional: true, + }, + "base_repository": { + Type: schema.TypeString, + Required: true, + }, + "number": { + Type: schema.TypeInt, + Required: true, + }, + "base_ref": { + Type: schema.TypeString, + Computed: true, + }, + "base_sha": { + Type: schema.TypeString, + Computed: true, + }, + "body": { + Type: schema.TypeString, + Computed: true, + }, + "draft": { + Type: schema.TypeBool, + Computed: true, + }, + "head_owner": { + Type: schema.TypeString, + Computed: true, + }, + "head_ref": { + Type: schema.TypeString, + Computed: true, + }, + "head_repository": { + Type: schema.TypeString, + Computed: true, + }, + "head_sha": { + Type: schema.TypeString, + Computed: true, + }, + "labels": { + Type: schema.TypeList, + Elem: &schema.Schema{Type: schema.TypeString}, + Computed: true, + Description: "List of names of labels on the PR", + }, + "maintainer_can_modify": { + Type: schema.TypeBool, + Computed: true, + }, + "opened_at": { + Type: schema.TypeInt, + Computed: true, + }, + "opened_by": { + Type: schema.TypeString, + Computed: true, + Description: "Username of the PR creator", + }, + "state": { + Type: schema.TypeString, + Computed: true, + }, + "title": { + Type: schema.TypeString, + Computed: true, + }, + "updated_at": { + Type: schema.TypeInt, + Computed: true, + }, + }, + } +} + +func dataSourceGithubRepositoryPullRequestRead(d *schema.ResourceData, meta interface{}) error { + ctx := context.TODO() + client := meta.(*Owner).v3client + + owner := meta.(*Owner).name + if expliclitOwner, ok := d.GetOk("owner"); ok { + owner = expliclitOwner.(string) + } + + repository := d.Get("base_repository").(string) + number := d.Get("number").(int) + + pullRequest, _, err := client.PullRequests.Get(ctx, owner, repository, number) + if err != nil { + return err + } + + if head := pullRequest.GetHead(); head != nil { + d.Set("head_ref", head.GetRef()) + d.Set("head_sha", head.GetSHA()) + + if headRepo := head.Repo; headRepo != nil { + d.Set("head_repository", headRepo.GetName()) + } + + if headUser := head.User; headUser != nil { + d.Set("head_owner", headUser.GetLogin()) + } + } + + if base := pullRequest.GetBase(); base != nil { + d.Set("base_ref", base.GetRef()) + d.Set("base_sha", base.GetSHA()) + } + + d.Set("body", pullRequest.GetBody()) + d.Set("draft", pullRequest.GetDraft()) + d.Set("maintainer_can_modify", pullRequest.GetMaintainerCanModify()) + d.Set("number", pullRequest.GetNumber()) + d.Set("opened_at", pullRequest.GetCreatedAt().Unix()) + d.Set("state", pullRequest.GetState()) + d.Set("title", pullRequest.GetTitle()) + d.Set("updated_at", pullRequest.GetUpdatedAt().Unix()) + + if user := pullRequest.GetUser(); user != nil { + d.Set("opened_by", user.GetLogin()) + } + + labels := []string{} + for _, label := range pullRequest.Labels { + labels = append(labels, label.GetName()) + } + d.Set("labels", labels) + + d.SetId(buildThreePartID(owner, repository, strconv.Itoa(number))) + + return nil +} diff --git a/github/data_source_github_repository_pull_request_test.go b/github/data_source_github_repository_pull_request_test.go new file mode 100644 index 0000000000..ed74c56494 --- /dev/null +++ b/github/data_source_github_repository_pull_request_test.go @@ -0,0 +1,96 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubRepositoryPullRequestDataSource(t *testing.T) { + t.Run("manages the pull request lifecycle", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + 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 = "test" + source_branch = github_repository.test.default_branch + } + + resource "github_repository_file" "test" { + repository = github_repository.test.name + branch = github_branch.test.branch + file = "test" + content = "bar" + } + + resource "github_repository_pull_request" "test" { + base_repository = github_repository_file.test.repository + base_ref = github_repository.test.default_branch + head_ref = github_branch.test.branch + title = "test title" + body = "test body" + } + + data "github_repository_pull_request" "test" { + base_repository = github_repository_pull_request.test.base_repository + number = github_repository_pull_request.test.number + } + `, randomID) + + const resourceName = "data.github_repository_pull_request.test" + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + resourceName, "base_repository", + fmt.Sprintf("tf-acc-test-%s", randomID), + ), + resource.TestCheckResourceAttr(resourceName, "base_ref", "main"), + resource.TestCheckResourceAttr(resourceName, "head_ref", "test"), + resource.TestCheckResourceAttr(resourceName, "title", "test title"), + resource.TestCheckResourceAttr(resourceName, "body", "test body"), + resource.TestCheckResourceAttr(resourceName, "maintainer_can_modify", "false"), + resource.TestCheckResourceAttrSet(resourceName, "base_sha"), + resource.TestCheckResourceAttr(resourceName, "draft", "false"), + resource.TestCheckResourceAttrSet(resourceName, "head_sha"), + resource.TestCheckResourceAttr(resourceName, "labels.#", "0"), + resource.TestCheckResourceAttrSet(resourceName, "number"), + resource.TestCheckResourceAttrSet(resourceName, "opened_at"), + resource.TestCheckResourceAttrSet(resourceName, "opened_by"), + resource.TestCheckResourceAttr(resourceName, "state", "open"), + resource.TestCheckResourceAttrSet(resourceName, "updated_at"), + ) + + 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/data_source_github_repository_pull_requests.go b/github/data_source_github_repository_pull_requests.go new file mode 100644 index 0000000000..222c92e98c --- /dev/null +++ b/github/data_source_github_repository_pull_requests.go @@ -0,0 +1,227 @@ +package github + +import ( + "context" + "strings" + + "github.com/google/go-github/v32/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/helper/validation" +) + +// Docs: https://docs.github.com/en/rest/reference/pulls#list-pull-requests +func dataSourceGithubRepositoryPullRequests() *schema.Resource { + return &schema.Resource{ + Read: dataSourceGithubRepositoryPullRequestsRead, + Schema: map[string]*schema.Schema{ + "owner": { + Type: schema.TypeString, + Optional: true, + }, + "base_repository": { + Type: schema.TypeString, + Required: true, + }, + "base_ref": { + Type: schema.TypeString, + Optional: true, + }, + "head_ref": { + Type: schema.TypeString, + Optional: true, + }, + "sort_by": { + Type: schema.TypeString, + Optional: true, + Default: "created", + ValidateFunc: validation.StringInSlice([]string{"created", "updated", "popularity", "long-running"}, false), + }, + "sort_direction": { + Type: schema.TypeString, + Optional: true, + Default: "asc", + ValidateFunc: validation.StringInSlice([]string{"asc", "desc"}, false), + }, + "state": { + Type: schema.TypeString, + Default: "open", + Optional: true, + ValidateFunc: validation.StringInSlice([]string{"open", "closed", "all"}, false), + }, + "results": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "number": { + Type: schema.TypeInt, + Computed: true, + Description: "Per-repository, monotonically increasing ID of this PR", + }, + "base_ref": { + Type: schema.TypeString, + Computed: true, + }, + "base_sha": { + Type: schema.TypeString, + Computed: true, + }, + "body": { + Type: schema.TypeString, + Computed: true, + }, + "draft": { + Type: schema.TypeBool, + Computed: true, + }, + "head_owner": { + Type: schema.TypeString, + Computed: true, + }, + "head_ref": { + Type: schema.TypeString, + Computed: true, + }, + "head_repository": { + Type: schema.TypeString, + Computed: true, + }, + "head_sha": { + Type: schema.TypeString, + Computed: true, + }, + "labels": { + Type: schema.TypeList, + Elem: &schema.Schema{Type: schema.TypeString}, + Computed: true, + Description: "List of names of labels on the PR", + }, + "maintainer_can_modify": { + Type: schema.TypeBool, + Computed: true, + }, + "opened_at": { + Type: schema.TypeInt, + Computed: true, + }, + "opened_by": { + Type: schema.TypeString, + Computed: true, + Description: "Username of the PR creator", + }, + "state": { + Type: schema.TypeString, + Computed: true, + }, + "title": { + Type: schema.TypeString, + Computed: true, + }, + "updated_at": { + Type: schema.TypeInt, + Computed: true, + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubRepositoryPullRequestsRead(d *schema.ResourceData, meta interface{}) error { + ctx := context.TODO() + client := meta.(*Owner).v3client + + owner := meta.(*Owner).name + if explicitOwner, ok := d.GetOk("owner"); ok { + owner = explicitOwner.(string) + } + + baseRepository := d.Get("base_repository").(string) + state := d.Get("state").(string) + head := d.Get("head_ref").(string) + base := d.Get("base_ref").(string) + sort := d.Get("sort_by").(string) + direction := d.Get("sort_direction").(string) + + options := &github.PullRequestListOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + State: state, + Head: head, + Base: base, + Sort: sort, + Direction: direction, + } + + results := make([]map[string]interface{}, 0) + + for { + pullRequests, resp, err := client.PullRequests.List(ctx, owner, baseRepository, options) + if err != nil { + return err + } + + for _, pullRequest := range pullRequests { + result := map[string]interface{}{ + "number": pullRequest.GetNumber(), + "body": pullRequest.GetBody(), + "draft": pullRequest.GetDraft(), + "maintainer_can_modify": pullRequest.GetMaintainerCanModify(), + "opened_at": pullRequest.GetCreatedAt().Unix(), + "state": pullRequest.GetState(), + "title": pullRequest.GetTitle(), + "updated_at": pullRequest.GetUpdatedAt().Unix(), + } + + if head := pullRequest.GetHead(); head != nil { + result["head_ref"] = head.GetRef() + result["head_sha"] = head.GetSHA() + + if headRepo := head.GetRepo(); headRepo != nil { + result["head_repository"] = headRepo.GetName() + + if headOwner := headRepo.GetOwner(); headOwner != nil { + result["head_owner"] = headOwner.GetLogin() + } + } + } + + if base := pullRequest.GetBase(); base != nil { + result["base_ref"] = base.GetRef() + result["base_sha"] = base.GetSHA() + } + + labels := []string{} + for _, label := range pullRequest.Labels { + labels = append(labels, label.GetName()) + } + result["labels"] = labels + + if user := pullRequest.GetUser(); user != nil { + result["opened_by"] = user.GetLogin() + } + + results = append(results, result) + } + + if resp.NextPage == 0 { + break + } + + options.Page = resp.NextPage + } + + d.SetId(strings.Join([]string{ + owner, + baseRepository, + state, + head, + base, + sort, + direction, + }, "/")) + + d.Set("results", results) + + return nil +} diff --git a/github/data_source_github_repository_pull_requests_test.go b/github/data_source_github_repository_pull_requests_test.go new file mode 100644 index 0000000000..9752293377 --- /dev/null +++ b/github/data_source_github_repository_pull_requests_test.go @@ -0,0 +1,106 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubRepositoryPullRequestsDataSource(t *testing.T) { + t.Run("manages the pull request lifecycle", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + 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 = "test" + source_branch = github_repository.test.default_branch + } + + resource "github_repository_file" "test" { + repository = github_repository.test.name + branch = github_branch.test.branch + file = "test" + content = "bar" + } + + resource "github_repository_pull_request" "test" { + base_repository = github_repository_file.test.repository + base_ref = github_repository.test.default_branch + head_ref = github_branch.test.branch + title = "test title" + body = "test body" + } + + data "github_repository_pull_requests" "test" { + base_repository = github_repository_pull_request.test.base_repository + head_ref = github_branch.test.branch + base_ref = github_repository.test.default_branch + sort_by = "updated" + sort_direction = "desc" + state = "open" + } + `, randomID) + + const resourceName = "data.github_repository_pull_requests.test" + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "base_repository", fmt.Sprintf("tf-acc-test-%s", randomID)), + resource.TestCheckResourceAttr(resourceName, "state", "open"), + resource.TestCheckResourceAttr(resourceName, "base_ref", "main"), + resource.TestCheckResourceAttr(resourceName, "head_ref", "test"), + resource.TestCheckResourceAttr(resourceName, "sort_by", "updated"), + resource.TestCheckResourceAttr(resourceName, "sort_direction", "desc"), + + resource.TestCheckResourceAttr(resourceName, "results.#", "1"), + resource.TestCheckResourceAttrSet(resourceName, "results.0.number"), + resource.TestCheckResourceAttr(resourceName, "results.0.base_ref", "main"), + resource.TestCheckResourceAttrSet(resourceName, "results.0.base_sha"), + resource.TestCheckResourceAttr(resourceName, "results.0.body", "test body"), + resource.TestCheckResourceAttr(resourceName, "results.0.draft", "false"), + resource.TestCheckResourceAttrSet(resourceName, "results.0.head_owner"), + resource.TestCheckResourceAttr(resourceName, "results.0.head_ref", "test"), + resource.TestCheckResourceAttr(resourceName, "results.0.head_repository", fmt.Sprintf("tf-acc-test-%s", randomID)), + resource.TestCheckResourceAttrSet(resourceName, "results.0.head_sha"), + resource.TestCheckResourceAttr(resourceName, "results.0.labels.#", "0"), + resource.TestCheckResourceAttr(resourceName, "results.0.maintainer_can_modify", "false"), + resource.TestCheckResourceAttrSet(resourceName, "results.0.opened_at"), + resource.TestCheckResourceAttrSet(resourceName, "results.0.opened_by"), + resource.TestCheckResourceAttr(resourceName, "results.0.state", "open"), + resource.TestCheckResourceAttr(resourceName, "results.0.title", "test title"), + resource.TestCheckResourceAttrSet(resourceName, "results.0.updated_at"), + ) + + 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 7068d94519..2a6c82ade0 100644 --- a/github/provider.go +++ b/github/provider.go @@ -60,6 +60,7 @@ func Provider() terraform.ResourceProvider { "github_repository_file": resourceGithubRepositoryFile(), "github_repository_milestone": resourceGithubRepositoryMilestone(), "github_repository_project": resourceGithubRepositoryProject(), + "github_repository_pull_request": resourceGithubRepositoryPullRequest(), "github_repository_webhook": resourceGithubRepositoryWebhook(), "github_repository": resourceGithubRepository(), "github_team_membership": resourceGithubTeamMembership(), @@ -85,6 +86,8 @@ func Provider() terraform.ResourceProvider { "github_repositories": dataSourceGithubRepositories(), "github_repository": dataSourceGithubRepository(), "github_repository_milestone": dataSourceGithubRepositoryMilestone(), + "github_repository_pull_request": dataSourceGithubRepositoryPullRequest(), + "github_repository_pull_requests": dataSourceGithubRepositoryPullRequests(), "github_team": dataSourceGithubTeam(), "github_user": dataSourceGithubUser(), }, diff --git a/github/resource_github_repository_pull_request.go b/github/resource_github_repository_pull_request.go new file mode 100644 index 0000000000..400bd47120 --- /dev/null +++ b/github/resource_github_repository_pull_request.go @@ -0,0 +1,294 @@ +package github + +import ( + "context" + "fmt" + "log" + "strconv" + "strings" + + "github.com/google/go-github/v32/github" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func resourceGithubRepositoryPullRequest() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubRepositoryPullRequestCreate, + Read: resourceGithubRepositoryPullRequestRead, + Update: resourceGithubRepositoryPullRequestUpdate, + Delete: resourceGithubRepositoryPullRequestDelete, + Importer: &schema.ResourceImporter{ + State: func(d *schema.ResourceData, m interface{}) ([]*schema.ResourceData, error) { + _, baseRepository, _, err := parsePullRequestID(d) + if err != nil { + return nil, err + } + d.Set("base_repository", baseRepository) + + return []*schema.ResourceData{d}, nil + }, + }, + + Schema: map[string]*schema.Schema{ + "owner": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "base_repository": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "base_ref": { + Type: schema.TypeString, + Required: true, + }, + "head_ref": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "title": { + // Even though the documentation does not explicitly mark the + // title field as required, attempts to create a PR with an + // empty title result in a "missing_field" validation error + // (HTTP 422). + Type: schema.TypeString, + Required: true, + }, + "body": { + Type: schema.TypeString, + Optional: true, + }, + "maintainer_can_modify": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "base_sha": { + Type: schema.TypeString, + Computed: true, + }, + "draft": { + // The "draft" field is an interesting corner case because while + // you can create a draft PR through the API, the documentation + // does not indicate that you can change this field during + // update: + // + // https://docs.github.com/en/rest/reference/pulls#update-a-pull-request + // + // And since you cannot manage the lifecycle of this field to + // reconcile the actual state with the desired one, this field + // cannot be managed by Terraform. + Type: schema.TypeBool, + Computed: true, + }, + "head_sha": { + Type: schema.TypeString, + Computed: true, + }, + "labels": { + Type: schema.TypeList, + Elem: &schema.Schema{Type: schema.TypeString}, + Computed: true, + Description: "List of names of labels on the PR", + }, + "number": { + Type: schema.TypeInt, + Computed: true, + }, + "opened_at": { + Type: schema.TypeInt, + Computed: true, + }, + "opened_by": { + Type: schema.TypeString, + Computed: true, + Description: "Username of the PR creator", + }, + "state": { + Type: schema.TypeString, + Computed: true, + }, + "updated_at": { + Type: schema.TypeInt, + Computed: true, + }, + }, + } +} + +func resourceGithubRepositoryPullRequestCreate(d *schema.ResourceData, meta interface{}) error { + ctx := context.TODO() + client := meta.(*Owner).v3client + + // For convenience, by default we expect that the base repository and head + // repository owners are the same, and both belong to the caller, indicating + // a "PR within the same repo" scenario. The head will *always* belong to + // the current caller, the base - not necessarily. The base will belong to + // another namespace in case of forks, and this resource supports them. + headOwner := meta.(*Owner).name + + baseOwner := headOwner + if explicitBaseOwner, ok := d.GetOk("owner"); ok { + baseOwner = explicitBaseOwner.(string) + } + + baseRepository := d.Get("base_repository").(string) + + head := d.Get("head_ref").(string) + if headOwner != baseOwner { + head = strings.Join([]string{headOwner, head}, ":") + } + + pullRequest, _, err := client.PullRequests.Create(ctx, baseOwner, baseRepository, &github.NewPullRequest{ + Title: github.String(d.Get("title").(string)), + Head: github.String(head), + Base: github.String(d.Get("base_ref").(string)), + Body: github.String(d.Get("body").(string)), + MaintainerCanModify: github.Bool(d.Get("maintainer_can_modify").(bool)), + }) + + if err != nil { + return err + } + + d.SetId(buildThreePartID(baseOwner, baseRepository, strconv.Itoa(pullRequest.GetNumber()))) + + return resourceGithubRepositoryPullRequestRead(d, meta) +} + +func resourceGithubRepositoryPullRequestRead(d *schema.ResourceData, meta interface{}) error { + ctx := context.TODO() + client := meta.(*Owner).v3client + + owner, repository, number, err := parsePullRequestID(d) + if err != nil { + return err + } + + pullRequest, _, err := client.PullRequests.Get(ctx, owner, repository, number) + if err != nil { + return err + } + + d.Set("number", pullRequest.GetNumber()) + + if head := pullRequest.GetHead(); head != nil { + d.Set("head_ref", head.GetRef()) + d.Set("head_sha", head.GetSHA()) + } else { + // Totally unexpected condition. Better do that than segfault, I guess? + log.Printf("[WARN] Head branch missing, expected %s", d.Get("head_ref")) + d.SetId("") + return nil + } + + if base := pullRequest.GetBase(); base != nil { + d.Set("base_ref", base.GetRef()) + d.Set("base_sha", base.GetSHA()) + } else { + // Seme logic as with the missing head branch. + log.Printf("[WARN] Base branch missing, expected %s", d.Get("base_ref")) + d.SetId("") + return nil + } + + d.Set("body", pullRequest.GetBody()) + d.Set("title", pullRequest.GetTitle()) + d.Set("draft", pullRequest.GetDraft()) + d.Set("maintainer_can_modify", pullRequest.GetMaintainerCanModify()) + d.Set("number", pullRequest.GetNumber()) + d.Set("state", pullRequest.GetState()) + d.Set("opened_at", pullRequest.GetCreatedAt().Unix()) + d.Set("updated_at", pullRequest.GetUpdatedAt().Unix()) + + if user := pullRequest.GetUser(); user != nil { + d.Set("opened_by", user.GetLogin()) + } + + labels := []string{} + for _, label := range pullRequest.Labels { + labels = append(labels, label.GetName()) + } + d.Set("labels", labels) + + return nil +} + +func resourceGithubRepositoryPullRequestUpdate(d *schema.ResourceData, meta interface{}) error { + ctx := context.TODO() + client := meta.(*Owner).v3client + + owner, repository, number, err := parsePullRequestID(d) + if err != nil { + return err + } + + update := &github.PullRequest{ + Title: github.String(d.Get("title").(string)), + Body: github.String(d.Get("body").(string)), + MaintainerCanModify: github.Bool(d.Get("maintainer_can_modify").(bool)), + } + + if d.HasChange("base_ref") { + update.Base = &github.PullRequestBranch{ + Ref: github.String(d.Get("base_ref").(string)), + } + } + + _, _, err = client.PullRequests.Edit(ctx, owner, repository, number, update) + if err == nil { + return resourceGithubRepositoryPullRequestRead(d, meta) + } + + errors := []string{fmt.Sprintf("could not update the Pull Request: %v", err)} + + if err := resourceGithubRepositoryPullRequestRead(d, meta); err != nil { + errors = append(errors, fmt.Sprintf("could not read the Pull Request after the failed update: %v", err)) + } + + return fmt.Errorf(strings.Join(errors, ", ")) +} + +func resourceGithubRepositoryPullRequestDelete(d *schema.ResourceData, meta interface{}) error { + // It's not entirely clear how to treat PR deletion according to Terraform's + // CRUD semantics. The approach we're taking here is to close the PR unless + // it's already closed or merged. Merging it feels intuitively wrong in what + // effectively is a destructor. + if d.Get("state").(string) != "open" { + d.SetId("") + return nil + } + + ctx := context.TODO() + client := meta.(*Owner).v3client + + owner, repository, number, err := parsePullRequestID(d) + if err != nil { + return err + } + + update := &github.PullRequest{State: github.String("closed")} + if _, _, err = client.PullRequests.Edit(ctx, owner, repository, number, update); err != nil { + return err + } + + d.SetId("") + return nil +} + +func parsePullRequestID(d *schema.ResourceData) (owner, repository string, number int, err error) { + var strNumber string + + if owner, repository, strNumber, err = parseThreePartID(d.Id(), "owner", "base_repository", "number"); err != nil { + return + } + + if number, err = strconv.Atoi(strNumber); err != nil { + err = fmt.Errorf("invalid PR number %s: %w", strNumber, err) + } + + return +} diff --git a/github/resource_github_repository_pull_request_test.go b/github/resource_github_repository_pull_request_test.go new file mode 100644 index 0000000000..c25f7b1d71 --- /dev/null +++ b/github/resource_github_repository_pull_request_test.go @@ -0,0 +1,96 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccGithubRepositoryPullRequest(t *testing.T) { + t.Run("manages the pull request lifecycle", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + 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 = "test" + source_branch = github_repository.test.default_branch + } + + resource "github_repository_file" "test" { + repository = github_repository.test.name + branch = github_branch.test.branch + file = "test" + content = "bar" + } + + resource "github_repository_pull_request" "test" { + base_repository = github_repository_file.test.repository + base_ref = github_repository.test.default_branch + head_ref = github_branch.test.branch + title = "test title" + body = "test body" + } + `, randomID) + + const resourceName = "github_repository_pull_request.test" + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + resourceName, "base_repository", + fmt.Sprintf("tf-acc-test-%s", randomID), + ), + resource.TestCheckResourceAttr(resourceName, "base_ref", "main"), + resource.TestCheckResourceAttr(resourceName, "head_ref", "test"), + resource.TestCheckResourceAttr(resourceName, "title", "test title"), + resource.TestCheckResourceAttr(resourceName, "body", "test body"), + resource.TestCheckResourceAttr(resourceName, "maintainer_can_modify", "false"), + resource.TestCheckResourceAttrSet(resourceName, "base_sha"), + resource.TestCheckResourceAttr(resourceName, "draft", "false"), + resource.TestCheckResourceAttrSet(resourceName, "head_sha"), + resource.TestCheckResourceAttr(resourceName, "labels.#", "0"), + resource.TestCheckResourceAttrSet(resourceName, "number"), + resource.TestCheckResourceAttrSet(resourceName, "opened_at"), + resource.TestCheckResourceAttrSet(resourceName, "opened_by"), + resource.TestCheckResourceAttr(resourceName, "state", "open"), + resource.TestCheckResourceAttrSet(resourceName, "updated_at"), + ) + + 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: resourceName, + 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) + }) + }) +} diff --git a/github/util.go b/github/util.go index 051ab379e9..cad1d106dd 100644 --- a/github/util.go +++ b/github/util.go @@ -63,6 +63,21 @@ func buildTwoPartID(a, b string) string { return fmt.Sprintf("%s:%s", a, b) } +// return the pieces of id `left:center:right` as left, center, right +func parseThreePartID(id, left, center, right string) (string, string, string, error) { + parts := strings.SplitN(id, ":", 3) + if len(parts) != 3 { + return "", "", "", fmt.Errorf("Unexpected ID format (%q). Expected %s:%s:%s", id, left, center, right) + } + + return parts[0], parts[1], parts[2], nil +} + +// format the strings into an id `a:b:c` +func buildThreePartID(a, b, c string) string { + return fmt.Sprintf("%s:%s:%s", a, b, c) +} + func expandStringList(configured []interface{}) []string { vs := make([]string, 0, len(configured)) for _, v := range configured { diff --git a/website/docs/d/repository_pull_request.html.markdown b/website/docs/d/repository_pull_request.html.markdown new file mode 100644 index 0000000000..4173601e61 --- /dev/null +++ b/website/docs/d/repository_pull_request.html.markdown @@ -0,0 +1,58 @@ +--- +layout: "github" +page_title: "GitHub: repository_pull_request" +description: |- + Get information on a single GitHub Pull Request. +--- + +# github_repository_pull_request + +Use this data source to retrieve information about a specific GitHub Pull Request in a repository. + +## Example Usage + +```hcl +data "github_repository_pull_request" "example" { + base_repository = "example_repository" + number = 1 +} +``` + +## Argument Reference + +* `base_repository` - (Required) Name of the base repository to retrieve the Pull Request from. + +* `number` - (Required) The number of the Pull Request within the repository. + +* `owner` - (Optional) Owner of the repository. If not provided, the provider's default owner is used. + +## Attributes Reference + +* `base_ref` - Name of the ref (branch) of the Pull Request base. + +* `base_sha` - Head commit SHA of the Pull Request base. + +* `body` - Body of the Pull Request. + +* `draft` - Indicates Whether this Pull Request is a draft. + +* `head_owner` - Owner of the Pull Request head repository. + +* `head_repository` - Name of the Pull Request head repository. + +* `head_sha` - Head commit SHA of the Pull Request head. + +* `labels` - List of label names set on the Pull Request. + +* `maintainer_can_modify` - Indicates whether the base repository maintainers can modify the Pull Request. + +* `opened_at` - Unix timestamp indicating the Pull Request creation time. + +* `opened_by` - GitHub login of the user who opened the Pull Request. + +* `state` - the current Pull Request state - can be "open", "closed" or "merged". + +* `title` - The title of the Pull Request. + +* `updated_at` - The timestamp of the last Pull Request update. + \ No newline at end of file diff --git a/website/docs/d/repository_pull_requests.html.markdown b/website/docs/d/repository_pull_requests.html.markdown new file mode 100644 index 0000000000..7777719664 --- /dev/null +++ b/website/docs/d/repository_pull_requests.html.markdown @@ -0,0 +1,72 @@ +--- +layout: "github" +page_title: "GitHub: repository_pull_requests" +description: |- + Get information on multiple GitHub Pull Requests. +--- + +# github_repository_pull_requests + +Use this data source to retrieve information about multiple GitHub Pull Requests in a repository. + +## Example Usage + +```hcl +data "github_repository_pull_requests" "example" { + base_repository = "example-repository" + base_ref = "main" + sort_by = "updated" + sort_direction = "desc" + state = "open" +} +``` + +## Argument Reference + +* `base_repository` - (Required) Name of the base repository to retrieve the Pull Requests from. + +* `owner` - (Optional) Owner of the repository. If not provided, the provider's default owner is used. + +* `base_ref` - (Optional) If set, filters Pull Requests by base branch name. + +* `head_ref` - (Optional) If set, filters Pull Requests by head user or head organization and branch name in the format of "user:ref-name" or "organization:ref-name". For example: "github:new-script-format" or "octocat:test-branch". + +* `sort_by` - (Optional) If set, indicates what to sort results by. Can be either "created", "updated", "popularity" (comment count) or "long-running" (age, filtering by pulls updated in the last month). Default: "created". + +* `sort_direction` - (Optional) If set, controls the direction of the sort. Can be either "asc" or "desc". Default: "asc". + +* `state` - (Optional) If set, filters Pull Requests by state. Can be "open", "closed", or "all". Default: "open". + +## Attributes Reference + +* `results` - Collection of Pull Requests matching the filters. Each of the results conforms to the following scheme: + + * `base_ref` - Name of the ref (branch) of the Pull Request base. + + * `base_sha` - Head commit SHA of the Pull Request base. + + * `body` - Body of the Pull Request. + + * `draft` - Indicates Whether this Pull Request is a draft. + + * `head_owner` - Owner of the Pull Request head repository. + + * `head_repository` - Name of the Pull Request head repository. + + * `head_sha` - Head commit SHA of the Pull Request head. + + * `labels` - List of label names set on the Pull Request. + + * `maintainer_can_modify` - Indicates whether the base repository maintainers can modify the Pull Request. + + * `number` - The number of the Pull Request within the repository. + + * `opened_at` - Unix timestamp indicating the Pull Request creation time. + + * `opened_by` - GitHub login of the user who opened the Pull Request. + + * `state` - the current Pull Request state - can be "open", "closed" or "merged". + + * `title` - The title of the Pull Request. + + * `updated_at` - The timestamp of the last Pull Request update. diff --git a/website/docs/r/repository_pull_request.html.markdown b/website/docs/r/repository_pull_request.html.markdown new file mode 100644 index 0000000000..24ccca034c --- /dev/null +++ b/website/docs/r/repository_pull_request.html.markdown @@ -0,0 +1,58 @@ +--- +layout: "github" +page_title: "GitHub: repository_pull_request" +description: |- + Get information on a single GitHub Pull Request. +--- + +# github_repository_pull_request + +This resource allows you to create and manage PullRequests for repositories within your GitHub organization or personal account. + +## Example Usage + +```hcl +resource "github_repository_pull_request" "example" { + base_repository = "example-repository" + base_ref = "main" + head_ref = "feature-branch" + title = "My newest feature" + body = "This will change everything" +} +``` + +## Argument Reference + +* `base_repository` - (Required) Name of the base repository to retrieve the Pull Requests from. + +* `base_ref` - (Required) Name of the branch serving as the base of the Pull Request. + +* `head_ref` - (Required) Name of the branch serving as the head of the Pull Request. + +* `owner` - (Optional) Owner of the repository. If not provided, the provider's default owner is used. + +* `title` - (Optional) The title of the Pull Request. + +* `body` - (Optional) Body of the Pull Request. + +* `maintainer_can_modify` - Controls whether the base repository maintainers can modify the Pull Request. Default: false. + +## Attributes Reference + +* `base_sha` - Head commit SHA of the Pull Request base. + +* `draft` - Indicates Whether this Pull Request is a draft. + +* `head_sha` - Head commit SHA of the Pull Request head. + +* `labels` - List of label names set on the Pull Request. + +* `number` - The number of the Pull Request within the repository. + +* `opened_at` - Unix timestamp indicating the Pull Request creation time. + +* `opened_by` - GitHub login of the user who opened the Pull Request. + +* `state` - the current Pull Request state - can be "open", "closed" or "merged". + +* `updated_at` - The timestamp of the last Pull Request update.