diff --git a/internal/cmd/release/create/create.go b/internal/cmd/release/create/create.go new file mode 100644 index 00000000..f759247f --- /dev/null +++ b/internal/cmd/release/create/create.go @@ -0,0 +1,195 @@ +package create + +import ( + "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" + "github.com/ankitpokhrel/jira-cli/pkg/surveyext" + "github.com/ankitpokhrel/jira-cli/pkg/tui" +) + +const ( + helpText = `Create a version (release) in a project.` + examples = `$ jira release create + +# Create a version with minimal info +$ jira release create --name "Version 1.0" + +# Create a version with full details +$ jira release create --name "v2.0" --description "Major release" --released --release-date "2024-12-31" + +# Create in a specific project +$ jira release create -p PROJECT --name "v1.5"` +) + +// NewCmdCreate is a create command. +func NewCmdCreate() *cobra.Command { + return &cobra.Command{ + Use: "create", + Short: "Create a version in a project", + Long: helpText, + Example: examples, + Run: create, + } +} + +// SetFlags sets flags supported by create command. +func SetFlags(cmd *cobra.Command) { + cmd.Flags().String("name", "", "Version name (required)") + cmd.Flags().String("description", "", "Version description") + cmd.Flags().Bool("released", false, "Mark version as released") + cmd.Flags().Bool("archived", false, "Mark version as archived") + cmd.Flags().String("release-date", "", "Release date (YYYY-MM-DD)") + cmd.Flags().String("start-date", "", "Start date (YYYY-MM-DD)") + cmd.Flags().Bool("no-input", false, "Disable interactive prompts") + cmd.Flags().Bool("debug", false, "Enable debug mode") +} + +type createParams struct { + Name string + Description string + Released bool + Archived bool + ReleaseDate string + StartDate string + NoInput bool + Debug bool +} + +func create(cmd *cobra.Command, _ []string) { + server := viper.GetString("server") + project := viper.GetString("project.key") + + params := parseFlags(cmd.Flags()) + client := api.DefaultClient(params.Debug) + cc := createCmd{ + client: client, + params: params, + } + + if cc.isNonInteractive() || params.NoInput || tui.IsDumbTerminal() { + params.NoInput = true + + if params.Name == "" { + cmdutil.Failed("Param `--name` is mandatory when using a non-interactive mode") + } + } + + qs := cc.getQuestions() + if len(qs) > 0 { + ans := struct{ Name, Description string }{} + err := survey.Ask(qs, &ans) + cmdutil.ExitIfError(err) + + if params.Name == "" { + params.Name = ans.Name + } + if params.Description == "" { + params.Description = ans.Description + } + } + + version, err := func() (*jira.ProjectVersion, error) { + s := cmdutil.Info("Creating version...") + defer s.Stop() + + req := &jira.VersionCreateRequest{ + Name: params.Name, + Description: params.Description, + Project: project, + Archived: params.Archived, + Released: params.Released, + ReleaseDate: params.ReleaseDate, + StartDate: params.StartDate, + } + + return client.CreateVersion(req) + }() + + cmdutil.ExitIfError(err) + cmdutil.Success( + "Version created: %s (ID: %s)\n%s", + version.Name, + version.ID, + cmdutil.GenerateServerBrowseURL(server, project), + ) +} + +func (cc *createCmd) getQuestions() []*survey.Question { + var qs []*survey.Question + + if cc.params.Name == "" { + qs = append(qs, &survey.Question{ + Name: "name", + Prompt: &survey.Input{Message: "Version name"}, + Validate: survey.Required, + }) + } + + if cc.params.Description == "" && !cc.params.NoInput { + qs = append(qs, &survey.Question{ + Name: "description", + Prompt: &surveyext.JiraEditor{ + Editor: &survey.Editor{ + Message: "Description (optional)", + HideDefault: true, + AppendDefault: true, + }, + BlankAllowed: true, + }, + }) + } + + return qs +} + +type createCmd struct { + client *jira.Client + params *createParams +} + +func (cc *createCmd) isNonInteractive() bool { + return cmdutil.StdinHasData() +} + +func parseFlags(flags query.FlagParser) *createParams { + name, err := flags.GetString("name") + cmdutil.ExitIfError(err) + + description, err := flags.GetString("description") + cmdutil.ExitIfError(err) + + released, err := flags.GetBool("released") + cmdutil.ExitIfError(err) + + archived, err := flags.GetBool("archived") + cmdutil.ExitIfError(err) + + releaseDate, err := flags.GetString("release-date") + cmdutil.ExitIfError(err) + + startDate, err := flags.GetString("start-date") + cmdutil.ExitIfError(err) + + noInput, err := flags.GetBool("no-input") + cmdutil.ExitIfError(err) + + debug, err := flags.GetBool("debug") + cmdutil.ExitIfError(err) + + return &createParams{ + Name: name, + Description: description, + Released: released, + Archived: archived, + ReleaseDate: releaseDate, + StartDate: startDate, + NoInput: noInput, + Debug: debug, + } +} diff --git a/internal/cmd/release/edit/edit.go b/internal/cmd/release/edit/edit.go new file mode 100644 index 00000000..77a17fc1 --- /dev/null +++ b/internal/cmd/release/edit/edit.go @@ -0,0 +1,330 @@ +package edit + +import ( + "fmt" + + "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" + "github.com/ankitpokhrel/jira-cli/pkg/surveyext" +) + +const ( + helpText = `Edit a version (release) in a project. + +You can provide either version ID or version name as the argument.` + examples = `$ jira release edit "Version 1.0" + +# Edit version by ID +$ jira release edit 10000 --name "Version 1.0 Updated" + +# Edit version by name +$ jira release edit "v1.0" --name "v1.1" --description "Updated version" + +# Mark version as released +$ jira release edit 10000 --released + +# Update release date +$ jira release edit "v1.0" --release-date "2024-12-31" + +# Use --no-input to skip prompts +$ jira release edit 10000 --name "v2.0" --no-input` +) + +// NewCmdEdit is an edit command. +func NewCmdEdit() *cobra.Command { + cmd := cobra.Command{ + Use: "edit VERSION-ID-OR-NAME", + Short: "Edit a version in a project", + Long: helpText, + Example: examples, + Aliases: []string{"update", "modify", "rename"}, + Annotations: map[string]string{ + "help:args": `VERSION-ID-OR-NAME Version ID or name, eg: 10000 or "Version 1.0"`, + }, + Args: cobra.ExactArgs(1), + Run: edit, + } + + setFlags(&cmd) + + return &cmd +} + +func setFlags(cmd *cobra.Command) { + cmd.Flags().String("name", "", "New version name") + cmd.Flags().String("description", "", "Version description") + cmd.Flags().Bool("released", false, "Mark version as released") + cmd.Flags().Bool("unreleased", false, "Mark version as unreleased") + cmd.Flags().Bool("archived", false, "Mark version as archived") + cmd.Flags().Bool("unarchived", false, "Mark version as unarchived") + cmd.Flags().String("release-date", "", "Release date (YYYY-MM-DD)") + cmd.Flags().String("start-date", "", "Start date (YYYY-MM-DD)") + cmd.Flags().Bool("no-input", false, "Disable interactive prompts") + cmd.Flags().Bool("debug", false, "Enable debug mode") +} + +type editParams struct { + versionIDOrName string + name string + description string + releasedFlag bool + unreleasedFlag bool + archivedFlag bool + unarchivedFlag bool + releaseDate string + startDate string + noInput bool + debug bool +} + +func edit(cmd *cobra.Command, args []string) { + project := viper.GetString("project.key") + + params := parseArgsAndFlags(cmd.Flags(), args) + client := api.DefaultClient(params.debug) + + // Resolve version ID from name or ID + versionID, currentVersion, err := resolveVersion(client, params.versionIDOrName, project) + cmdutil.ExitIfError(err) + + ec := editCmd{ + client: client, + params: params, + currentVersion: currentVersion, + } + + if !params.noInput { + cmdutil.ExitIfError(ec.askQuestions()) + } + + // Build update request with only changed fields + updateReq := &jira.VersionUpdateRequest{} + hasChanges := false + + if params.name != "" && params.name != currentVersion.Name { + updateReq.Name = params.name + hasChanges = true + } + + if params.description != "" { + var currentDesc string + if currentVersion.Description != nil { + currentDesc = fmt.Sprintf("%v", currentVersion.Description) + } + if params.description != currentDesc { + updateReq.Description = params.description + hasChanges = true + } + } + + if params.releasedFlag { + released := true + updateReq.Released = &released + hasChanges = true + } else if params.unreleasedFlag { + released := false + updateReq.Released = &released + hasChanges = true + } + + if params.archivedFlag { + archived := true + updateReq.Archived = &archived + hasChanges = true + } else if params.unarchivedFlag { + archived := false + updateReq.Archived = &archived + hasChanges = true + } + + if params.releaseDate != "" { + updateReq.ReleaseDate = params.releaseDate + hasChanges = true + } + + if params.startDate != "" { + updateReq.StartDate = params.startDate + hasChanges = true + } + + if !hasChanges { + cmdutil.Failed("No changes to apply") + } + + err = func() error { + s := cmdutil.Info("Updating version...") + defer s.Stop() + + return client.UpdateVersion(versionID, updateReq) + }() + + cmdutil.ExitIfError(err) + + displayName := params.name + if displayName == "" { + displayName = currentVersion.Name + } + + cmdutil.Success("Version updated: %s (ID: %s)", displayName, versionID) +} + +func (ec *editCmd) askQuestions() error { + var qs []*survey.Question + + // Current version display + var currentDesc string + if ec.currentVersion.Description != nil { + currentDesc = fmt.Sprintf("%v", ec.currentVersion.Description) + } + + currentInfo := fmt.Sprintf("Current: %s (Released: %v, Archived: %v)", + ec.currentVersion.Name, + ec.currentVersion.Released, + ec.currentVersion.Archived, + ) + + if ec.params.name == "" { + qs = append(qs, &survey.Question{ + Name: "name", + Prompt: &survey.Input{ + Message: fmt.Sprintf("Version name [%s]", currentInfo), + Default: ec.currentVersion.Name, + }, + }) + } + + if ec.params.description == "" { + qs = append(qs, &survey.Question{ + Name: "description", + Prompt: &surveyext.JiraEditor{ + Editor: &survey.Editor{ + Message: "Description (optional)", + Default: currentDesc, + HideDefault: true, + AppendDefault: true, + }, + BlankAllowed: true, + }, + }) + } + + if len(qs) == 0 { + return nil + } + + ans := struct { + Name string + Description string + }{} + + if err := survey.Ask(qs, &ans); err != nil { + return err + } + + if ec.params.name == "" { + ec.params.name = ans.Name + } + if ec.params.description == "" { + ec.params.description = ans.Description + } + + return nil +} + +type editCmd struct { + client *jira.Client + params *editParams + currentVersion *jira.ProjectVersion +} + +// resolveVersion resolves a version identifier (ID or name) to its ID and full version object. +// It first attempts to fetch by ID, and if that fails, searches by name in the project's versions. +func resolveVersion(client *jira.Client, versionIDOrName, project string) (string, *jira.ProjectVersion, error) { + s := cmdutil.Info(fmt.Sprintf("Fetching version %s...", versionIDOrName)) + defer s.Stop() + + // Try to get by ID first + if version, err := client.GetVersion(versionIDOrName); err == nil { + return versionIDOrName, version, nil + } + + // If that fails, search by name in project versions + versions, err := client.Release(project) + if err != nil { + return "", nil, err + } + + for _, v := range versions { + if v.Name == versionIDOrName { + return v.ID, v, nil + } + } + + return "", nil, fmt.Errorf("version not found: %s", versionIDOrName) +} + +func parseArgsAndFlags(flags query.FlagParser, args []string) *editParams { + name, err := flags.GetString("name") + cmdutil.ExitIfError(err) + + description, err := flags.GetString("description") + cmdutil.ExitIfError(err) + + releasedFlag, err := flags.GetBool("released") + cmdutil.ExitIfError(err) + + unreleasedFlag, err := flags.GetBool("unreleased") + cmdutil.ExitIfError(err) + + archivedFlag, err := flags.GetBool("archived") + cmdutil.ExitIfError(err) + + unarchivedFlag, err := flags.GetBool("unarchived") + cmdutil.ExitIfError(err) + + releaseDate, err := flags.GetString("release-date") + cmdutil.ExitIfError(err) + + startDate, err := flags.GetString("start-date") + cmdutil.ExitIfError(err) + + noInput, err := flags.GetBool("no-input") + cmdutil.ExitIfError(err) + + debug, err := flags.GetBool("debug") + cmdutil.ExitIfError(err) + + // Check for conflicting flags + if releasedFlag && unreleasedFlag { + cmdutil.Failed("Cannot use both --released and --unreleased flags") + } + if archivedFlag && unarchivedFlag { + cmdutil.Failed("Cannot use both --archived and --unarchived flags") + } + + // Auto-enable no-input if any flags are provided beyond the version arg + if name != "" || description != "" || releasedFlag || unreleasedFlag || + archivedFlag || unarchivedFlag || releaseDate != "" || startDate != "" { + noInput = true + } + + return &editParams{ + versionIDOrName: args[0], + name: name, + description: description, + releasedFlag: releasedFlag, + unreleasedFlag: unreleasedFlag, + archivedFlag: archivedFlag, + unarchivedFlag: unarchivedFlag, + releaseDate: releaseDate, + startDate: startDate, + noInput: noInput, + debug: debug, + } +} diff --git a/internal/cmd/release/release.go b/internal/cmd/release/release.go index acf787e9..f9871399 100644 --- a/internal/cmd/release/release.go +++ b/internal/cmd/release/release.go @@ -3,6 +3,8 @@ package release import ( "github.com/spf13/cobra" + "github.com/ankitpokhrel/jira-cli/internal/cmd/release/create" + "github.com/ankitpokhrel/jira-cli/internal/cmd/release/edit" "github.com/ankitpokhrel/jira-cli/internal/cmd/release/list" ) @@ -19,7 +21,14 @@ func NewCmdRelease() *cobra.Command { RunE: releases, } - cmd.AddCommand(list.NewCmdList()) + listCmd := list.NewCmdList() + createCmd := create.NewCmdCreate() + editCmd := edit.NewCmdEdit() + + cmd.AddCommand(listCmd, createCmd, editCmd) + + create.SetFlags(createCmd) + // edit command has its own setFlags call in NewCmdEdit return &cmd } diff --git a/internal/view/sprint.go b/internal/view/sprint.go index 0d1bfcde..809037b7 100644 --- a/internal/view/sprint.go +++ b/internal/view/sprint.go @@ -192,7 +192,7 @@ func (sl *SprintList) tableData() tui.TableData { var data tui.TableData headers := sl.tableHeader() - if !(sl.Display.Plain && sl.Display.NoHeaders) { + if !sl.Display.Plain || !sl.Display.NoHeaders { data = append(data, headers) } if len(headers) == 0 { diff --git a/pkg/jira/release.go b/pkg/jira/release.go index 41b63596..095f2f45 100644 --- a/pkg/jira/release.go +++ b/pkg/jira/release.go @@ -7,6 +7,27 @@ import ( "net/http" ) +// VersionCreateRequest holds request data for creating a version. +type VersionCreateRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Project string `json:"project"` + Archived bool `json:"archived"` + Released bool `json:"released"` + ReleaseDate string `json:"releaseDate,omitempty"` + StartDate string `json:"startDate,omitempty"` +} + +// VersionUpdateRequest holds request data for updating a version. +type VersionUpdateRequest struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Archived *bool `json:"archived,omitempty"` + Released *bool `json:"released,omitempty"` + ReleaseDate string `json:"releaseDate,omitempty"` + StartDate string `json:"startDate,omitempty"` +} + // Release fetches response from /project/{projectIdOrKey}/version endpoint. func (c *Client) Release(project string) ([]*ProjectVersion, error) { path := fmt.Sprintf("/project/%s/versions", project) @@ -29,3 +50,81 @@ func (c *Client) Release(project string) ([]*ProjectVersion, error) { return out, err } + +// CreateVersion creates a new version using POST /version endpoint. +func (c *Client) CreateVersion(req *VersionCreateRequest) (*ProjectVersion, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + + res, err := c.PostV2(context.Background(), "/version", body, Header{ + "Accept": "application/json", + "Content-Type": "application/json", + }) + if err != nil { + return nil, err + } + if res == nil { + return nil, ErrEmptyResponse + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusCreated { + return nil, formatUnexpectedResponse(res) + } + + var out ProjectVersion + err = json.NewDecoder(res.Body).Decode(&out) + + return &out, err +} + +// GetVersion fetches a specific version by ID using GET /version/{id} endpoint. +func (c *Client) GetVersion(id string) (*ProjectVersion, error) { + path := fmt.Sprintf("/version/%s", id) + res, err := c.GetV2(context.Background(), path, nil) + if err != nil { + return nil, err + } + if res == nil { + return nil, ErrEmptyResponse + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + return nil, formatUnexpectedResponse(res) + } + + var out ProjectVersion + err = json.NewDecoder(res.Body).Decode(&out) + + return &out, err +} + +// UpdateVersion updates a version using PUT /version/{id} endpoint. +func (c *Client) UpdateVersion(id string, req *VersionUpdateRequest) error { + body, err := json.Marshal(req) + if err != nil { + return err + } + + path := fmt.Sprintf("/version/%s", id) + res, err := c.PutV2(context.Background(), path, body, Header{ + "Accept": "application/json", + "Content-Type": "application/json", + }) + if err != nil { + return err + } + if res == nil { + return ErrEmptyResponse + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + return formatUnexpectedResponse(res) + } + + return nil +} diff --git a/pkg/jira/release_test.go b/pkg/jira/release_test.go index 72cd449f..953f6ec4 100644 --- a/pkg/jira/release_test.go +++ b/pkg/jira/release_test.go @@ -68,3 +68,114 @@ func TestReleases(t *testing.T) { _, err = client.Release("1000") assert.Error(t, &ErrUnexpectedResponse{}, err) } + +func TestCreateVersion(t *testing.T) { + var unexpectedStatusCode bool + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/rest/api/2/version", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + if unexpectedStatusCode { + w.WriteHeader(400) + } else { + resp, err := os.ReadFile("./testdata/version-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)) + + req := &VersionCreateRequest{ + Name: "Version 1.0", + Description: "First release", + Project: "TEST", + Archived: false, + Released: false, + } + + actual, err := client.CreateVersion(req) + assert.NoError(t, err) + assert.Equal(t, "10000", actual.ID) + assert.Equal(t, "Version 1.0", actual.Name) + assert.Equal(t, "First release", actual.Description) + + unexpectedStatusCode = true + + _, err = client.CreateVersion(req) + assert.Error(t, &ErrUnexpectedResponse{}, err) +} + +func TestGetVersion(t *testing.T) { + var unexpectedStatusCode bool + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "/rest/api/2/version/10000", r.URL.Path) + + if unexpectedStatusCode { + w.WriteHeader(404) + } else { + resp, err := os.ReadFile("./testdata/version-get.json") + assert.NoError(t, err) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, _ = w.Write(resp) + } + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + + actual, err := client.GetVersion("10000") + assert.NoError(t, err) + assert.Equal(t, "10000", actual.ID) + assert.Equal(t, "Version 1.0", actual.Name) + + unexpectedStatusCode = true + + _, err = client.GetVersion("10000") + assert.Error(t, &ErrUnexpectedResponse{}, err) +} + +func TestUpdateVersion(t *testing.T) { + var unexpectedStatusCode bool + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "PUT", r.Method) + assert.Equal(t, "/rest/api/2/version/10000", r.URL.Path) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + if unexpectedStatusCode { + w.WriteHeader(400) + } else { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, _ = w.Write([]byte(`{}`)) + } + })) + defer server.Close() + + client := NewClient(Config{Server: server.URL}, WithTimeout(3*time.Second)) + + released := true + req := &VersionUpdateRequest{ + Name: "Version 1.0 Updated", + Released: &released, + } + + err := client.UpdateVersion("10000", req) + assert.NoError(t, err) + + unexpectedStatusCode = true + + err = client.UpdateVersion("10000", req) + assert.Error(t, &ErrUnexpectedResponse{}, err) +} diff --git a/pkg/jira/testdata/version-create.json b/pkg/jira/testdata/version-create.json new file mode 100644 index 00000000..2b680736 --- /dev/null +++ b/pkg/jira/testdata/version-create.json @@ -0,0 +1,8 @@ +{ + "id": "10000", + "name": "Version 1.0", + "description": "First release", + "archived": false, + "released": false, + "projectId": 10000 +} diff --git a/pkg/jira/testdata/version-get.json b/pkg/jira/testdata/version-get.json new file mode 100644 index 00000000..f26f1b20 --- /dev/null +++ b/pkg/jira/testdata/version-get.json @@ -0,0 +1,8 @@ +{ + "id": "10000", + "name": "Version 1.0", + "description": "A test version", + "archived": false, + "released": false, + "projectId": 10000 +}