From 889481da06f25fbedec6e0ebb730bcfde55f4b3a Mon Sep 17 00:00:00 2001 From: Cyril Roelandt Date: Tue, 26 Aug 2025 02:18:31 +0200 Subject: [PATCH] feat: Add "sprint create" subcommand --- internal/cmd/sprint/create/create.go | 222 +++++++++++++++++++++++++++ internal/cmd/sprint/sprint.go | 4 +- pkg/jira/sprint.go | 50 ++++++ pkg/jira/sprint_test.go | 43 ++++++ pkg/jira/testdata/sprint-create.json | 10 ++ 5 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/sprint/create/create.go create mode 100644 pkg/jira/testdata/sprint-create.json diff --git a/internal/cmd/sprint/create/create.go b/internal/cmd/sprint/create/create.go new file mode 100644 index 00000000..d121298d --- /dev/null +++ b/internal/cmd/sprint/create/create.go @@ -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 +} diff --git a/internal/cmd/sprint/sprint.go b/internal/cmd/sprint/sprint.go index 148cf461..ce56ebb1 100644 --- a/internal/cmd/sprint/sprint.go +++ b/internal/cmd/sprint/sprint.go @@ -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" ) @@ -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) diff --git a/pkg/jira/sprint.go b/pkg/jira/sprint.go index b054409d..7b6839bb 100644 --- a/pkg/jira/sprint.go +++ b/pkg/jira/sprint.go @@ -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 diff --git a/pkg/jira/sprint_test.go b/pkg/jira/sprint_test.go index 7b9cedae..eb6a687a 100644 --- a/pkg/jira/sprint_test.go +++ b/pkg/jira/sprint_test.go @@ -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) +} diff --git a/pkg/jira/testdata/sprint-create.json b/pkg/jira/testdata/sprint-create.json new file mode 100644 index 00000000..87658293 --- /dev/null +++ b/pkg/jira/testdata/sprint-create.json @@ -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" +}