diff --git a/bitbucket/provider.go b/bitbucket/provider.go index a431ae1..a2f7d84 100644 --- a/bitbucket/provider.go +++ b/bitbucket/provider.go @@ -48,6 +48,7 @@ func Provider() terraform.ResourceProvider { }, ResourcesMap: map[string]*schema.Resource{ "bitbucketserver_banner": resourceBanner(), + "bitbucketserver_default_reviewers_condition": resourceDefaultReviewersCondition(), "bitbucketserver_global_permissions_group": resourceGlobalPermissionsGroup(), "bitbucketserver_global_permissions_user": resourceGlobalPermissionsUser(), "bitbucketserver_group": resourceGroup(), diff --git a/bitbucket/resource_default_reviewers_condition.go b/bitbucket/resource_default_reviewers_condition.go new file mode 100644 index 0000000..50891bf --- /dev/null +++ b/bitbucket/resource_default_reviewers_condition.go @@ -0,0 +1,369 @@ +package bitbucket + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/url" + "strconv" + "strings" + + "github.com/hashicorp/terraform/helper/schema" +) + +type Reviewer struct { + ID int `json:"id,omitempty"` +} + +type MatcherType struct { + ID string `json:"id,omitempty"` +} + +type Matcher struct { + ID string `json:"id,omitempty"` + Type MatcherType `json:"type,omitempty"` +} + +type RefMatcher struct { + ID string `json:"id,omitempty"` + Type struct { + ID string `json:"id,omitempty"` + } `json:"type,omitempty"` +} + +type DefaultReviewersConditionPayload struct { + SourceMatcher Matcher `json:"sourceMatcher,omitempty"` + TargetMatcher Matcher `json:"targetMatcher,omitempty"` + Reviewers []Reviewer `json:"reviewers,omitempty"` + RequiredApprovals int `json:"requiredApprovals,omitempty"` +} + +type DefaultReviewersConditionResp struct { + ID int `json:"id,omitempty"` + RequiredApprovals int `json:"requiredApprovals,omitempty"` + Reviewers []Reviewer `json:"reviewers,omitempty"` + SourceRefMatcher RefMatcher `json:"sourceRefMatcher,omitempty"` + TargetRefMatcher RefMatcher `json:"targetRefMatcher,omitempty"` +} + +var matcherDesc = `id can be either "any" to match all branches, "refs/heads/master" to match certain branch, "pattern" to match multiple branches or "development" to match branching model. type_id must be one of: "ANY_REF", "BRANCH", "PATTERN", "MODEL_BRANCH".` + +var validMatcherTypeIDs = []string{ + "ANY_REF", "BRANCH", "PATTERN", "MODEL_BRANCH", +} + +func resourceDefaultReviewersCondition() *schema.Resource { + return &schema.Resource{ + Create: resourceDefaultReviewersConditionCreate, + Read: resourceDefaultReviewersConditionRead, + Delete: resourceDefaultReviewersConditionDelete, + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "project_key": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "repository_slug": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "source_matcher": { + Type: schema.TypeMap, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Required: true, + ForceNew: true, + Description: matcherDesc, + }, + "target_matcher": { + Type: schema.TypeMap, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Required: true, + ForceNew: true, + Description: matcherDesc, + }, + "reviewers": { + Type: schema.TypeSet, + Elem: &schema.Schema{Type: schema.TypeInt}, + Required: true, + Set: schema.HashInt, + ForceNew: true, + MinItems: 1, + Description: "IDs of users to become default reviewers when you create a pull request.", + }, + "required_approvals": { + Type: schema.TypeInt, + Required: true, + ForceNew: true, + Description: "The number of default reviewers that must approve a pull request.", + }, + }, + } +} + +func refMatcherToMatcher(refMatcher RefMatcher) Matcher { + convertID := func(id string) string { + if id == "ANY_REF_MATCHER_ID" { + return "any" + } + + return id + } + + return Matcher{ + ID: convertID(refMatcher.ID), + Type: MatcherType{ + ID: refMatcher.Type.ID, + }, + } +} + +func expandReviewers(set *schema.Set) []Reviewer { + list := set.List() + + rs := make([]Reviewer, 0, len(list)) + + for _, v := range list { + rs = append(rs, Reviewer{ID: v.(int)}) + } + + return rs +} + +func collapseReviewers(reviewers []Reviewer) *schema.Set { + reviewerIDs := make([]interface{}, 0) + + for _, r := range reviewers { + reviewerIDs = append(reviewerIDs, r.ID) + } + + return schema.NewSet(schema.HashInt, reviewerIDs) +} + +func expandMatcher(matcherMap map[string]interface{}) Matcher { + return Matcher{ + ID: matcherMap["id"].(string), + Type: MatcherType{ + ID: matcherMap["type_id"].(string), + }, + } +} + +func collapseMatcher(matcher Matcher) map[string]interface{} { + return map[string]interface{}{ + "id": matcher.ID, + "type_id": matcher.Type.ID, + } +} + +func parseResourceID(id string) (string, string, string, error) { + parts := strings.Split(id, ":") + + errMsg := fmt.Errorf("invalid format of ID (%s), expected condition_id:project_key or condition_id:project_key:repository_slug", id) + + if len(parts) != 2 && len(parts) != 3 { + return "", "", "", errMsg + } + + if len(parts) == 2 { + if parts[0] == "" || parts[1] == "" { + return "", "", "", errMsg + } + + return parts[0], parts[1], "", nil + } + + if parts[0] == "" || parts[1] == "" || parts[2] == "" { + return "", "", "", errMsg + } + + return parts[0], parts[1], parts[2], nil +} + +func createResourceID(conditionID int, projectKey string, repositorySlug string) string { + if repositorySlug == "" { + return fmt.Sprintf("%v:%s", conditionID, projectKey) + } + + return fmt.Sprintf("%v:%s:%s", conditionID, projectKey, repositorySlug) +} + +func getCreateConditionURI(projectKey string, repositorySlug string) string { + if repositorySlug == "" { + return fmt.Sprintf("/rest/default-reviewers/1.0/projects/%s/condition", + url.PathEscape(projectKey), + ) + } + + return fmt.Sprintf("/rest/default-reviewers/1.0/projects/%s/repos/%s/condition", + url.PathEscape(projectKey), + url.PathEscape(repositorySlug), + ) +} + +func getReadConditionURI(projectKey string, repositorySlug string) string { + if repositorySlug == "" { + return fmt.Sprintf("/rest/default-reviewers/1.0/projects/%s/conditions", + url.PathEscape(projectKey), + ) + } + + return fmt.Sprintf("/rest/default-reviewers/1.0/projects/%s/repos/%s/conditions", + url.PathEscape(projectKey), + url.PathEscape(repositorySlug), + ) +} + +func getDeleteConditionURI(conditionID string, projectKey string, repositorySlug string) string { + if repositorySlug == "" { + return fmt.Sprintf("/rest/default-reviewers/1.0/projects/%s/condition/%s", + url.PathEscape(projectKey), + url.PathEscape(conditionID), + ) + } + + return fmt.Sprintf("/rest/default-reviewers/1.0/projects/%s/repos/%s/condition/%s", + url.PathEscape(projectKey), + url.PathEscape(repositorySlug), + url.PathEscape(conditionID), + ) +} + +func contains(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} + +func resourceDefaultReviewersConditionCreate(d *schema.ResourceData, m interface{}) error { + projectKey := d.Get("project_key").(string) + repositorySlug := d.Get("repository_slug").(string) + + condition := &DefaultReviewersConditionPayload{ + SourceMatcher: expandMatcher(d.Get("source_matcher").(map[string]interface{})), + TargetMatcher: expandMatcher(d.Get("target_matcher").(map[string]interface{})), + Reviewers: expandReviewers(d.Get("reviewers").(*schema.Set)), + RequiredApprovals: d.Get("required_approvals").(int), + } + + if contains(validMatcherTypeIDs, condition.SourceMatcher.Type.ID) == false { + return fmt.Errorf("source_matcher.type_id %s must be one of %v", condition.SourceMatcher.Type.ID, validMatcherTypeIDs) + } + + if contains(validMatcherTypeIDs, condition.TargetMatcher.Type.ID) == false { + return fmt.Errorf("target_matcher.type_id %s must be one of %v", condition.TargetMatcher.Type.ID, validMatcherTypeIDs) + } + + if condition.RequiredApprovals > len(condition.Reviewers) { + return fmt.Errorf("required_approvals %d cannot be more than length of reviewers %d", condition.RequiredApprovals, len(condition.Reviewers)) + } + + bytedata, err := json.Marshal(condition) + + if err != nil { + return err + } + + client := m.(*BitbucketServerProvider).BitbucketClient + + resp, err := client.Post(getCreateConditionURI(projectKey, repositorySlug), bytes.NewBuffer(bytedata)) + + if err != nil { + return err + } + + var newCondition DefaultReviewersConditionResp + + body, err := ioutil.ReadAll(resp.Body) + + if err != nil { + return err + } + + err = json.Unmarshal(body, &newCondition) + + if err != nil { + return err + } + + d.SetId(createResourceID(newCondition.ID, projectKey, repositorySlug)) + + return resourceDefaultReviewersConditionRead(d, m) +} + +func resourceDefaultReviewersConditionRead(d *schema.ResourceData, m interface{}) error { + conditionID, projectKey, repositorySlug, err := parseResourceID(d.Id()) + + if err != nil { + return err + } + + client := m.(*BitbucketServerProvider).BitbucketClient + + resp, err := client.Get(getReadConditionURI(projectKey, repositorySlug)) + + if err != nil { + return err + } + + if resp.StatusCode != 200 { + return fmt.Errorf("unable to find a matching default reviewers condition %s. API returned %d", d.Id(), resp.StatusCode) + } + + var conditions []DefaultReviewersConditionResp + + body, err := ioutil.ReadAll(resp.Body) + + if err != nil { + return err + } + + err = json.Unmarshal(body, &conditions) + + if err != nil { + return err + } + + for _, c := range conditions { + cID := strconv.Itoa(c.ID) + + if cID == conditionID { + d.Set("project_key", projectKey) + d.Set("repository_slug", repositorySlug) + d.Set("source_matcher", collapseMatcher(refMatcherToMatcher(c.SourceRefMatcher))) + d.Set("target_matcher", collapseMatcher(refMatcherToMatcher(c.TargetRefMatcher))) + d.Set("reviewers", collapseReviewers(c.Reviewers)) + d.Set("required_approvals", c.RequiredApprovals) + + return nil + } + } + + return fmt.Errorf("unable to find a matching default reviewers condition %s", d.Id()) +} + +func resourceDefaultReviewersConditionDelete(d *schema.ResourceData, m interface{}) error { + conditionID, projectKey, repositorySlug, err := parseResourceID(d.Id()) + + if err != nil { + return err + } + + client := m.(*BitbucketServerProvider).BitbucketClient + + _, err = client.Delete(getDeleteConditionURI(conditionID, projectKey, repositorySlug)) + + return err +} diff --git a/bitbucket/resource_default_reviewers_condition_test.go b/bitbucket/resource_default_reviewers_condition_test.go new file mode 100644 index 0000000..afd7330 --- /dev/null +++ b/bitbucket/resource_default_reviewers_condition_test.go @@ -0,0 +1,149 @@ +package bitbucket + +import ( + "fmt" + "math/rand" + "regexp" + "testing" + "time" + + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccBitbucketDefaultReviewersCondition_forProject(t *testing.T) { + key := fmt.Sprintf("%v", rand.New(rand.NewSource(time.Now().UnixNano())).Int()) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckBitbucketDefaultReviewersConditionDestroy, + Steps: []resource.TestStep{ + { + Config: testAccBitbucketDefaultReviewersConditionResourceForProject(key, 1), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("bitbucketserver_default_reviewers_condition.test", "project_key", "TEST"+key), + resource.TestCheckResourceAttr("bitbucketserver_default_reviewers_condition.test", "source_matcher.id", "any"), + resource.TestCheckResourceAttr("bitbucketserver_default_reviewers_condition.test", "source_matcher.type_id", "ANY_REF"), + resource.TestCheckResourceAttr("bitbucketserver_default_reviewers_condition.test", "target_matcher.id", "any"), + resource.TestCheckResourceAttr("bitbucketserver_default_reviewers_condition.test", "target_matcher.type_id", "ANY_REF"), + resource.TestCheckResourceAttr("bitbucketserver_default_reviewers_condition.test", "reviewers.#", "1"), + resource.TestCheckResourceAttr("bitbucketserver_default_reviewers_condition.test", "required_approvals", "1"), + ), + }, + }, + }) +} + +func TestAccBitbucketDefaultReviewersCondition_forRepository(t *testing.T) { + key := fmt.Sprintf("%v", rand.New(rand.NewSource(time.Now().UnixNano())).Int()) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckBitbucketDefaultReviewersConditionDestroy, + Steps: []resource.TestStep{ + { + Config: testAccBitbucketDefaultReviewersConditionResourceForRepository(key), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("bitbucketserver_default_reviewers_condition.test", "project_key", "TEST"+key), + resource.TestCheckResourceAttr("bitbucketserver_default_reviewers_condition.test", "repository_slug", "test-repo-"+key), + resource.TestCheckResourceAttr("bitbucketserver_default_reviewers_condition.test", "source_matcher.id", "any"), + resource.TestCheckResourceAttr("bitbucketserver_default_reviewers_condition.test", "source_matcher.type_id", "ANY_REF"), + resource.TestCheckResourceAttr("bitbucketserver_default_reviewers_condition.test", "target_matcher.id", "any"), + resource.TestCheckResourceAttr("bitbucketserver_default_reviewers_condition.test", "target_matcher.type_id", "ANY_REF"), + resource.TestCheckResourceAttr("bitbucketserver_default_reviewers_condition.test", "reviewers.#", "1"), + resource.TestCheckResourceAttr("bitbucketserver_default_reviewers_condition.test", "required_approvals", "1"), + ), + }, + { + ResourceName: "bitbucketserver_default_reviewers_condition.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccBitbucketDefaultReviewersCondition_expectRequiredApprovalsError(t *testing.T) { + key := fmt.Sprintf("%v", rand.New(rand.NewSource(time.Now().UnixNano())).Int()) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: testAccBitbucketDefaultReviewersConditionResourceForProject(key, 2), + ExpectError: regexp.MustCompile("required_approvals 2 cannot be more than length of reviewers 1"), + }, + }, + }) +} + +func testAccCheckBitbucketDefaultReviewersConditionDestroy(s *terraform.State) error { + _, ok := s.RootModule().Resources["bitbucketserver_default_reviewers_condition.test"] + + if !ok { + return fmt.Errorf("not found %s", "bitbucketserver_default_reviewers_condition.test") + } + + return nil +} + +func testAccBitbucketDefaultReviewersConditionResourceForProject(key string, requiredApprovals int) string { + return fmt.Sprintf(` +resource "bitbucketserver_project" "test" { + key = "TEST%s" + name = "test-project-%s" +} + +data "bitbucketserver_user" "reviewer" { + name = "admin" +} + +resource "bitbucketserver_default_reviewers_condition" "test" { + project_key = bitbucketserver_project.test.key + source_matcher = { + id = "any" + type_id = "ANY_REF" + } + target_matcher = { + id = "any" + type_id = "ANY_REF" + } + reviewers = [data.bitbucketserver_user.reviewer.user_id] + required_approvals = %d +}`, key, key, requiredApprovals) +} + +func testAccBitbucketDefaultReviewersConditionResourceForRepository(key string) string { + return fmt.Sprintf(` +resource "bitbucketserver_project" "test" { + key = "TEST%s" + name = "test-project-%s" +} + +resource "bitbucketserver_repository" "test" { + project = bitbucketserver_project.test.key + name = "test-repo-%s" +} + +data "bitbucketserver_user" "reviewer" { + name = "admin" +} + +resource "bitbucketserver_default_reviewers_condition" "test" { + project_key = bitbucketserver_project.test.key + repository_slug = bitbucketserver_repository.test.slug + source_matcher = { + id = "any" + type_id = "ANY_REF" + } + target_matcher = { + id = "any" + type_id = "ANY_REF" + } + reviewers = [data.bitbucketserver_user.reviewer.user_id] + required_approvals = 1 +}`, key, key, key) +} diff --git a/docusaurus/docs/resource_default_reviewers_condition.md b/docusaurus/docs/resource_default_reviewers_condition.md new file mode 100644 index 0000000..4f73745 --- /dev/null +++ b/docusaurus/docs/resource_default_reviewers_condition.md @@ -0,0 +1,52 @@ +--- +id: bitbucketserver_default_reviewers_condition +title: bitbucketserver_default_reviewers_condition +--- + +Create a default reviewers condition for project or repository. + +## Example Usage + +```hcl +resource "bitbucketserver_default_reviewers_condition" "condition" { + project_key = "PRO" + repository_slug = "repository-1" + source_matcher = { + id = "any" + type_id = "ANY_REF" + } + target_matcher = { + id = "any" + type_id = "ANY_REF" + } + reviewers = [1] + required_approvals = 1 +} +``` + +## Argument Reference + +* `project_key` - Required. Project key. +* `repository_slug` - Optional. Repository slug. If empty, default reviewers condition will be created for the whole project. +* `source_matcher.id` - Required. Source branch matcher id. It can be either `"any"` to match all branches, `"refs/heads/master"` to match certain branch, `"pattern"` to match multiple branches or `"development"` to match branching model. +* `source_matcher.type_id` - Required. Source branch matcher type.It must be one of: `"ANY_REF"`, `"BRANCH"`, `"PATTERN"`, `"MODEL_BRANCH"`. +* `target_matcher.id` - Required. Target branch matcher id. It can be either `"any"` to match all branches, `"refs/heads/master"` to match certain branch, `"pattern"` to match multiple branches or `"development"` to match branching model. +* `target_matcher.type_id` - Required. Target branch matcher type. It must be one of: `"ANY_REF"`, `"BRANCH"`, `"PATTERN"`, `"MODEL_BRANCH"`. +* `reviewers` - Required. IDs of Bitbucket users to become default reviewers when new pull request is created. +* `required_approvals` - Required. The number of default reviewers that must approve a pull request. Can't be higher than length of `reviewers`. + +You can find more information about [how to use branch matchers here](https://confluence.atlassian.com/bitbucketserver/add-default-reviewers-to-pull-requests-834221295.html). + +## Import + +Import a default reviewers condition reference via the ID in this format `condition_id:project_key:repository_slug`. + +``` +terraform import bitbucketserver_default_reviewers_condition.test 1:pro:repo +``` + +When importing a default reviewers condition for the whole project omit the `repository_slug`. + +``` +terraform import bitbucketserver_default_reviewers_condition.test 1:pro +``` diff --git a/docusaurus/website/i18n/en.json b/docusaurus/website/i18n/en.json index 28555d8..69b33db 100644 --- a/docusaurus/website/i18n/en.json +++ b/docusaurus/website/i18n/en.json @@ -52,6 +52,9 @@ "bitbucketserver_banner": { "title": "bitbucketserver_banner" }, + "bitbucketserver_default_reviewers_condition": { + "title": "bitbucketserver_default_reviewers_condition" + }, "bitbucketserver_global_permissions_group": { "title": "bitbucketserver_global_permissions_group" }, diff --git a/docusaurus/website/sidebars.json b/docusaurus/website/sidebars.json index 75f676c..4f87bfe 100755 --- a/docusaurus/website/sidebars.json +++ b/docusaurus/website/sidebars.json @@ -21,6 +21,7 @@ ], "Resources": [ "bitbucketserver_banner", + "bitbucketserver_default_reviewers_condition", "bitbucketserver_global_permissions_group", "bitbucketserver_global_permissions_user", "bitbucketserver_group",