diff --git a/board.go b/board.go index 890d6e0a..10663da7 100644 --- a/board.go +++ b/board.go @@ -65,14 +65,14 @@ type SprintsList struct { // Sprint represents a sprint on Jira agile board type Sprint struct { - ID int `json:"id" structs:"id"` - Name string `json:"name" structs:"name"` - CompleteDate *time.Time `json:"completeDate" structs:"completeDate"` - EndDate *time.Time `json:"endDate" structs:"endDate"` - StartDate *time.Time `json:"startDate" structs:"startDate"` - OriginBoardID int `json:"originBoardId" structs:"originBoardId"` - Self string `json:"self" structs:"self"` - State string `json:"state" structs:"state"` + ID int `json:"id,omitempty" structs:"id,omitempty"` + Name string `json:"name,omitempty" structs:"name,omitempty"` + CompleteDate *time.Time `json:"completeDate,omitempty" structs:"completeDate,omitempty"` + EndDate *time.Time `json:"endDate,omitempty" structs:"endDate,omitempty"` + StartDate *time.Time `json:"startDate,omitempty" structs:"startDate,omitempty"` + OriginBoardID int `json:"originBoardId,omitempty" structs:"originBoardId,omitempty"` + Self string `json:"self,omitempty" structs:"self,omitempty"` + State string `json:"state,omitempty" structs:"state,omitempty"` } // BoardConfiguration represents a boardConfiguration of a jira board diff --git a/examples/sprint/main.go b/examples/sprint/main.go new file mode 100644 index 00000000..e01d9ad8 --- /dev/null +++ b/examples/sprint/main.go @@ -0,0 +1,124 @@ +package main + +import ( + "bufio" + "fmt" + "golang.org/x/term" + "log" + "os" + "strconv" + "syscall" + "time" + + jira "github.com/andygrunwald/go-jira" +) + +// This example implement the behaviour of Jira's "Complete sprint" button. +// It creates a new sprint and close the sprint currently active. +// Then all issues under the closed sprint are moved to the newly created sprint. +func main() { + sc := bufio.NewScanner(os.Stdin) + + fmt.Print("Jira URL: ") + sc.Scan() + jiraURL := sc.Text() + + fmt.Print("Jira Username: ") + sc.Scan() + username := sc.Text() + + fmt.Print("Jira Password: ") + bytePassword, _ := term.ReadPassword(int(syscall.Stdin)) + password := string(bytePassword) + + fmt.Print("\nJira Board ID : ") + sc.Scan() + boardIDstr := sc.Text() + boardID, err := strconv.Atoi(boardIDstr) + if err != nil { + log.Fatal(err) + } + + tp := jira.BasicAuthTransport{ + Username: username, + Password: password, + } + + client, err := jira.NewClient(tp.Client(), jiraURL) + if err != nil { + log.Println(err) + return + } + + // create a new sprint + start := time.Now() + end := start.AddDate(0, 0, 7) + s := jira.Sprint{ + Name: "New Sprint", + StartDate: &start, + EndDate: &end, + OriginBoardID: boardID, + } + futureSprint, _, err := client.Sprint.Create(&s) + if err != nil { + log.Fatal(err) + } + log.Println("New sprint created ID:", futureSprint.ID) + + // get the sprint currently active + sprints, _, err := client.Board.GetAllSprintsWithOptions(boardID, &jira.GetAllSprintsOptions{ + State: "active", + }) + if err != nil { + log.Fatal(err) + } + if len(sprints.Values) != 1 { + log.Fatal("Retrieved active sprint list has invalid length") + } + activeSprint := sprints.Values[0] + log.Println("Active sprint retrieved ID:", activeSprint.ID) + + // get all non-subtask issues of the active sprint + issues, _, err := client.Sprint.GetIssuesForSprintWithOptions(activeSprint.ID, &jira.GetIssuesForSprintOptions{ + Jql: "type NOT IN ('Sub-task')", + }) + if err != nil { + log.Fatal(err) + } + log.Println("All non-subtask issues of the active sprint retrieved length:", len(issues)) + for _, i := range issues { + log.Printf("\t(%s) %s", i.ID, i.Fields.Summary) + } + + // complete the active sprint + completeParam := make(map[string]interface{}) + completeParam["state"] = "closed" + resp, err := client.Sprint.UpdateSprint(activeSprint.ID, completeParam) + if err != nil { + log.Fatal(err) + } + resp.Body.Close() + log.Println("Active sprint completed") + + // move the issues previously under the active sprint to the newly created sprint + var issueIDs []string + for _, i := range issues { + issueIDs = append(issueIDs, i.ID) + } + resp, err = client.Sprint.MoveIssuesToSprint(futureSprint.ID, issueIDs) + if err != nil { + log.Fatal(err) + } + resp.Body.Close() + log.Println("The issues from the active sprint have been moved to the new sprint") + + // start the newly created sprint + startParam := make(map[string]interface{}) + startParam["state"] = "active" + resp, err = client.Sprint.UpdateSprint(futureSprint.ID, startParam) + if err != nil { + log.Fatal(err) + } + resp.Body.Close() + log.Println("The new sprint has been started") +} diff --git a/sprint.go b/sprint.go index f0f98d6f..841e5147 100644 --- a/sprint.go +++ b/sprint.go @@ -23,6 +23,13 @@ type IssuesInSprintResult struct { Issues []Issue `json:"issues"` } +type GetIssuesForSprintOptions struct { + // Jql filters results to issues that match the given JQL. + Jql string `url:"jql,omitempty"` + + SearchOptions +} + // MoveIssuesToSprintWithContext moves issues to a sprint, for a given sprint Id. // Issues can only be moved to open or active sprints. // The maximum number of issues that can be moved in one operation is 50. @@ -81,6 +88,39 @@ func (s *SprintService) GetIssuesForSprint(sprintID int) ([]Issue, *Response, er return s.GetIssuesForSprintWithContext(context.Background(), sprintID) } +// GetIssuesForSprintWithOptionsWithContext returns all issues in a sprint, for a given sprint Id. +// The issues are filtered out with the given options. +// This only includes issues that the user has permission to view. +// By default, the returned issues are ordered by rank. +// +// Jira API Docs: https://docs.atlassian.com/jira-software/REST/cloud/#agile/1.0/sprint-getIssuesForSprint +func (s *SprintService) GetIssuesForSprintWithOptionsWithContext(ctx context.Context, sprintID int, options *GetIssuesForSprintOptions) ([]Issue, *Response, error) { + apiEndpoint := fmt.Sprintf("rest/agile/1.0/sprint/%d/issue", sprintID) + url, err := addOptions(apiEndpoint, options) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequestWithContext(ctx, "GET", url, nil) + + if err != nil { + return nil, nil, err + } + + result := new(IssuesInSprintResult) + resp, err := s.client.Do(req, result) + if err != nil { + err = NewJiraError(resp, err) + } + + return result.Issues, resp, err +} + +// GetIssuesForSprintWithOptions wraps GetIssuesForSprintWithOptionsWithContext using the background context. +func (s *SprintService) GetIssuesForSprintWithOptions(sprintID int, options *GetIssuesForSprintOptions) ([]Issue, *Response, error) { + return s.GetIssuesForSprintWithOptionsWithContext(context.Background(), sprintID, options) +} + // GetIssueWithContext returns a full representation of the issue for the given issue key. // Jira will attempt to identify the issue by the issueIdOrKey path parameter. // This can be an issue id, or an issue key. @@ -123,3 +163,54 @@ func (s *SprintService) GetIssueWithContext(ctx context.Context, issueID string, func (s *SprintService) GetIssue(issueID string, options *GetQueryOptions) (*Issue, *Response, error) { return s.GetIssueWithContext(context.Background(), issueID, options) } + +// CreateWithContext creates a sprint from a JSON representation. +// +// Jira API docs: https://docs.atlassian.com/jira-software/REST/7.3.1/#agile/1.0/sprint-createSprint +func (s *SprintService) CreateWithContext(ctx context.Context, sprint *Sprint) (*Sprint, *Response, error) { + apiEndpoint := "rest/agile/1.0/sprint" + req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndpoint, sprint) + if err != nil { + return nil, nil, err + } + + created := new(Sprint) + resp, err := s.client.Do(req, created) + if err != nil { + jerr := NewJiraError(resp, err) + return nil, resp, jerr + } + + return created, resp, nil +} + +// Create wraps CreateWithContext using the background context. +func (s *SprintService) Create(sprint *Sprint) (*Sprint, *Response, error) { + return s.CreateWithContext(context.Background(), sprint) +} + +// UpdateSprintWithContext partially updates a sprint from a JSON representation. The sprint is found by ID. +// +// Jira API docs: https://docs.atlassian.com/jira-software/REST/7.3.1/#agile/1.0/sprint-partiallyUpdateSprint +// Caller must close resp.Body +func (s *SprintService) UpdateSprintWithContext(ctx context.Context, sprintID int, data map[string]interface{}) (*Response, error) { + apiEndpoint := fmt.Sprintf("rest/agile/1.0/sprint/%v", sprintID) + + req, err := s.client.NewRequestWithContext(ctx, "POST", apiEndpoint, data) + if err != nil { + return nil, err + } + resp, err := s.client.Do(req, nil) + if err != nil { + jerr := NewJiraError(resp, err) + return resp, jerr + } + + return resp, nil +} + +// UpdateSprint wraps UpdateSprintWithContext using the background context. +// Caller must close resp.Body +func (s *SprintService) UpdateSprint(sprintID int, data map[string]interface{}) (*Response, error) { + return s.UpdateSprintWithContext(context.Background(), sprintID, data) +} diff --git a/sprint_test.go b/sprint_test.go index 64125dbc..b13c0a11 100644 --- a/sprint_test.go +++ b/sprint_test.go @@ -7,6 +7,7 @@ import ( "net/http" "reflect" "testing" + "time" ) func TestSprintService_MoveIssuesToSprint(t *testing.T) { @@ -67,6 +68,35 @@ func TestSprintService_GetIssuesForSprint(t *testing.T) { } +func TestSprintService_GetIssuesForSprintWithOptions(t *testing.T) { + setup() + defer teardown() + testAPIEdpoint := "/rest/agile/1.0/sprint/123/issue" + + raw, err := ioutil.ReadFile("./mocks/issues_in_sprint.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)) + }) + + issues, _, err := testClient.Sprint.GetIssuesForSprintWithOptions(123, &GetIssuesForSprintOptions{ + Jql: "type NOT IN ('Sub-task')", + }) + if err != nil { + t.Errorf("Error given: %v", err) + } + if issues == nil { + t.Error("Expected issues in sprint list. Issues list is nil") + } + if len(issues) != 1 { + t.Errorf("Expect there to be 1 issue in the sprint, found %v", len(issues)) + } +} + func TestSprintService_GetIssue(t *testing.T) { setup() defer teardown() @@ -114,3 +144,55 @@ func TestSprintService_GetIssue(t *testing.T) { t.Errorf("Error given: %s", err) } } + +func TestSprintService_Create(t *testing.T) { + setup() + defer teardown() + + testAPIEndpoint := "/rest/agile/1.0/sprint" + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, `{"id":11,"self":"http://www.example.com/jira/rest/agile/1.0/sprint/11","state":"future","name":"Test Sprint","startDate":"2022-08-09T12:34:56.000Z","endDate":"2022-08-16T12:34:56.000Z","originBoardId":17}`) + }) + + start := time.Date(2022, 8, 9, 12, 34, 56, 0, time.UTC) + end := time.Date(2022, 8, 16, 12, 34, 56, 0, time.UTC) + s := Sprint{ + Name: "Test Sprint", + StartDate: &start, + EndDate: &end, + OriginBoardID: 17, + } + sprint, _, err := testClient.Sprint.Create(&s) + if sprint == nil { + t.Error("Expected sprint. Sprint is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +} + +func TestSprintService_UpdateSprint(t *testing.T) { + setup() + defer teardown() + + testAPIEndpoint := "/rest/agile/1.0/sprint/11" + + testMux.HandleFunc(testAPIEndpoint, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + testRequestURL(t, r, testAPIEndpoint) + fmt.Fprint(w, `{"id":11,"self":"http://www.example.com/jira/rest/agile/1.0/sprint/11","state":"future","name":"Updated Name","startDate":"2022-08-09T12:34:56.000Z","endDate":"2022-08-16T12:34:56.000Z","originBoardId":17}`) + }) + + data := make(map[string]interface{}) + data["name"] = "Updated Name" + resp, err := testClient.Sprint.UpdateSprint(11, data) + if resp == nil { + t.Error("Expected resp. resp is nil") + } + if err != nil { + t.Errorf("Error given: %s", err) + } +}