diff --git a/board.go b/board.go new file mode 100644 index 0000000..d7266dc --- /dev/null +++ b/board.go @@ -0,0 +1,124 @@ +package jira + +import ( + "fmt" + "net/http" +) + +type BoardService struct { + client *Client +} + +//Type for boards list +type BoardsList struct { + MaxResults int `json:"maxResults"` + StartAt int `json:"startAt"` + Total int `json:"total"` + IsLast bool `json:"isLast"` + Values []Board `json:"values"` +} + +// Board represents a JIRA board +type Board struct { + ID int `json:"id",omitempty"` + Self string `json:"self",omitempty"` + Name string `json:"name",omitempty"` + Type string `json:"type",omitempty"` + FilterId int `omitempty` +} + +// BoardListOptions specifies the optional parameters to the BoardService.GetList +type BoardListOptions struct { + // Filters results to boards of the specified type. + // Valid values: scrum, kanban. + BoardType string `url:"boardType,omitempty"` + // Filters results to boards that match or partially match the specified name. + Name string `url:"name,omitempty"` + // Filters results to boards that are relevant to a project. + // Relevance meaning that the jql filter defined in board contains a reference to a project. + ProjectKeyOrId string `url:"projectKeyOrId,omitempty"` + // ListOptions specifies the optional parameters to various List methods that + // support pagination. + // Pagination is used for the JIRA REST APIs to conserve server resources and limit + // response size for resources that return potentially large collection of items. + // A request to a pages API will result in a values array wrapped in a JSON object with some paging metadata + // Default Pagination options + // The starting index of the returned projects. Base index: 0. + StartAt int `url:"startAt,omitempty"` + // The maximum number of projects to return per page. Default: 50. + MaxResults int `url:"maxResults,omitempty"` +} + +// Get all boards form jira +// +// JIRA API docs: https://docs.atlassian.com/jira/REST/latest/#api/2/project-getAllProjects +func (s *BoardService) GetList(opt *BoardListOptions) (*BoardsList, *http.Response, error) { + apiEndpoint := "rest/agile/1.0/board" + url, err := addOptions(apiEndpoint, opt) + req, err := s.client.NewRequest("GET", url, nil) + if err != nil { + return nil, nil, err + } + + boards := new(BoardsList) + resp, err := s.client.Do(req, boards) + if err != nil { + return nil, resp, err + } + + return boards, resp, err +} + +// Returns the board for the given board Id. This board will only be returned if the user has permission to view it. +func (s *BoardService) Get(boardID int) (*Board, *http.Response, error) { + apiEndpoint := fmt.Sprintf("rest/agile/1.0/board/%v", boardID) + req, err := s.client.NewRequest("GET", apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + board := new(Board) + resp, err := s.client.Do(req, board) + if err != nil { + return nil, resp, err + } + return board, resp, nil +} + +// Creates a new board. Board name, type and filter Id is required. +// name - Must be less than 255 characters. +// type - Valid values: scrum, kanban +// filterId - Id of a filter that the user has permissions to view. +// Note, if the user does not have the 'Create shared objects' permission and tries to create a shared board, a private +// board will be created instead (remember that board sharing depends on the filter sharing). +// +// JIRA API docs: https://docs.atlassian.com/jira-software/REST/cloud/#agile/1.0/board-createBoard +func (s *BoardService) Create(board *Board) (*Board, *http.Response, error) { + + apiEndpoint := "rest/agile/1.0/board" + req, err := s.client.NewRequest("POST", apiEndpoint, board) + if err != nil { + return nil, nil, err + } + + responseBoard := new(Board) + resp, err := s.client.Do(req, responseBoard) + if err != nil { + return nil, resp, err + } + return responseBoard, resp, nil +} + +// Deletes the board. +// +// https://docs.atlassian.com/jira-software/REST/cloud/#agile/1.0/board-deleteBoard +func (s *BoardService) Delete(boardID int) (*Board, *http.Response, error) { + apiEndpoint := fmt.Sprintf("rest/agile/1.0/board/%v", boardID) + req, err := s.client.NewRequest("DELETE", apiEndpoint, nil) + if err != nil { + return nil, nil, err + } + + resp, err := s.client.Do(req, nil) + return nil, resp, err +} diff --git a/board_test.go b/board_test.go new file mode 100644 index 0000000..727d928 --- /dev/null +++ b/board_test.go @@ -0,0 +1,154 @@ +package jira + +import ( + "fmt" + "io/ioutil" + "net/http" + "testing" +) + +func TestBoardsGetAll(t *testing.T) { + setup() + defer teardown() + testAPIEdpoint := "/rest/agile/1.0/board" + + raw, err := ioutil.ReadFile("./mocks/all_boards.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testAPIEdpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + testRequestURL(t, r, testAPIEdpoint) + fmt.Fprint(w, string(raw)) + }) + + projects, _, err := testClient.Board.GetList(nil) + if projects == nil { + t.Error("Expected boards list. Boards list is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +// Test with params +func TestBoardsGetFiltered(t *testing.T) { + setup() + defer teardown() + testAPIEdpoint := "/rest/agile/1.0/board" + + raw, err := ioutil.ReadFile("./mocks/all_boards_filtered.json") + if err != nil { + t.Error(err.Error()) + } + testMux.HandleFunc(testAPIEdpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + testRequestURL(t, r, testAPIEdpoint) + fmt.Fprint(w, string(raw)) + }) + + boardsListOptions := &BoardListOptions{ + BoardType: "scrum", + Name: "Test", + ProjectKeyOrId: "TE", + StartAt: 1, + MaxResults: 10, + } + + projects, _, err := testClient.Board.GetList(boardsListOptions) + if projects == nil { + t.Error("Expected boards list. Boards list is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestBoardGet(t *testing.T) { + setup() + defer teardown() + testAPIEdpoint := "/rest/agile/1.0/board/1" + + testMux.HandleFunc(testAPIEdpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + testRequestURL(t, r, testAPIEdpoint) + fmt.Fprint(w, `{"id":4,"self":"https://test.jira.org/rest/agile/1.0/board/1","name":"Test Weekly","type":"scrum"}`) + }) + + board, _, err := testClient.Board.Get(1) + if board == nil { + t.Error("Expected board list. Board list is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestBoardGet_NoBoard(t *testing.T) { + setup() + defer teardown() + testAPIEndpoint := "/rest/api/2/board/99999999" + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, nil) + }) + + board, resp, err := testClient.Board.Get(99999999) + if board != nil { + t.Errorf("Expected nil. Got %s", err) + } + + if resp.Status == "404" { + t.Errorf("Expected status 404. Got %s", resp.Status) + } + if err == nil { + t.Errorf("Error given: %s", err) + } +} + +func TestBoardCreate(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/agile/1.0/board", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + testRequestURL(t, r, "/rest/agile/1.0/board") + + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"id":17,"self":"https://test.jira.org/rest/agile/1.0/board/17","name":"Test","type":"kanban"}`) + }) + + b := &Board{ + Name: "Test", + Type: "kanban", + FilterId: 17, + } + issue, _, err := testClient.Board.Create(b) + if issue == nil { + t.Error("Expected board. Board is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestBoardDelete(t *testing.T) { + setup() + defer teardown() + testMux.HandleFunc("/rest/agile/1.0/board/1", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "DELETE") + testRequestURL(t, r, "/rest/agile/1.0/board/1") + + w.WriteHeader(http.StatusNoContent) + fmt.Fprint(w, `{}`) + }) + + _, resp, err := testClient.Board.Delete(1) + if resp.StatusCode != 204 { + t.Error("Expected board not deleted.") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} diff --git a/jira.go b/jira.go index 8c7034d..6222a37 100644 --- a/jira.go +++ b/jira.go @@ -7,6 +7,9 @@ import ( "io" "net/http" "net/url" + "reflect" + + "github.com/google/go-querystring/query" ) // A Client manages communication with the JIRA API. @@ -24,6 +27,7 @@ type Client struct { Authentication *AuthenticationService Issue *IssueService Project *ProjectService + Board *BoardService } // NewClient returns a new JIRA API client. @@ -50,6 +54,7 @@ func NewClient(httpClient *http.Client, baseURL string) (*Client, error) { c.Authentication = &AuthenticationService{client: c} c.Issue = &IssueService{client: c} c.Project = &ProjectService{client: c} + c.Board = &BoardService{client: c} return c, nil } @@ -92,6 +97,28 @@ func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Requ return req, nil } +// addOptions adds the parameters in opt as URL query parameters to s. opt +// must be a struct whose fields may contain "url" tags. +func addOptions(s string, opt interface{}) (string, error) { + v := reflect.ValueOf(opt) + if v.Kind() == reflect.Ptr && v.IsNil() { + return s, nil + } + + u, err := url.Parse(s) + if err != nil { + return s, err + } + + qs, err := query.Values(opt) + if err != nil { + return s, err + } + + u.RawQuery = qs.Encode() + return u.String(), nil +} + // NewMultiPartRequest creates an API request including a multi-part file. // A relative URL can be provided in urlStr, in which case it is resolved relative to the baseURL of the Client. // Relative URLs should always be specified without a preceding slash. diff --git a/jira_test.go b/jira_test.go index dcad2e6..94f8f89 100644 --- a/jira_test.go +++ b/jira_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "net/url" "reflect" + "strings" "testing" "time" ) @@ -52,7 +53,7 @@ func testMethod(t *testing.T, r *http.Request, want string) { } func testRequestURL(t *testing.T, r *http.Request, want string) { - if got := r.URL.String(); got != want { + if got := r.URL.String(); !strings.HasPrefix(got, want) { t.Errorf("Request URL: %v, want %v", got, want) } } diff --git a/mocks/all_boards.json b/mocks/all_boards.json new file mode 100644 index 0000000..2065cb6 --- /dev/null +++ b/mocks/all_boards.json @@ -0,0 +1,43 @@ +{ + "maxResults": 50, + "startAt": 0, + "isLast": true, + "values": [ + { + "id": 4, + "self": "https://test.jira.org/rest/agile/1.0/board/4", + "name": "Test Weekly", + "type": "scrum" + }, + { + "id": 5, + "self": "https://test.jira.org/rest/agile/1.0/board/5", + "name": "Test Production Support", + "type": "kanban" + }, + { + "id": 6, + "self": "https://test.jira.org/rest/agile/1.0/board/6", + "name": "Test To Give", + "type": "kanban" + }, + { + "id": 7, + "self": "https://test.jira.org/rest/agile/1.0/board/7", + "name": "Test Journey App", + "type": "kanban" + }, + { + "id": 9, + "self": "https://test.jira.org/rest/agile/1.0/board/9", + "name": "Testix", + "type": "scrum" + }, + { + "id": 1, + "self": "https://test.jira.org/rest/agile/1.0/board/1", + "name": "Test Mobile", + "type": "scrum" + } + ] +} \ No newline at end of file diff --git a/mocks/all_boards_filtered.json b/mocks/all_boards_filtered.json new file mode 100644 index 0000000..545f8a8 --- /dev/null +++ b/mocks/all_boards_filtered.json @@ -0,0 +1,25 @@ +{ + "maxResults": 10, + "startAt": 1, + "isLast": true, + "values": [ + { + "id": 4, + "self": "https://test.jira.org/rest/agile/1.0/board/4", + "name": "Test Weekly", + "type": "scrum" + }, + { + "id": 9, + "self": "https://test.jira.org/rest/agile/1.0/board/9", + "name": "Testix", + "type": "scrum" + }, + { + "id": 1, + "self": "https://test.jira.org/rest/agile/1.0/board/1", + "name": "Test Mobile", + "type": "scrum" + } + ] +} \ No newline at end of file