Skip to content

Commit 889481d

Browse files
feat: Add "sprint create" subcommand
1 parent 7202104 commit 889481d

File tree

5 files changed

+328
-1
lines changed

5 files changed

+328
-1
lines changed
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
package create
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"time"
8+
9+
"github.com/AlecAivazis/survey/v2"
10+
"github.com/spf13/cobra"
11+
"github.com/spf13/viper"
12+
13+
"github.com/ankitpokhrel/jira-cli/api"
14+
"github.com/ankitpokhrel/jira-cli/internal/cmdutil"
15+
"github.com/ankitpokhrel/jira-cli/internal/query"
16+
"github.com/ankitpokhrel/jira-cli/pkg/jira"
17+
)
18+
19+
const (
20+
helpText = `Create a new sprint.`
21+
examples = `$ jira sprint create MySprint
22+
23+
# Add a start and end date to the sprint:
24+
$ jira sprint create --start 2025-08-25 --end 2025-08-31 MySprint
25+
26+
# Also add a goal for the sprint:
27+
$ jira sprint create --start 2025-08-25 --end 2025-08-31 --goal "Fix all bugs" MySprint
28+
29+
# Omit some parameters on purpose:
30+
$ jira sprint create --no-input MySprint
31+
32+
# Get JSON output:
33+
$ jira sprint create --raw MySprint
34+
`
35+
)
36+
37+
// NewCmdCreate is a create command.
38+
func NewCmdCreate() *cobra.Command {
39+
cmd := cobra.Command{
40+
Use: "create SPRINT-NAME",
41+
Short: "Create new sprint",
42+
Long: helpText,
43+
Example: examples,
44+
Annotations: map[string]string{
45+
"help:args": "SPRINT_NAME\t\tThe name of the sprint to be created",
46+
},
47+
Run: create,
48+
}
49+
50+
cmd.Flags().Bool("raw", false, "Print output in JSON format")
51+
cmd.Flags().Bool("no-input", false, "Disable prompt for non-required fields")
52+
cmd.Flags().StringP("start", "s", "", "Start date (YYYY-MM-DD)")
53+
cmd.Flags().StringP("end", "e", "", "End date (YYYY-MM-DD)")
54+
cmd.Flags().StringP("goal", "g", "", "Goal of the sprint")
55+
56+
return &cmd
57+
}
58+
59+
func create(cmd *cobra.Command, args []string) {
60+
params := parseFlags(cmd.Flags(), args)
61+
client := api.DefaultClient(params.Debug)
62+
63+
qs := getQuestions(params)
64+
if len(qs) > 0 {
65+
ans := struct {
66+
SprintName string
67+
StartDate string
68+
EndDate string
69+
Goal string
70+
}{}
71+
err := survey.Ask(qs, &ans)
72+
cmdutil.ExitIfError(err)
73+
74+
if params.SprintName == "" {
75+
params.SprintName = ans.SprintName
76+
}
77+
if params.StartDate == "" {
78+
params.StartDate = ans.StartDate
79+
}
80+
if params.EndDate == "" {
81+
params.EndDate = ans.EndDate
82+
}
83+
if params.Goal == "" {
84+
params.Goal = ans.Goal
85+
}
86+
}
87+
88+
if (params.StartDate != "" && params.EndDate == "") || (params.StartDate == "" && params.EndDate != "") {
89+
cmdutil.Failed("Either both start and end dates must be supplied, or none of them")
90+
}
91+
cr := jira.SprintCreateRequest{
92+
Name: params.SprintName,
93+
StartDate: params.StartDate,
94+
EndDate: params.EndDate,
95+
Goal: params.Goal,
96+
OriginBoardID: viper.GetInt("board.id"),
97+
}
98+
sprint, err := client.CreateSprint(&cr)
99+
cmdutil.ExitIfError(err)
100+
101+
if params.Raw {
102+
jsonData, err := json.Marshal(sprint)
103+
cmdutil.ExitIfError(err)
104+
fmt.Println(string(jsonData))
105+
return
106+
}
107+
108+
cmdutil.Success("Sprint '%s' with id '%d' created\n", sprint.Name, sprint.ID)
109+
}
110+
111+
func parseFlags(flags query.FlagParser, args []string) *SprintCreateParams {
112+
var sprintName string
113+
114+
if len(args) > 0 {
115+
sprintName = args[0]
116+
}
117+
118+
start, err := flags.GetString("start")
119+
cmdutil.ExitIfError(err)
120+
if start != "" {
121+
if err = validateDate(start); err != nil {
122+
cmdutil.Failed("Invalid start date. Should be in YYYY-MM-DD format")
123+
}
124+
}
125+
126+
end, err := flags.GetString("end")
127+
cmdutil.ExitIfError(err)
128+
if end != "" {
129+
if err = validateDate(end); err != nil {
130+
cmdutil.Failed("Invalid end date. Should be in YYYY-MM-DD format")
131+
}
132+
}
133+
134+
goal, err := flags.GetString("goal")
135+
cmdutil.ExitIfError(err)
136+
137+
debug, err := flags.GetBool("debug")
138+
cmdutil.ExitIfError(err)
139+
140+
noInput, err := flags.GetBool("no-input")
141+
cmdutil.ExitIfError(err)
142+
143+
raw, err := flags.GetBool("raw")
144+
cmdutil.ExitIfError(err)
145+
146+
return &SprintCreateParams{
147+
SprintName: sprintName,
148+
StartDate: start,
149+
EndDate: end,
150+
Goal: goal,
151+
Debug: debug,
152+
NoInput: noInput,
153+
Raw: raw,
154+
}
155+
}
156+
157+
func getQuestions(params *SprintCreateParams) []*survey.Question {
158+
var qs []*survey.Question
159+
160+
if params.SprintName == "" {
161+
qs = append(qs, &survey.Question{
162+
Name: "SprintName",
163+
Prompt: &survey.Input{Message: "Sprint Name"},
164+
Validate: survey.Required,
165+
})
166+
}
167+
168+
if params.NoInput {
169+
return qs
170+
}
171+
172+
if params.StartDate == "" {
173+
qs = append(qs, &survey.Question{
174+
Name: "StartDate",
175+
Prompt: &survey.Input{Message: "Start date (YYYY-MM-DDD)"},
176+
Validate: validateDate,
177+
})
178+
}
179+
180+
if params.EndDate == "" {
181+
qs = append(qs, &survey.Question{
182+
Name: "EndDate",
183+
Prompt: &survey.Input{Message: "End date (YYYY-MM-DDD)"},
184+
Validate: validateDate,
185+
})
186+
}
187+
188+
if params.Goal == "" {
189+
qs = append(qs, &survey.Question{
190+
Name: "Goal",
191+
Prompt: &survey.Input{Message: "Goal"},
192+
})
193+
}
194+
195+
return qs
196+
}
197+
198+
type SprintCreateParams struct {
199+
SprintName string
200+
StartDate string
201+
EndDate string
202+
Goal string
203+
Debug bool
204+
NoInput bool
205+
Raw bool
206+
}
207+
208+
// Returns an error if the date is not in the form YYYY-MM-DD. Technically, the
209+
// JIRA API accepts other formats, but YYYY-MM-DD should be enough for most
210+
// users.
211+
func validateDate(val interface{}) error {
212+
// We allow empty dates for the start and end of the sprint
213+
if val.(string) == "" {
214+
return nil
215+
}
216+
217+
_, err := time.Parse(time.DateOnly, val.(string))
218+
if err != nil {
219+
return errors.New("Invalid date")
220+
}
221+
return nil
222+
}

internal/cmd/sprint/sprint.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55

66
"github.com/ankitpokhrel/jira-cli/internal/cmd/sprint/add"
77
"github.com/ankitpokhrel/jira-cli/internal/cmd/sprint/close"
8+
"github.com/ankitpokhrel/jira-cli/internal/cmd/sprint/create"
89
"github.com/ankitpokhrel/jira-cli/internal/cmd/sprint/list"
910
)
1011

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

28-
cmd.AddCommand(lc, ac, cc)
30+
cmd.AddCommand(lc, ac, cc, crc)
2931

3032
list.SetFlags(lc)
3133

pkg/jira/sprint.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,56 @@ func (c *Client) lastNSprints(boardID int, qp string, limit int) (*SprintResult,
260260
return c.Sprints(boardID, qp, n, limit)
261261
}
262262

263+
// SprintCreateRequest struct holds request data for sprint create request.
264+
type SprintCreateRequest struct {
265+
Name string `json:"name"`
266+
StartDate string `json:"startDate,omitempty"`
267+
EndDate string `json:"endDate,omitempty"`
268+
Goal string `json:"goal,omitempty"`
269+
OriginBoardID int `json:"originBoardId"`
270+
}
271+
272+
// SprintCreateResponse struct holds response from POST /sprint endpoint.
273+
type SprintCreateResponse struct {
274+
ID int `json:"id"`
275+
Name string `json:"name"`
276+
State string `json:"state"`
277+
StartDate string `json:"startDate"`
278+
EndDate string `json:"endDate"`
279+
OriginBoardID int `json:"originBoardId"`
280+
Goal string `json:"goal"`
281+
}
282+
283+
func (c *Client) CreateSprint(req *SprintCreateRequest) (*SprintCreateResponse, error) {
284+
body, err := json.Marshal(req)
285+
if err != nil {
286+
return nil, err
287+
}
288+
289+
res, err := c.PostV1(
290+
context.Background(),
291+
"/sprint",
292+
body,
293+
Header{
294+
"Accept": "application/json",
295+
"Content-Type": "application/json",
296+
},
297+
)
298+
defer func() { _ = res.Body.Close() }()
299+
if err != nil {
300+
return nil, err
301+
}
302+
if res == nil {
303+
return nil, ErrEmptyResponse
304+
}
305+
if res.StatusCode != http.StatusCreated {
306+
return nil, formatUnexpectedResponse(res)
307+
}
308+
var out SprintCreateResponse
309+
err = json.NewDecoder(res.Body).Decode(&out)
310+
return &out, err
311+
}
312+
263313
func injectBoardID(sprints []*Sprint, boardID int) {
264314
for _, s := range sprints {
265315
s.BoardID = boardID

pkg/jira/sprint_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,3 +414,46 @@ func TestEndSprint(t *testing.T) {
414414
err = client.EndSprint(5)
415415
assert.Error(t, &ErrUnexpectedResponse{}, err)
416416
}
417+
418+
func TestCreateSrpint(t *testing.T) {
419+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
420+
assert.NotNilf(t, r.Method, "invalid request method")
421+
422+
assert.Equal(t, "POST", r.Method)
423+
assert.Equal(t, "application/json", r.Header.Get("Accept"))
424+
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
425+
426+
assert.Equal(t, "/rest/agile/1.0/sprint", r.URL.Path)
427+
428+
resp, err := os.ReadFile("./testdata/sprint-create.json")
429+
assert.NoError(t, err)
430+
431+
w.Header().Set("Content-Type", "application/json")
432+
w.WriteHeader(201)
433+
_, _ = w.Write(resp)
434+
}))
435+
defer server.Close()
436+
437+
client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second))
438+
createRequestData := SprintCreateRequest{
439+
Name: "Test sprint",
440+
StartDate: "2025-09-01T13:37:00.000+00:00",
441+
EndDate: "2025-09-08T13:37:00.000+00:00",
442+
Goal: "Testing jira-cli",
443+
OriginBoardID: 42,
444+
}
445+
446+
expected := &SprintCreateResponse{
447+
ID: 42,
448+
Name: "Test sprint",
449+
State: "future",
450+
StartDate: "2025-09-01T13:37:00.000+00:00",
451+
EndDate: "2025-09-08T13:37:00.000+00:00",
452+
OriginBoardID: 5,
453+
Goal: "Testing jira-cli",
454+
}
455+
456+
actual, err := client.CreateSprint(&createRequestData)
457+
assert.NoError(t, err)
458+
assert.Equal(t, expected, actual)
459+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"id": 42,
3+
"self": "https://example.com/rest/agile/1.0/sprint/42",
4+
"state": "future",
5+
"name": "Test sprint",
6+
"startDate": "2025-09-01T13:37:00.000+00:00",
7+
"endDate": "2025-09-08T13:37:00.000+00:00",
8+
"originBoardId": 5,
9+
"goal": "Testing jira-cli"
10+
}

0 commit comments

Comments
 (0)