cli/pkg/cmd/discussion/list/list.go
Babak K. Shandiz 236224dc44
fix(discussion list): replace state with closed in domain types
Signed-off-by: Babak K. Shandiz <babakks@github.com>
2026-04-14 11:52:52 +01:00

354 lines
9.2 KiB
Go

package list
import (
"fmt"
"net/url"
"strings"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/browser"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/tableprinter"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmd/discussion/client"
"github.com/cli/cli/v2/pkg/cmd/discussion/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)
const defaultLimit = 30
// discussionListFields lists the field names available for --json output
// on the discussion list command. This excludes fields like "comments"
// that are only populated by the view command.
var discussionListFields = []string{
"id",
"number",
"title",
"body",
"url",
"closed",
"stateReason",
"author",
"category",
"labels",
"answered",
"answerChosenAt",
"answerChosenBy",
"reactionGroups",
"createdAt",
"updatedAt",
"closedAt",
"locked",
}
// ListOptions holds the configuration for the discussion list command.
type ListOptions struct {
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Browser browser.Browser
Client func() (client.DiscussionClient, error)
Author string
Category string
Labels []string
State string
Limit int
Answered *bool
Sort string
Order string
Search string
After string
WebMode bool
Exporter cmdutil.Exporter
Now func() time.Time
}
// NewCmdList creates the "discussion list" command.
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
opts := &ListOptions{
IO: f.IOStreams,
Browser: f.Browser,
Now: time.Now,
}
cmd := &cobra.Command{
Use: "list [flags]",
Short: "List discussions in a repository (preview)",
Long: heredoc.Doc(`
List discussions in a GitHub repository. By default, only open discussions
are shown.
`),
Example: heredoc.Doc(`
# List open discussions
$ gh discussion list
# List discussions with a specific category
$ gh discussion list --category General
# List closed discussions by author
$ gh discussion list --state closed --author monalisa
# List all discussions (closed or open) by label
$ gh discussion list --state all --label bug,enhancement
# List answered discussions as JSON
$ gh discussion list --answered --json number,title,url
# List unanswered discussions as JSON
$ gh discussion list --answered=false --json number,title,url
`),
Aliases: []string{"ls"},
Args: cmdutil.NoArgsQuoteReminder,
RunE: func(cmd *cobra.Command, args []string) error {
opts.BaseRepo = f.BaseRepo
opts.Client = shared.DiscussionClientFunc(f)
if opts.Limit < 1 {
return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit)
}
if runF != nil {
return runF(opts)
}
return listRun(opts)
},
}
cmd.Flags().StringVarP(&opts.Author, "author", "A", "", "Filter by author")
cmd.Flags().StringVarP(&opts.Category, "category", "c", "", "Filter by category name or slug")
cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Filter by label")
cmdutil.StringEnumFlag(cmd, &opts.State, "state", "s", "open", []string{"open", "closed", "all"}, "Filter by state")
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, fmt.Sprintf("Maximum number of discussions to fetch (default %d)", defaultLimit))
cmdutil.NilBoolFlag(cmd, &opts.Answered, "answered", "", "Filter by answered state")
cmdutil.StringEnumFlag(cmd, &opts.Sort, "sort", "", "updated", []string{"created", "updated"}, "Sort by field")
cmdutil.StringEnumFlag(cmd, &opts.Order, "order", "", "desc", []string{"asc", "desc"}, "Order of results")
cmd.Flags().StringVarP(&opts.Search, "search", "S", "", "Search discussions with `query`")
cmd.Flags().StringVar(&opts.After, "after", "", "Cursor for the next page of results")
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "List discussions in the web browser")
cmdutil.AddJSONFlags(cmd, &opts.Exporter, discussionListFields)
return cmd
}
// toFilterState maps CLI state strings to domain-level filter state pointers.
// "all" maps to nil (no state filter).
func toFilterState(v string) *string {
switch v {
case "open":
s := client.FilterStateOpen
return &s
case "closed":
s := client.FilterStateClosed
return &s
default:
return nil
}
}
func listRun(opts *ListOptions) error {
repo, err := opts.BaseRepo()
if err != nil {
return err
}
if opts.WebMode {
return openInBrowser(opts, repo)
}
dc, err := opts.Client()
if err != nil {
return err
}
var categoryID string
var categorySlug string
if opts.Category != "" {
categories, err := dc.ListCategories(repo)
if err != nil {
return err
}
cat, err := shared.MatchCategory(opts.Category, categories)
if err != nil {
return err
}
categoryID = cat.ID
categorySlug = cat.Slug
}
state := toFilterState(opts.State)
var result client.DiscussionListResult
useSearch := opts.Author != "" || len(opts.Labels) > 0 || opts.Search != ""
if useSearch {
filters := client.SearchFilters{
Author: opts.Author,
Labels: opts.Labels,
State: state,
Category: categorySlug,
Answered: opts.Answered,
Keywords: opts.Search,
OrderBy: opts.Sort,
Direction: opts.Order,
}
result, err = dc.Search(repo, filters, opts.After, opts.Limit)
} else {
filters := client.ListFilters{
State: state,
CategoryID: categoryID,
Answered: opts.Answered,
OrderBy: opts.Sort,
Direction: opts.Order,
}
result, err = dc.List(repo, filters, opts.After, opts.Limit)
}
if err != nil {
return err
}
if opts.Exporter != nil {
envelope := map[string]interface{}{
"totalCount": result.TotalCount,
"discussions": result.Discussions,
"next": result.NextCursor,
}
return opts.Exporter.Write(opts.IO, envelope)
}
if len(result.Discussions) == 0 {
return noResults(repo, opts.State)
}
if err := opts.IO.StartPager(); err == nil {
defer opts.IO.StopPager()
} else {
fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err)
}
isTerminal := opts.IO.IsStdoutTTY()
if isTerminal {
title := listHeader(ghrepo.FullName(repo), len(result.Discussions), result.TotalCount, opts.State)
fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title)
}
printDiscussions(opts, result.Discussions, result.TotalCount)
return nil
}
func openInBrowser(opts *ListOptions, repo ghrepo.Interface) error {
discussionsURL := ghrepo.GenerateRepoURL(repo, "discussions")
var queryParts []string
if opts.State != "" && opts.State != "all" {
queryParts = append(queryParts, "is:"+opts.State)
}
if opts.Author != "" {
queryParts = append(queryParts, "author:"+opts.Author)
}
for _, l := range opts.Labels {
queryParts = append(queryParts, fmt.Sprintf("label:%q", l))
}
if opts.Category != "" {
queryParts = append(queryParts, fmt.Sprintf("category:%q", opts.Category))
}
if opts.Answered != nil {
if *opts.Answered {
queryParts = append(queryParts, "is:answered")
} else {
queryParts = append(queryParts, "is:unanswered")
}
}
if len(queryParts) > 0 {
discussionsURL += "?" + url.Values{"q": {strings.Join(queryParts, " ")}}.Encode()
}
if opts.IO.IsStderrTTY() {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(discussionsURL))
}
return opts.Browser.Browse(discussionsURL)
}
func noResults(repo ghrepo.Interface, state string) error {
stateQualifier := ""
switch state {
case "open":
stateQualifier = " open"
case "closed":
stateQualifier = " closed"
}
return cmdutil.NewNoResultsError(fmt.Sprintf("no%s discussions match your search in %s", stateQualifier, ghrepo.FullName(repo)))
}
func listHeader(repoName string, count, total int, state string) string {
stateQualifier := ""
switch state {
case "open":
stateQualifier = " open"
case "closed":
stateQualifier = " closed"
}
return fmt.Sprintf("Showing %d of %d%s discussions in %s", count, total, stateQualifier, repoName)
}
func printDiscussions(opts *ListOptions, discussions []client.Discussion, totalCount int) {
isTerminal := opts.IO.IsStdoutTTY()
cs := opts.IO.ColorScheme()
now := opts.Now()
headers := []string{"ID", "TITLE", "CATEGORY", "LABELS", "ANSWERED", "UPDATED"}
if !isTerminal {
headers = []string{"ID", "STATE", "TITLE", "CATEGORY", "LABELS", "ANSWERED", "UPDATED"}
}
tp := tableprinter.New(opts.IO, tableprinter.WithHeader(headers...))
for _, d := range discussions {
if isTerminal {
idColor := cs.Green
if d.Closed {
idColor = cs.Gray
}
tp.AddField(fmt.Sprintf("#%d", d.Number), tableprinter.WithColor(idColor))
} else {
tp.AddField(fmt.Sprintf("%d", d.Number))
if d.Closed {
tp.AddField("CLOSED")
} else {
tp.AddField("OPEN")
}
}
tp.AddField(text.RemoveExcessiveWhitespace(d.Title))
tp.AddField(d.Category.Name)
labelNames := make([]string, len(d.Labels))
for i, l := range d.Labels {
if isTerminal {
labelNames[i] = cs.Label(l.Color, l.Name)
} else {
labelNames[i] = l.Name
}
}
tp.AddField(strings.Join(labelNames, ", "), tableprinter.WithTruncate(nil))
if d.Answered {
tp.AddField("✓")
} else {
tp.AddField("")
}
tp.AddTimeField(now, d.UpdatedAt, cs.Muted)
tp.EndRow()
}
_ = tp.Render()
remaining := totalCount - len(discussions)
if remaining > 0 {
fmt.Fprintf(opts.IO.Out, cs.Muted("And %d more\n"), remaining)
}
}