Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,24 @@ jira project list
```
</details>

<details><summary>List all components in a project</summary>

```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
```
</details>

<details><summary>List all boards in a project</summary>

```sh
Expand Down
12 changes: 12 additions & 0 deletions api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
28 changes: 28 additions & 0 deletions internal/cmd/project/component/component.go
Original file line number Diff line number Diff line change
@@ -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()
}
103 changes: 103 additions & 0 deletions internal/cmd/project/component/list/list.go
Original file line number Diff line number Diff line change
@@ -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)
}
Comment on lines +87 to +100
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--plain is described as “tab-delimited/scriptable”, but tabwriter.Writer expands tabs into aligned spaces. That makes the output no longer reliably tab-delimited for scripting/parsing. Consider writing directly with \t separators (optionally behind a bufio.Writer) and skipping tabwriter in plain mode.

Copilot uses AI. Check for mistakes.

_ = w.Flush()
}
2 changes: 2 additions & 0 deletions internal/cmd/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -20,6 +21,7 @@ func NewCmdProject() *cobra.Command {
}

cmd.AddCommand(list.NewCmdList())
cmd.AddCommand(component.NewCmdComponent())

return &cmd
}
Expand Down
83 changes: 83 additions & 0 deletions internal/view/component.go
Original file line number Diff line number Diff line change
@@ -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)
}
29 changes: 29 additions & 0 deletions internal/view/component_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
51 changes: 51 additions & 0 deletions pkg/jira/component.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading