Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 222 additions & 0 deletions internal/cmd/sprint/create/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package create

import (
"encoding/json"
"errors"
"fmt"
"time"

"github.com/AlecAivazis/survey/v2"
"github.com/spf13/cobra"
"github.com/spf13/viper"

"github.com/ankitpokhrel/jira-cli/api"
"github.com/ankitpokhrel/jira-cli/internal/cmdutil"
"github.com/ankitpokhrel/jira-cli/internal/query"
"github.com/ankitpokhrel/jira-cli/pkg/jira"
)

const (
helpText = `Create a new sprint.`
examples = `$ jira sprint create MySprint

# Add a start and end date to the sprint:
$ jira sprint create --start 2025-08-25 --end 2025-08-31 MySprint

# Also add a goal for the sprint:
$ jira sprint create --start 2025-08-25 --end 2025-08-31 --goal "Fix all bugs" MySprint

# Omit some parameters on purpose:
$ jira sprint create --no-input MySprint

# Get JSON output:
$ jira sprint create --raw MySprint
`
)

// NewCmdCreate is a create command.
func NewCmdCreate() *cobra.Command {
cmd := cobra.Command{
Use: "create SPRINT-NAME",
Short: "Create new sprint",
Long: helpText,
Example: examples,
Annotations: map[string]string{
"help:args": "SPRINT_NAME\t\tThe name of the sprint to be created",
},
Run: create,
}

cmd.Flags().Bool("raw", false, "Print output in JSON format")
cmd.Flags().Bool("no-input", false, "Disable prompt for non-required fields")
cmd.Flags().StringP("start", "s", "", "Start date (YYYY-MM-DD)")
cmd.Flags().StringP("end", "e", "", "End date (YYYY-MM-DD)")
cmd.Flags().StringP("goal", "g", "", "Goal of the sprint")

return &cmd
}

func create(cmd *cobra.Command, args []string) {
params := parseFlags(cmd.Flags(), args)
client := api.DefaultClient(params.Debug)

qs := getQuestions(params)
if len(qs) > 0 {
ans := struct {
SprintName string
StartDate string
EndDate string
Goal string
}{}
err := survey.Ask(qs, &ans)
cmdutil.ExitIfError(err)

if params.SprintName == "" {
params.SprintName = ans.SprintName
}
if params.StartDate == "" {
params.StartDate = ans.StartDate
}
if params.EndDate == "" {
params.EndDate = ans.EndDate
}
if params.Goal == "" {
params.Goal = ans.Goal
}
}

if (params.StartDate != "" && params.EndDate == "") || (params.StartDate == "" && params.EndDate != "") {
cmdutil.Failed("Either both start and end dates must be supplied, or none of them")
}
cr := jira.SprintCreateRequest{
Name: params.SprintName,
StartDate: params.StartDate,
EndDate: params.EndDate,
Goal: params.Goal,
OriginBoardID: viper.GetInt("board.id"),
}
sprint, err := client.CreateSprint(&cr)
cmdutil.ExitIfError(err)

if params.Raw {
jsonData, err := json.Marshal(sprint)
cmdutil.ExitIfError(err)
fmt.Println(string(jsonData))
return
}

cmdutil.Success("Sprint '%s' with id '%d' created\n", sprint.Name, sprint.ID)
}

func parseFlags(flags query.FlagParser, args []string) *SprintCreateParams {
var sprintName string

if len(args) > 0 {
sprintName = args[0]
}

start, err := flags.GetString("start")
cmdutil.ExitIfError(err)
if start != "" {
if err = validateDate(start); err != nil {
cmdutil.Failed("Invalid start date. Should be in YYYY-MM-DD format")
}
}

end, err := flags.GetString("end")
cmdutil.ExitIfError(err)
if end != "" {
if err = validateDate(end); err != nil {
cmdutil.Failed("Invalid end date. Should be in YYYY-MM-DD format")
}
}

goal, err := flags.GetString("goal")
cmdutil.ExitIfError(err)

debug, err := flags.GetBool("debug")
cmdutil.ExitIfError(err)

noInput, err := flags.GetBool("no-input")
cmdutil.ExitIfError(err)

raw, err := flags.GetBool("raw")
cmdutil.ExitIfError(err)

return &SprintCreateParams{
SprintName: sprintName,
StartDate: start,
EndDate: end,
Goal: goal,
Debug: debug,
NoInput: noInput,
Raw: raw,
}
}

func getQuestions(params *SprintCreateParams) []*survey.Question {
var qs []*survey.Question

if params.SprintName == "" {
qs = append(qs, &survey.Question{
Name: "SprintName",
Prompt: &survey.Input{Message: "Sprint Name"},
Validate: survey.Required,
})
}

if params.NoInput {
return qs
}

if params.StartDate == "" {
qs = append(qs, &survey.Question{
Name: "StartDate",
Prompt: &survey.Input{Message: "Start date (YYYY-MM-DDD)"},
Validate: validateDate,
})
}

if params.EndDate == "" {
qs = append(qs, &survey.Question{
Name: "EndDate",
Prompt: &survey.Input{Message: "End date (YYYY-MM-DDD)"},
Validate: validateDate,
})
}

if params.Goal == "" {
qs = append(qs, &survey.Question{
Name: "Goal",
Prompt: &survey.Input{Message: "Goal"},
})
}

return qs
}

type SprintCreateParams struct {
SprintName string
StartDate string
EndDate string
Goal string
Debug bool
NoInput bool
Raw bool
}

// Returns an error if the date is not in the form YYYY-MM-DD. Technically, the
// JIRA API accepts other formats, but YYYY-MM-DD should be enough for most
// users.
func validateDate(val interface{}) error {
// We allow empty dates for the start and end of the sprint
if val.(string) == "" {
return nil
}

_, err := time.Parse(time.DateOnly, val.(string))
if err != nil {
return errors.New("Invalid date")
}
return nil
}
4 changes: 3 additions & 1 deletion internal/cmd/sprint/sprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

"github.com/ankitpokhrel/jira-cli/internal/cmd/sprint/add"
"github.com/ankitpokhrel/jira-cli/internal/cmd/sprint/close"
"github.com/ankitpokhrel/jira-cli/internal/cmd/sprint/create"
"github.com/ankitpokhrel/jira-cli/internal/cmd/sprint/list"
)

Expand All @@ -24,8 +25,9 @@ func NewCmdSprint() *cobra.Command {
lc := list.NewCmdList()
ac := add.NewCmdAdd()
cc := close.NewCmdClose()
crc := create.NewCmdCreate()

cmd.AddCommand(lc, ac, cc)
cmd.AddCommand(lc, ac, cc, crc)

list.SetFlags(lc)

Expand Down
50 changes: 50 additions & 0 deletions pkg/jira/sprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,56 @@ func (c *Client) lastNSprints(boardID int, qp string, limit int) (*SprintResult,
return c.Sprints(boardID, qp, n, limit)
}

// SprintCreateRequest struct holds request data for sprint create request.
type SprintCreateRequest struct {
Name string `json:"name"`
StartDate string `json:"startDate,omitempty"`
EndDate string `json:"endDate,omitempty"`
Goal string `json:"goal,omitempty"`
OriginBoardID int `json:"originBoardId"`
}

// SprintCreateResponse struct holds response from POST /sprint endpoint.
type SprintCreateResponse struct {
ID int `json:"id"`
Name string `json:"name"`
State string `json:"state"`
StartDate string `json:"startDate"`
EndDate string `json:"endDate"`
OriginBoardID int `json:"originBoardId"`
Goal string `json:"goal"`
}

func (c *Client) CreateSprint(req *SprintCreateRequest) (*SprintCreateResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, err
}

res, err := c.PostV1(
context.Background(),
"/sprint",
body,
Header{
"Accept": "application/json",
"Content-Type": "application/json",
},
)
defer func() { _ = res.Body.Close() }()
if err != nil {
return nil, err
}
if res == nil {
return nil, ErrEmptyResponse
}
if res.StatusCode != http.StatusCreated {
return nil, formatUnexpectedResponse(res)
}
var out SprintCreateResponse
err = json.NewDecoder(res.Body).Decode(&out)
return &out, err
}

func injectBoardID(sprints []*Sprint, boardID int) {
for _, s := range sprints {
s.BoardID = boardID
Expand Down
43 changes: 43 additions & 0 deletions pkg/jira/sprint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -414,3 +414,46 @@ func TestEndSprint(t *testing.T) {
err = client.EndSprint(5)
assert.Error(t, &ErrUnexpectedResponse{}, err)
}

func TestCreateSrpint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.NotNilf(t, r.Method, "invalid request method")

assert.Equal(t, "POST", r.Method)
assert.Equal(t, "application/json", r.Header.Get("Accept"))
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))

assert.Equal(t, "/rest/agile/1.0/sprint", r.URL.Path)

resp, err := os.ReadFile("./testdata/sprint-create.json")
assert.NoError(t, err)

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(201)
_, _ = w.Write(resp)
}))
defer server.Close()

client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second))
createRequestData := SprintCreateRequest{
Name: "Test sprint",
StartDate: "2025-09-01T13:37:00.000+00:00",
EndDate: "2025-09-08T13:37:00.000+00:00",
Goal: "Testing jira-cli",
OriginBoardID: 42,
}

expected := &SprintCreateResponse{
ID: 42,
Name: "Test sprint",
State: "future",
StartDate: "2025-09-01T13:37:00.000+00:00",
EndDate: "2025-09-08T13:37:00.000+00:00",
OriginBoardID: 5,
Goal: "Testing jira-cli",
}

actual, err := client.CreateSprint(&createRequestData)
assert.NoError(t, err)
assert.Equal(t, expected, actual)
}
10 changes: 10 additions & 0 deletions pkg/jira/testdata/sprint-create.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"id": 42,
"self": "https://example.com/rest/agile/1.0/sprint/42",
"state": "future",
"name": "Test sprint",
"startDate": "2025-09-01T13:37:00.000+00:00",
"endDate": "2025-09-08T13:37:00.000+00:00",
"originBoardId": 5,
"goal": "Testing jira-cli"
}