diff --git a/README.md b/README.md index 28a16856..76d89cf9 100644 --- a/README.md +++ b/README.md @@ -713,6 +713,24 @@ jira project list ``` +
List all components in a project + +```sh +# List components for default project +jira project component list + +# List components for specific project +jira project component list --project 1000 +jira project component list --project KEY + +# List components in plain mode +jira project component list --plain + +# List components as raw JSON +jira project component list --raw +``` +
+
List all boards in a project ```sh diff --git a/api/client.go b/api/client.go index 683f4b16..7c2fd094 100644 --- a/api/client.go +++ b/api/client.go @@ -228,3 +228,15 @@ func ProxyWatchIssue(c *jira.Client, key string, user *jira.User) error { } return c.WatchIssue(key, assignee) } + +// ProxyProjectComponents uses either a v2 or v3 version of the Jira +// GET /project/{projectIdOrKey}/components endpoint to fetch project components. +// Defaults to v3 if installation type is not defined in the config. +func ProxyProjectComponents(c *jira.Client, project string) ([]*jira.ProjectComponent, error) { + it := viper.GetString("installation") + + if it == jira.InstallationTypeLocal { + return c.ProjectComponentsV2(project) + } + return c.ProjectComponents(project) +} diff --git a/internal/cmd/project/component/component.go b/internal/cmd/project/component/component.go new file mode 100644 index 00000000..8dd9df60 --- /dev/null +++ b/internal/cmd/project/component/component.go @@ -0,0 +1,28 @@ +package component + +import ( + "github.com/spf13/cobra" + + "github.com/ankitpokhrel/jira-cli/internal/cmd/project/component/list" +) + +const helpText = `Component manages Jira project components. See available commands below.` + +// NewCmdComponent is a project component command. +func NewCmdComponent() *cobra.Command { + cmd := cobra.Command{ + Use: "component", + Short: "Component manages Jira project components", + Long: helpText, + Aliases: []string{"components"}, + RunE: component, + } + + cmd.AddCommand(list.NewCmdList()) + + return &cmd +} + +func component(cmd *cobra.Command, _ []string) error { + return cmd.Help() +} diff --git a/internal/cmd/project/component/list/list.go b/internal/cmd/project/component/list/list.go new file mode 100644 index 00000000..30040991 --- /dev/null +++ b/internal/cmd/project/component/list/list.go @@ -0,0 +1,103 @@ +package list + +import ( + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + "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/view" + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +const tabWidth = 8 + +// NewCmdList is a list command. +func NewCmdList() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List lists Jira project components", + Long: "List lists Jira project components for the given Jira project.", + Aliases: []string{"lists", "ls"}, + Run: List, + } + + cmd.Flags().Bool("plain", false, "Display output in plain mode") + cmd.Flags().Bool("raw", false, "Print raw JSON output") + + return cmd +} + +// List displays a list view. +func List(cmd *cobra.Command, _ []string) { + project := viper.GetString("project.key") + debug, err := cmd.Flags().GetBool("debug") + cmdutil.ExitIfError(err) + + components, total, err := func() ([]*jira.ProjectComponent, int, error) { + s := cmdutil.Info("Fetching project components...") + defer s.Stop() + + components, err := api.ProxyProjectComponents(api.DefaultClient(debug), project) + if err != nil { + return nil, 0, err + } + return components, len(components), nil + }() + cmdutil.ExitIfError(err) + + if total == 0 { + cmdutil.Failed("No components found.") + return + } + + raw, err := cmd.Flags().GetBool("raw") + cmdutil.ExitIfError(err) + + if raw { + outputRawJSON(components) + return + } + + plain, err := cmd.Flags().GetBool("plain") + cmdutil.ExitIfError(err) + + if plain { + outputPlain(components) + return + } + + v := view.NewComponent(components) + + cmdutil.ExitIfError(v.Render()) +} + +func outputRawJSON(components []*jira.ProjectComponent) { + data, err := json.MarshalIndent(components, "", " ") + if err != nil { + cmdutil.Failed("Failed to marshal components to JSON: %s", err) + return + } + fmt.Println(string(data)) +} + +func outputPlain(components []*jira.ProjectComponent) { + w := tabwriter.NewWriter(os.Stdout, 0, tabWidth, 1, '\t', 0) + _, _ = fmt.Fprintln(w, "ID\tNAME\tDESCRIPTION") + + for _, c := range components { + desc := "" + if c.Description != nil { + desc = fmt.Sprint(c.Description) + } + + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", c.ID, c.Name, desc) + } + + _ = w.Flush() +} diff --git a/internal/cmd/project/project.go b/internal/cmd/project/project.go index 395176e9..8c83dc00 100644 --- a/internal/cmd/project/project.go +++ b/internal/cmd/project/project.go @@ -3,6 +3,7 @@ package project import ( "github.com/spf13/cobra" + "github.com/ankitpokhrel/jira-cli/internal/cmd/project/component" "github.com/ankitpokhrel/jira-cli/internal/cmd/project/list" ) @@ -20,6 +21,7 @@ func NewCmdProject() *cobra.Command { } cmd.AddCommand(list.NewCmdList()) + cmd.AddCommand(component.NewCmdComponent()) return &cmd } diff --git a/internal/view/component.go b/internal/view/component.go new file mode 100644 index 00000000..4220265d --- /dev/null +++ b/internal/view/component.go @@ -0,0 +1,83 @@ +package view + +import ( + "bytes" + "fmt" + "io" + "text/tabwriter" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" + "github.com/ankitpokhrel/jira-cli/pkg/tui" +) + +// ComponentOption is a functional option to wrap component properties. +type ComponentOption func(*Component) + +// Component is a project component view. +type Component struct { + data []*jira.ProjectComponent + writer io.Writer + buf *bytes.Buffer +} + +// NewComponent initializes a component view. +func NewComponent(data []*jira.ProjectComponent, opts ...ComponentOption) *Component { + c := Component{ + data: data, + buf: new(bytes.Buffer), + } + c.writer = tabwriter.NewWriter(c.buf, 0, tabWidth, 1, '\t', 0) + + for _, opt := range opts { + opt(&c) + } + return &c +} + +// WithComponentWriter sets a writer for the component. +func WithComponentWriter(w io.Writer) ComponentOption { + return func(c *Component) { + c.writer = w + } +} + +// Render renders the component view. +func (c Component) Render() error { + c.printHeader() + + for _, d := range c.data { + desc := "" + if d.Description != nil { + desc = fmt.Sprint(d.Description) + } + _, _ = fmt.Fprintf(c.writer, "%v\t%v\t%v\n", d.ID, prepareTitle(d.Name), desc) + } + if _, ok := c.writer.(*tabwriter.Writer); ok { + err := c.writer.(*tabwriter.Writer).Flush() + if err != nil { + return err + } + } + + return tui.PagerOut(c.buf.String()) +} + +func (c Component) header() []string { + return []string{ + "ID", + "NAME", + "DESCRIPTION", + } +} + +func (c Component) printHeader() { + headers := c.header() + end := len(headers) - 1 + for i, h := range headers { + _, _ = fmt.Fprintf(c.writer, "%s", h) + if i != end { + _, _ = fmt.Fprintf(c.writer, "\t") + } + } + _, _ = fmt.Fprintln(c.writer) +} diff --git a/internal/view/component_test.go b/internal/view/component_test.go new file mode 100644 index 00000000..01cffc4e --- /dev/null +++ b/internal/view/component_test.go @@ -0,0 +1,29 @@ +package view + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/ankitpokhrel/jira-cli/pkg/jira" +) + +func TestComponentRender(t *testing.T) { + var b bytes.Buffer + + data := []*jira.ProjectComponent{ + {ID: "10000", Name: "Backend", Description: "Core backend APIs"}, + {ID: "10001", Name: "[UI] Frontend", Description: "Web app"}, + {ID: "10002", Name: "Mobile", Description: nil}, + } + component := NewComponent(data, WithComponentWriter(&b)) + assert.NoError(t, component.Render()) + + expected := `ID NAME DESCRIPTION +10000 Backend Core backend APIs +10001 [UI[] Frontend Web app +10002 Mobile +` + assert.Equal(t, expected, b.String()) +} diff --git a/pkg/jira/component.go b/pkg/jira/component.go new file mode 100644 index 00000000..95e6e2b2 --- /dev/null +++ b/pkg/jira/component.go @@ -0,0 +1,51 @@ +package jira + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// ProjectComponents fetches response from /project/{projectIdOrKey}/components endpoint. +func (c *Client) ProjectComponents(project string) ([]*ProjectComponent, error) { + return c.projectComponents(project, apiVersion3) +} + +// ProjectComponentsV2 fetches response from /project/{projectIdOrKey}/components endpoint. +func (c *Client) ProjectComponentsV2(project string) ([]*ProjectComponent, error) { + return c.projectComponents(project, apiVersion2) +} + +func (c *Client) projectComponents(project, ver string) ([]*ProjectComponent, error) { + path := fmt.Sprintf("/project/%s/components", project) + + var ( + res *http.Response + err error + ) + + switch ver { + case apiVersion2: + res, err = c.GetV2(context.Background(), path, nil) + default: + res, err = c.Get(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 []*ProjectComponent + + err = json.NewDecoder(res.Body).Decode(&out) + + return out, err +} diff --git a/pkg/jira/component_test.go b/pkg/jira/component_test.go new file mode 100644 index 00000000..7f6be3b9 --- /dev/null +++ b/pkg/jira/component_test.go @@ -0,0 +1,60 @@ +package jira + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func testProjectComponentsHelper(t *testing.T, expectedPath string, fn func(*Client, string) ([]*ProjectComponent, error)) { + var unexpectedStatusCode bool + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, expectedPath, r.URL.Path) + + if unexpectedStatusCode { + w.WriteHeader(400) + } else { + resp, err := os.ReadFile("./testdata/components.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 := fn(client, "PRJ") + assert.NoError(t, err) + + expected := []*ProjectComponent{ + {ID: "10000", Name: "Backend", Description: "Backend component"}, + {ID: "10001", Name: "Frontend", Description: "Frontend component"}, + {ID: "10002", Name: "Mobile", Description: "Mobile component"}, + } + assert.Equal(t, expected, actual) + + unexpectedStatusCode = true + + _, err = fn(client, "PRJ") + assert.Error(t, &ErrUnexpectedResponse{}, err) +} + +func TestProjectComponents(t *testing.T) { + testProjectComponentsHelper(t, "/rest/api/3/project/PRJ/components", func(c *Client, p string) ([]*ProjectComponent, error) { + return c.ProjectComponents(p) + }) +} + +func TestProjectComponentsV2(t *testing.T) { + testProjectComponentsHelper(t, "/rest/api/2/project/PRJ/components", func(c *Client, p string) ([]*ProjectComponent, error) { + return c.ProjectComponentsV2(p) + }) +} diff --git a/pkg/jira/testdata/components.json b/pkg/jira/testdata/components.json new file mode 100644 index 00000000..35d37d3a --- /dev/null +++ b/pkg/jira/testdata/components.json @@ -0,0 +1,17 @@ +[ + { + "id": "10000", + "name": "Backend", + "description": "Backend component" + }, + { + "id": "10001", + "name": "Frontend", + "description": "Frontend component" + }, + { + "id": "10002", + "name": "Mobile", + "description": "Mobile component" + } +] diff --git a/pkg/jira/types.go b/pkg/jira/types.go index e1c17719..0dbdac71 100644 --- a/pkg/jira/types.go +++ b/pkg/jira/types.go @@ -36,6 +36,13 @@ type Project struct { Type string `json:"style"` } +// ProjectComponent holds project component info. +type ProjectComponent struct { + ID string `json:"id"` + Name string `json:"name"` + Description interface{} `json:"description"` +} + // ProjectVersion holds project version info. type ProjectVersion struct { Archived bool `json:"archived"`