From 8614e671c28e529b13b4dc047022134884c3cb1b Mon Sep 17 00:00:00 2001 From: Timo Furrer Date: Fri, 8 Jul 2022 18:22:52 +0200 Subject: [PATCH] New Resource: `gitlab_project_issue_board` Closes: #772 --- docs/resources/project_issue_board.md | 124 ++++++++ .../gitlab_project_issue_board/import.sh | 2 + .../gitlab_project_issue_board/resource.tf | 58 ++++ internal/provider/helper_test.go | 26 ++ .../resource_gitlab_project_issue_board.go | 219 +++++++++++++ ...esource_gitlab_project_issue_board_test.go | 288 ++++++++++++++++++ .../schema_gitlab_project_issue_board.go | 163 ++++++++++ 7 files changed, 880 insertions(+) create mode 100644 docs/resources/project_issue_board.md create mode 100644 examples/resources/gitlab_project_issue_board/import.sh create mode 100644 examples/resources/gitlab_project_issue_board/resource.tf create mode 100644 internal/provider/resource_gitlab_project_issue_board.go create mode 100644 internal/provider/resource_gitlab_project_issue_board_test.go create mode 100644 internal/provider/schema_gitlab_project_issue_board.go diff --git a/docs/resources/project_issue_board.md b/docs/resources/project_issue_board.md new file mode 100644 index 000000000..54fbf42a5 --- /dev/null +++ b/docs/resources/project_issue_board.md @@ -0,0 +1,124 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "gitlab_project_issue_board Resource - terraform-provider-gitlab" +subcategory: "" +description: |- + The gitlab_project_issue_board resource allows to manage the lifecycle of a Project Issue Board. + ~> NOTE: If the board lists are changed all lists will be recreated. + Upstream API: GitLab REST API docs https://docs.gitlab.com/ee/api/boards.html +--- + +# gitlab_project_issue_board (Resource) + +The `gitlab_project_issue_board` resource allows to manage the lifecycle of a Project Issue Board. + +~> **NOTE:** If the board lists are changed all lists will be recreated. + +**Upstream API**: [GitLab REST API docs](https://docs.gitlab.com/ee/api/boards.html) + +## Example Usage + +```terraform +resource "gitlab_project" "example" { + name = "example project" + description = "Lorem Ipsum" + visibility_level = "public" +} + +resource "gitlab_user" "example" { + name = "example" + username = "example" + email = "example@example.com" + password = "example1$$$" +} + +resource "gitlab_project_membership" "example" { + project_id = gitlab_project.example.id + user_id = gitlab_user.example.id + access_level = "developer" +} + +resource "gitlab_project_milestone" "example" { + project = gitlab_project.example.id + title = "m1" +} + +resource "gitlab_project_issue_board" "this" { + project = gitlab_project.example.id + name = "Test Issue Board" + + lists { + assignee_id = gitlab_user.example.id + } + + lists { + milestone_id = gitlab_project_milestone.example.milestone_id + } + + depends_on = [ + gitlab_project_membership.example + ] +} + +resource "gitlab_project_issue_board" "list_syntax" { + project = gitlab_project.example.id + name = "Test Issue Board with list syntax" + + lists = [ + { + assignee_id = gitlab_user.example.id + }, + { + milestone_id = gitlab_project_milestone.example.milestone_id + } + ] + + depends_on = [ + gitlab_project_membership.example + ] +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the board. +- `project` (String) The ID or full path of the project maintained by the authenticated user. + +### Optional + +- `assignee_id` (Number) The assignee the board should be scoped to. Requires a GitLab EE license. +- `labels` (Set of String) The list of label names which the board should be scoped to. Requires a GitLab EE license. +- `lists` (Block List) The list of issue board lists (see [below for nested schema](#nestedblock--lists)) +- `milestone_id` (Number) The milestone the board should be scoped to. Requires a GitLab EE license. +- `weight` (Number) The weight range from 0 to 9, to which the board should be scoped to. Requires a GitLab EE license. + +### Read-Only + +- `id` (String) The ID of this resource. + + +### Nested Schema for `lists` + +Optional: + +- `assignee_id` (Number) The ID of the assignee the list should be scoped to. Requires a GitLab EE license. +- `iteration_id` (Number) The ID of the iteration the list should be scoped to. Requires a GitLab EE license. +- `label_id` (Number) The ID of the label the list should be scoped to. Requires a GitLab EE license. +- `milestone_id` (Number) The ID of the milestone the list should be scoped to. Requires a GitLab EE license. + +Read-Only: + +- `id` (Number) The ID of the list +- `position` (Number) The position of the list within the board. The position for the list is based on the its position in the `lists` array. + +## Import + +Import is supported using the following syntax: + +```shell +# You can import this resource with an id made up of `{project-id}:{issue-board-id}`, e.g. +terraform import gitlab_project_issue_board.kanban 42:1 +``` diff --git a/examples/resources/gitlab_project_issue_board/import.sh b/examples/resources/gitlab_project_issue_board/import.sh new file mode 100644 index 000000000..1b079e353 --- /dev/null +++ b/examples/resources/gitlab_project_issue_board/import.sh @@ -0,0 +1,2 @@ +# You can import this resource with an id made up of `{project-id}:{issue-board-id}`, e.g. +terraform import gitlab_project_issue_board.kanban 42:1 diff --git a/examples/resources/gitlab_project_issue_board/resource.tf b/examples/resources/gitlab_project_issue_board/resource.tf new file mode 100644 index 000000000..b4ef015dc --- /dev/null +++ b/examples/resources/gitlab_project_issue_board/resource.tf @@ -0,0 +1,58 @@ +resource "gitlab_project" "example" { + name = "example project" + description = "Lorem Ipsum" + visibility_level = "public" +} + +resource "gitlab_user" "example" { + name = "example" + username = "example" + email = "example@example.com" + password = "example1$$$" +} + +resource "gitlab_project_membership" "example" { + project_id = gitlab_project.example.id + user_id = gitlab_user.example.id + access_level = "developer" +} + +resource "gitlab_project_milestone" "example" { + project = gitlab_project.example.id + title = "m1" +} + +resource "gitlab_project_issue_board" "this" { + project = gitlab_project.example.id + name = "Test Issue Board" + + lists { + assignee_id = gitlab_user.example.id + } + + lists { + milestone_id = gitlab_project_milestone.example.milestone_id + } + + depends_on = [ + gitlab_project_membership.example + ] +} + +resource "gitlab_project_issue_board" "list_syntax" { + project = gitlab_project.example.id + name = "Test Issue Board with list syntax" + + lists = [ + { + assignee_id = gitlab_user.example.id + }, + { + milestone_id = gitlab_project_milestone.example.milestone_id + } + ] + + depends_on = [ + gitlab_project_membership.example + ] +} diff --git a/internal/provider/helper_test.go b/internal/provider/helper_test.go index b51850a85..598f53776 100644 --- a/internal/provider/helper_test.go +++ b/internal/provider/helper_test.go @@ -363,6 +363,32 @@ func testAccCreateProjectIssues(t *testing.T, pid interface{}, n int) []*gitlab. return issues } +func testAccCreateProjectIssueBoard(t *testing.T, pid interface{}) *gitlab.IssueBoard { + t.Helper() + + issueBoard, _, err := testGitlabClient.Boards.CreateIssueBoard(pid, &gitlab.CreateIssueBoardOptions{Name: gitlab.String(acctest.RandomWithPrefix("acctest"))}) + if err != nil { + t.Fatalf("could not create test issue board: %v", err) + } + + return issueBoard +} + +func testAccCreateProjectLabels(t *testing.T, pid interface{}, n int) []*gitlab.Label { + t.Helper() + + var labels []*gitlab.Label + for i := 0; i < n; i++ { + label, _, err := testGitlabClient.Labels.CreateLabel(pid, &gitlab.CreateLabelOptions{Name: gitlab.String(acctest.RandomWithPrefix("acctest")), Color: gitlab.String("#000000")}) + if err != nil { + t.Fatalf("could not create test label: %v", err) + } + labels = append(labels, label) + } + + return labels +} + // testAccAddGroupMembers is a test helper for adding users as members of a group. // It assumes the group will be destroyed at the end of the test and will not cleanup members. func testAccAddGroupMembers(t *testing.T, gid interface{}, users []*gitlab.User) { diff --git a/internal/provider/resource_gitlab_project_issue_board.go b/internal/provider/resource_gitlab_project_issue_board.go new file mode 100644 index 000000000..d43b27580 --- /dev/null +++ b/internal/provider/resource_gitlab_project_issue_board.go @@ -0,0 +1,219 @@ +package provider + +import ( + "context" + "fmt" + "log" + "strconv" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + gitlab "github.com/xanzy/go-gitlab" +) + +var _ = registerResource("gitlab_project_issue_board", func() *schema.Resource { + return &schema.Resource{ + Description: `The ` + "`" + `gitlab_project_issue_board` + "`" + ` resource allows to manage the lifecycle of a Project Issue Board. + +~> **NOTE:** If the board lists are changed all lists will be recreated. + +**Upstream API**: [GitLab REST API docs](https://docs.gitlab.com/ee/api/boards.html)`, + + CreateContext: resourceGitlabProjectIssueBoardCreate, + ReadContext: resourceGitlabProjectIssueBoardRead, + UpdateContext: resourceGitlabProjectIssueBoardUpdate, + DeleteContext: resourceGitlabProjectIssueBoardDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: gitlabProjectIssueBoardSchema(), + } +}) + +func resourceGitlabProjectIssueBoardCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*gitlab.Client) + + project := d.Get("project").(string) + options := gitlab.CreateIssueBoardOptions{ + Name: gitlab.String(d.Get("name").(string)), + } + + log.Printf("[DEBUG] create Project Issue Board %q in project %q", *options.Name, project) + issueBoard, _, err := client.Boards.CreateIssueBoard(project, &options, gitlab.WithContext(ctx)) + if err != nil { + return diag.FromErr(err) + } + + updateOptions := gitlab.UpdateIssueBoardOptions{} + if v, ok := d.GetOk("milestone_id"); ok { + updateOptions.MilestoneID = gitlab.Int(v.(int)) + } + if v, ok := d.GetOk("assignee_id"); ok { + updateOptions.AssigneeID = gitlab.Int(v.(int)) + } + if v, ok := d.GetOk("labels"); ok { + gitlabLabels := gitlab.Labels(*stringSetToStringSlice(v.(*schema.Set))) + updateOptions.Labels = &gitlabLabels + } + if v, ok := d.GetOk("weight"); ok { + updateOptions.Weight = gitlab.Int(v.(int)) + } + + if (gitlab.UpdateIssueBoardOptions{}) != updateOptions { + log.Printf("[DEBUG] update Project Issue Board %q in project %q after creation", *options.Name, project) + _, _, err = client.Boards.UpdateIssueBoard(project, issueBoard.ID, &updateOptions, gitlab.WithContext(ctx)) + if err != nil { + return diag.FromErr(err) + } + } + + if v, ok := d.GetOk("lists"); ok { + if err = resourceGitlabProjectIssueBoardCreateLists(ctx, client, project, issueBoard, v.([]interface{})); err != nil { + return diag.FromErr(err) + } + } + + d.SetId(resourceGitlabProjectIssueBoardBuildID(project, issueBoard.ID)) + return resourceGitlabProjectIssueBoardRead(ctx, d, meta) +} + +func resourceGitlabProjectIssueBoardRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*gitlab.Client) + project, issueBoardID, err := resourceGitlabProjectIssueBoardParseID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + log.Printf("[DEBUG] read Project Issue Board in project %q with id %q", project, issueBoardID) + issueBoard, _, err := client.Boards.GetIssueBoard(project, issueBoardID, gitlab.WithContext(ctx)) + if err != nil { + if is404(err) { + log.Printf("[DEBUG] Project Issue Board in project %s with id %d not found, removing from state", project, issueBoardID) + d.SetId("") + return nil + } + return diag.FromErr(err) + } + + stateMap := gitlabProjectIssueBoardToStateMap(project, issueBoard) + if err = setStateMapInResourceData(stateMap, d); err != nil { + return diag.FromErr(err) + } + return nil +} + +func resourceGitlabProjectIssueBoardUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*gitlab.Client) + project, issueBoardID, err := resourceGitlabProjectIssueBoardParseID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + options := &gitlab.UpdateIssueBoardOptions{} + if d.HasChange("name") { + options.Name = gitlab.String(d.Get("name").(string)) + } + if d.HasChange("milestone_id") { + options.MilestoneID = gitlab.Int(d.Get("milestone_id").(int)) + } + if d.HasChange("assignee_id") { + options.AssigneeID = gitlab.Int(d.Get("assignee_id").(int)) + } + if d.HasChange("labels") { + gitlabLabels := gitlab.Labels(*stringSetToStringSlice(d.Get("labels").(*schema.Set))) + options.Labels = &gitlabLabels + } + if d.HasChange("weight") { + options.Weight = gitlab.Int(d.Get("weight").(int)) + } + + log.Printf("[DEBUG] update Project Issue Board %q in project %q", issueBoardID, project) + updatedIssueBoard, _, err := client.Boards.UpdateIssueBoard(project, issueBoardID, options, gitlab.WithContext(ctx)) + if err != nil { + return diag.FromErr(err) + } + + if d.HasChange("lists") { + // NOTE: since we do not have a straightforward way to know which lists have been changed, we just re-create all lists + log.Printf("[DEBUG] deleting lists for Project Issue Board %q in project %q", updatedIssueBoard.Name, project) + for _, list := range updatedIssueBoard.Lists { + log.Printf("[DEBUG] deleting list %d for Project Issue Board %q in project %q", list.ID, updatedIssueBoard.Name, project) + _, err := client.Boards.DeleteIssueBoardList(project, issueBoardID, list.ID, gitlab.WithContext(ctx)) + if err != nil { + return diag.Errorf("failed to delete list %q for Project Issue Board %q in project %q: %s", list.ID, updatedIssueBoard.Name, project, err) + } + } + log.Printf("[DEBUG] deleted lists for Project Issue Board %q in project %q", updatedIssueBoard.Name, project) + + if err = resourceGitlabProjectIssueBoardCreateLists(ctx, client, project, updatedIssueBoard, d.Get("lists").([]interface{})); err != nil { + return diag.FromErr(err) + } + } + + return resourceGitlabProjectIssueBoardRead(ctx, d, meta) +} + +func resourceGitlabProjectIssueBoardDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*gitlab.Client) + project, issueBoardID, err := resourceGitlabProjectIssueBoardParseID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + log.Printf("[DEBUG] delete Project Issue Board in project %q with id %q", project, issueBoardID) + if _, err := client.Boards.DeleteIssueBoard(project, issueBoardID, gitlab.WithContext(ctx)); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGitlabProjectIssueBoardBuildID(project string, issueBoardID int) string { + return fmt.Sprintf("%s:%d", project, issueBoardID) +} + +func resourceGitlabProjectIssueBoardParseID(id string) (string, int, error) { + project, rawIssueBoardID, err := parseTwoPartID(id) + if err != nil { + return "", 0, err + } + + issueBoardID, err := strconv.Atoi(rawIssueBoardID) + if err != nil { + return "", 0, err + } + + return project, issueBoardID, nil +} + +func resourceGitlabProjectIssueBoardCreateLists(ctx context.Context, client *gitlab.Client, project string, issueBoard *gitlab.IssueBoard, lists []interface{}) error { + log.Printf("[DEBUG] creating lists for Project Issue Board %q in project %q", issueBoard.Name, project) + for i, listData := range lists { + position := i + 1 + log.Printf("[DEBUG] creating list at position %d for Project Issue Board %q in project %q", position, issueBoard.Name, project) + + listOptions := gitlab.CreateIssueBoardListOptions{} + if listData != nil { + l := listData.(map[string]interface{}) + if v, ok := l["label_id"]; ok && v != 0 { + listOptions.LabelID = gitlab.Int(v.(int)) + } + if v, ok := l["assignee_id"]; ok && v != 0 { + listOptions.AssigneeID = gitlab.Int(v.(int)) + } + if v, ok := l["milestone_id"]; ok && v != 0 { + listOptions.MilestoneID = gitlab.Int(v.(int)) + } + } + + list, _, err := client.Boards.CreateIssueBoardList(project, issueBoard.ID, &listOptions, gitlab.WithContext(ctx)) + if err != nil { + return fmt.Errorf("failed to create list at position %d for Project Issue Board %q in project %q: %s", position, issueBoard.Name, project, err) + } + + log.Printf("[DEBUG] created list at position %d for Project Issue Board %q in project %q", list.Position, issueBoard.Name, project) + } + + return nil +} diff --git a/internal/provider/resource_gitlab_project_issue_board_test.go b/internal/provider/resource_gitlab_project_issue_board_test.go new file mode 100644 index 000000000..38636816e --- /dev/null +++ b/internal/provider/resource_gitlab_project_issue_board_test.go @@ -0,0 +1,288 @@ +//go:build acceptance +// +build acceptance + +package provider + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccGitlabProjectIssueBoard_basic(t *testing.T) { + testProject := testAccCreateProject(t) + testMilestone := testAccAddProjectMilestones(t, testProject, 1)[0] + testLabels := testAccCreateProjectLabels(t, testProject.ID, 2) + testUser := testAccCreateUsers(t, 1)[0] + + // NOTE: there is no way to delete the last issue board, see + // https://gitlab.com/gitlab-org/gitlab/-/issues/367395 + testAccCreateProjectIssueBoard(t, testProject.ID) + + resource.ParallelTest(t, resource.TestCase{ + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGitlabProjectIssueBoardDestroy, + Steps: []resource.TestStep{ + // Verify creation + { + Config: fmt.Sprintf(` + resource "gitlab_project_issue_board" "this" { + project = "%d" + name = "Test Board" + } + `, testProject.ID), + }, + // Verify Import + { + ResourceName: "gitlab_project_issue_board.this", + ImportState: true, + ImportStateVerify: true, + }, + // Verify update with optional values (all optional attributes are EE only) + { + SkipFunc: isRunningInCE, + Config: fmt.Sprintf(` + resource "gitlab_project_issue_board" "this" { + project = "%d" + name = "Test Board" + milestone_id = %d + assignee_id = %d + labels = ["%s", "%s"] + weight = 8 + } + `, testProject.ID, testMilestone.ID, testUser.ID, testLabels[0].Name, testLabels[1].Name), + }, + // Verify Import + { + SkipFunc: isRunningInCE, + ResourceName: "gitlab_project_issue_board.this", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccGitlabProjectIssueBoard_AllOnCreateEE(t *testing.T) { + testAccCheckEE(t) + + testProject := testAccCreateProject(t) + testMilestones := testAccAddProjectMilestones(t, testProject, 2) + testLabels := testAccCreateProjectLabels(t, testProject.ID, 4) + testUsers := testAccCreateUsers(t, 2) + + // NOTE: there is no way to delete the last issue board, see + // https://gitlab.com/gitlab-org/gitlab/-/issues/367395 + testAccCreateProjectIssueBoard(t, testProject.ID) + + resource.ParallelTest(t, resource.TestCase{ + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGitlabProjectIssueBoardDestroy, + Steps: []resource.TestStep{ + // Verify creation with all attributes set (some are only available in the update API) + { + Config: fmt.Sprintf(` + resource "gitlab_project_issue_board" "this" { + project = "%d" + name = "Test Board" + milestone_id = %d + assignee_id = %d + labels = ["%s", "%s"] + weight = 8 + } + `, testProject.ID, testMilestones[0].ID, testUsers[0].ID, testLabels[0].Name, testLabels[1].Name), + }, + // Verify Import + { + ResourceName: "gitlab_project_issue_board.this", + ImportState: true, + ImportStateVerify: true, + }, + // Verify update with changed attributes + { + Config: fmt.Sprintf(` + resource "gitlab_project_issue_board" "this" { + project = "%d" + name = "Test Board" + milestone_id = %d + assignee_id = %d + labels = ["%s", "%s"] + weight = 8 + } + `, testProject.ID, testMilestones[1].ID, testUsers[1].ID, testLabels[2].Name, testLabels[3].Name), + }, + // Verify Import + { + ResourceName: "gitlab_project_issue_board.this", + ImportState: true, + ImportStateVerify: true, + }, + // Verify update with removed optional attributes + { + Config: fmt.Sprintf(` + resource "gitlab_project_issue_board" "this" { + project = "%d" + name = "Test Board" + } + `, testProject.ID), + }, + // Verify Import + { + ResourceName: "gitlab_project_issue_board.this", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccGitlabProjectIssueBoard_Lists(t *testing.T) { + testProject := testAccCreateProject(t) + testMilestones := testAccAddProjectMilestones(t, testProject, 2) + testLabels := testAccCreateProjectLabels(t, testProject.ID, 4) + testUsers := testAccCreateUsers(t, 2) + testAccAddProjectMembers(t, testProject.ID, testUsers) + + // NOTE: there is no way to delete the last issue board, see + // https://gitlab.com/gitlab-org/gitlab/-/issues/367395 + testAccCreateProjectIssueBoard(t, testProject.ID) + + resource.ParallelTest(t, resource.TestCase{ + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGitlabProjectIssueBoardDestroy, + Steps: []resource.TestStep{ + // Create Board with 2 lists with core features + { + Config: fmt.Sprintf(` + resource "gitlab_project_issue_board" "this" { + project = "%d" + name = "Test Board" + + lists { + label_id = %d + } + + lists { + label_id = %d + } + } + `, testProject.ID, testLabels[0].ID, testLabels[1].ID), + }, + // Verify import + { + ResourceName: "gitlab_project_issue_board.this", + ImportState: true, + ImportStateVerify: true, + }, + // Update Board list labels + { + Config: fmt.Sprintf(` + resource "gitlab_project_issue_board" "this" { + project = "%d" + name = "Test Board" + + lists { + label_id = %d + } + + lists { + label_id = %d + } + } + `, testProject.ID, testLabels[2].ID, testLabels[3].ID), + }, + // Verify Import + { + ResourceName: "gitlab_project_issue_board.this", + ImportState: true, + ImportStateVerify: true, + }, + // Force a destroy for the board so that it can be recreated as the same resource + { + SkipFunc: isRunningInCE, + Config: ` `, // requires a space for empty config + }, + { + SkipFunc: isRunningInCE, + Config: fmt.Sprintf(` + resource "gitlab_project_issue_board" "this" { + project = "%d" + name = "Test Board" + + lists { + label_id = %d + } + + lists { + assignee_id = %d + } + + lists { + milestone_id = %d + } + } + `, testProject.ID, testLabels[0].ID, testUsers[0].ID, testMilestones[0].ID), + }, + // Verify Import + { + ResourceName: "gitlab_project_issue_board.this", + ImportState: true, + ImportStateVerify: true, + }, + { + SkipFunc: isRunningInCE, + Config: fmt.Sprintf(` + resource "gitlab_project_issue_board" "this" { + project = "%d" + name = "Test Board" + + lists { + label_id = %d + } + + lists { + assignee_id = %d + } + + lists { + milestone_id = %d + } + } + `, testProject.ID, testLabels[1].ID, testUsers[1].ID, testMilestones[1].ID), + }, + // Verify Import + { + ResourceName: "gitlab_project_issue_board.this", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccCheckGitlabProjectIssueBoardDestroy(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "gitlab_project_issue_board" { + continue + } + + project, issueBoardID, err := resourceGitlabProjectIssueBoardParseID(rs.Primary.ID) + if err != nil { + return err + } + + subject, _, err := testGitlabClient.Boards.GetIssueBoard(project, issueBoardID) + if err == nil && subject != nil { + return fmt.Errorf("gitlab_project_issue_board resource '%s' still exists", rs.Primary.ID) + } + + if err != nil && !is404(err) { + return err + } + + return nil + } + return nil +} diff --git a/internal/provider/schema_gitlab_project_issue_board.go b/internal/provider/schema_gitlab_project_issue_board.go new file mode 100644 index 000000000..7139d4552 --- /dev/null +++ b/internal/provider/schema_gitlab_project_issue_board.go @@ -0,0 +1,163 @@ +package provider + +import ( + "sort" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/xanzy/go-gitlab" +) + +func gitlabProjectIssueBoardSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "project": { + Description: "The ID or full path of the project maintained by the authenticated user.", + Type: schema.TypeString, + ForceNew: true, + Required: true, + }, + "name": { + Description: "The name of the board.", + Type: schema.TypeString, + Required: true, + }, + "assignee_id": { + Description: "The assignee the board should be scoped to. Requires a GitLab EE license.", + Type: schema.TypeInt, + Optional: true, + }, + "milestone_id": { + Description: "The milestone the board should be scoped to. Requires a GitLab EE license.", + Type: schema.TypeInt, + Optional: true, + }, + "labels": { + Description: "The list of label names which the board should be scoped to. Requires a GitLab EE license.", + Type: schema.TypeSet, + Elem: &schema.Schema{Type: schema.TypeString}, + Optional: true, + }, + "weight": { + Description: "The weight range from 0 to 9, to which the board should be scoped to. Requires a GitLab EE license.", + Type: schema.TypeInt, + Optional: true, + ValidateDiagFunc: validation.ToDiagFunc(validation.IntBetween(0, 9)), + }, + "lists": { + Description: "The list of issue board lists", + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Description: "The ID of the list", + Type: schema.TypeInt, + Computed: true, + }, + "label_id": { + Description: "The ID of the label the list should be scoped to. Requires a GitLab EE license.", + Type: schema.TypeInt, + Optional: true, + // NOTE(TF): not supported by the SDK yet, see https://github.com/hashicorp/terraform-plugin-sdk/issues/71 + // Anyways, GitLab will complain about this, so no big deal ... + // ConflictsWith: []string{"lists.assignee_id", "lists.milestone_id", "lists.iteration_id"}, + }, + "assignee_id": { + Description: "The ID of the assignee the list should be scoped to. Requires a GitLab EE license.", + Type: schema.TypeInt, + Optional: true, + // NOTE(TF): not supported by the SDK yet, see https://github.com/hashicorp/terraform-plugin-sdk/issues/71 + // Anyways, GitLab will complain about this, so no big deal ... + // ConflictsWith: []string{"lists.label_id", "lists.milestone_id", "lists.iteration_id"}, + }, + "milestone_id": { + Description: "The ID of the milestone the list should be scoped to. Requires a GitLab EE license.", + Type: schema.TypeInt, + Optional: true, + // NOTE(TF): not supported by the SDK yet, see https://github.com/hashicorp/terraform-plugin-sdk/issues/71 + // Anyways, GitLab will complain about this, so no big deal ... + // ConflictsWith: []string{"lists.label_id", "lists.assignee_id", "lists.iteration_id"}, + }, + "iteration_id": { + Description: "The ID of the iteration the list should be scoped to. Requires a GitLab EE license.", + Type: schema.TypeInt, + Optional: true, + // NOTE(TF): not supported by the SDK yet, see https://github.com/hashicorp/terraform-plugin-sdk/issues/71 + // Anyways, GitLab will complain about this, so no big deal ... + // ConflictsWith: []string{"lists.label_id", "lists.assignee_id", "lists.milestone_id"}, + }, + "position": { + Description: "The position of the list within the board. The position for the list is based on the its position in the `lists` array.", + Type: schema.TypeInt, + Computed: true, + }, + }, + }, + }, + } +} + +func gitlabProjectIssueBoardToStateMap(project string, issueBoard *gitlab.IssueBoard) map[string]interface{} { + stateMap := make(map[string]interface{}) + stateMap["project"] = project + stateMap["name"] = issueBoard.Name + if issueBoard.Milestone != nil { + stateMap["milestone_id"] = issueBoard.Milestone.ID + } else { + stateMap["milestone_id"] = nil + } + if issueBoard.Assignee != nil { + stateMap["assignee_id"] = issueBoard.Assignee.ID + } else { + stateMap["assignee_id"] = nil + } + stateMap["weight"] = issueBoard.Weight + stateMap["labels"] = extractLabelNames(issueBoard.Labels) + stateMap["lists"] = flattenProjectIssueBoardLists(issueBoard.Lists) + return stateMap +} + +func flattenProjectIssueBoardLists(lists []*gitlab.BoardList) (values []map[string]interface{}) { + // GitLab returns the lists in arbitrary order, so we need to sort them by position first + sort.Slice(lists, func(i, j int) bool { + return lists[i].Position < lists[j].Position + }) + for _, list := range lists { + v := map[string]interface{}{ + "id": list.ID, + "position": list.Position, + } + + if list.Label != nil { + v["label_id"] = list.Label.ID + } else { + v["label_id"] = nil + } + if list.Assignee != nil { + v["assignee_id"] = list.Assignee.ID + } else { + v["assignee_id"] = nil + } + if list.Milestone != nil { + v["milestone_id"] = list.Milestone.ID + } else { + v["milestone_id"] = nil + } + if list.Iteration != nil { + v["iteration_id"] = list.Iteration.ID + } else { + v["iteration_id"] = nil + } + + values = append(values, v) + } + return values +} + +func extractLabelNames(labels []*gitlab.LabelDetails) []string { + var labelNames []string + for _, label := range labels { + labelNames = append(labelNames, label.Name) + } + return labelNames +}