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"`