Skip to content
93 changes: 90 additions & 3 deletions pkg/cmd/project/item-list/item_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ type listOpts struct {
owner string
number int32
query string
fields []string
fieldIDs []string
exporter cmdutil.Exporter
}

Expand Down Expand Up @@ -55,6 +57,9 @@ func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.C

# List items with the "bug" label that are not done
$ gh project item-list 1 --owner "@me" --query "label:bug -status:Done"

# Show the "Status" and "Priority" field values as extra columns
$ gh project item-list 1 --owner "@me" --field "Status" --field "Priority"
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -71,6 +76,14 @@ func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.C
opts.number = int32(num)
}

if err := cmdutil.MutuallyExclusive(
"only one of `--field` or `--field-id` may be used",
len(opts.fields) > 0,
len(opts.fieldIDs) > 0,
); err != nil {
return err
}
Comment thread
zwick marked this conversation as resolved.

config := listConfig{
io: f.IOStreams,
client: client,
Expand Down Expand Up @@ -101,6 +114,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.C

listCmd.Flags().StringVar(&opts.owner, "owner", "", "Login of the owner. Use \"@me\" for the current user")
listCmd.Flags().StringVar(&opts.query, "query", "", `Filter items using the Projects filter syntax, e.g. "assignee:octocat -status:Done"`)
listCmd.Flags().StringArrayVar(&opts.fields, "field", nil, "Name of a field to show as an extra column")
listCmd.Flags().StringArrayVar(&opts.fieldIDs, "field-id", nil, "ID of a field to show as an extra column")
cmdutil.AddFormatFlags(listCmd, &opts.exporter)
listCmd.Flags().IntVarP(&opts.limit, "limit", "L", queries.LimitDefault, "Maximum number of items to fetch")

Expand Down Expand Up @@ -142,15 +157,73 @@ func runList(config listConfig) error {
return config.opts.exporter.Write(config.io, project.DetailedItems())
}

return printResults(config, project.Items.Nodes, owner.Login)
// Resolve any requested extra field columns to (header, fieldID) pairs. Name
// columns are resolved against the fields already returned with the items, so
// there is no separate field-ID preflight lookup. When extra columns are
// requested for a project whose field list spans more than one page, fetch the
// complete field list first so resolution does not miss fields beyond the
// first page.
fields := project.Fields.Nodes
if (len(config.opts.fields) > 0 || len(config.opts.fieldIDs) > 0) && project.Fields.PageInfo.HasNextPage {
withFields, err := config.client.ProjectFields(owner, config.opts.number, project.Fields.TotalCount)
if err != nil {
return err
}
fields = withFields.Fields.Nodes
}

extraFields, err := resolveFieldColumns(config.opts, fields)
if err != nil {
return err
}

return printResults(config, project.Items.Nodes, owner.Login, extraFields)
}

func printResults(config listConfig, items []queries.ProjectItem, login string) error {
// fieldColumn identifies an extra table column to render for each item: Header
// is the column title and FieldID is the field node ID whose value to show.
type fieldColumn struct {
Header string
FieldID string
}

// resolveFieldColumns turns the --field (names) and --field-id (node IDs) flags
// into ordered column descriptors. Names are resolved with ResolveFieldByName so
// unknown or ambiguous names fail with candidate-carrying errors; IDs are
// validated against the project's fields so an unknown ID also fails clearly.
func resolveFieldColumns(opts listOpts, fields []queries.ProjectField) ([]fieldColumn, error) {
columns := make([]fieldColumn, 0, len(opts.fields)+len(opts.fieldIDs))

for _, name := range opts.fields {
field, err := queries.ResolveFieldByName(fields, name)
if err != nil {
return nil, err
}
columns = append(columns, fieldColumn{Header: field.Name(), FieldID: field.ID()})
}

for _, id := range opts.fieldIDs {
field, err := queries.ResolveFieldByID(fields, id)
if err != nil {
return nil, err
}
columns = append(columns, fieldColumn{Header: field.Name(), FieldID: field.ID()})
}

return columns, nil
}

func printResults(config listConfig, items []queries.ProjectItem, login string, extraFields []fieldColumn) error {
if len(items) == 0 {
return cmdutil.NewNoResultsError(fmt.Sprintf("Project %d for owner %s has no items", config.opts.number, login))
}

tp := tableprinter.New(config.io, tableprinter.WithHeader("Type", "Title", "Number", "Repository", "ID"))
headers := []string{"Type", "Title", "Number", "Repository", "ID"}
for _, f := range extraFields {
headers = append(headers, f.Header)
}

tp := tableprinter.New(config.io, tableprinter.WithHeader(headers...))

for _, i := range items {
tp.AddField(i.Type())
Expand All @@ -162,8 +235,22 @@ func printResults(config listConfig, items []queries.ProjectItem, login string)
}
tp.AddField(i.Repo())
tp.AddField(i.ID(), tableprinter.WithTruncate(nil))
for _, f := range extraFields {
tp.AddField(fieldValueForItem(i, f.FieldID))
}
tp.EndRow()
}

return tp.Render()
}

// fieldValueForItem returns the display value of the field identified by fieldID
// on item i, or an empty string when the item has no value for that field.
func fieldValueForItem(i queries.ProjectItem, fieldID string) string {
for _, v := range i.FieldValues.Nodes {
if v.ID() == fieldID {
return v.DisplayValue()
}
}
return ""
}
Loading