From 90449b51970c05fe07ed5270ee75578c3f8568d4 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Thu, 2 Apr 2026 12:53:30 -0500 Subject: [PATCH] Implement `gh discussion list` command Add the discussion list command with full support for: - Listing discussions with state, category, answered, and order filters - Searching discussions by author and labels (uses Search API) - Category resolution by slug or name (case-insensitive) - TTY and non-TTY table output with colored IDs and labels - JSON output with totalCount envelope - Web mode (--web) to open discussions in browser - Pagination for large result sets Implement List, Search, and ListCategories methods on the DiscussionClient. List and Search use plain text GraphQL for flexible variable handling. ListCategories uses safely typed GraphQL via shurcooL/githubv4. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/discussion/client/client_impl.go | 351 ++++++++++++++++- pkg/cmd/discussion/discussion.go | 5 + pkg/cmd/discussion/list/list.go | 311 +++++++++++++++ pkg/cmd/discussion/list/list_test.go | 466 +++++++++++++++++++++++ 4 files changed, 1127 insertions(+), 6 deletions(-) create mode 100644 pkg/cmd/discussion/list/list.go create mode 100644 pkg/cmd/discussion/list/list_test.go diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index e5db32dd5..567cce2af 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -3,9 +3,12 @@ package client import ( "fmt" "net/http" + "strings" + "time" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/shurcooL/githubv4" ) type discussionClient struct { @@ -19,12 +22,314 @@ func NewDiscussionClient(httpClient *http.Client) DiscussionClient { } } -func (c *discussionClient) List(_ ghrepo.Interface, _ ListFilters, _ int) ([]Discussion, int, error) { - return nil, 0, fmt.Errorf("not implemented") +// discussionNode is the shared GraphQL response shape for a single discussion, +// used by both List and Search to avoid duplicating the field mapping. +type discussionNode struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + State string `json:"state"` + StateReason string `json:"stateReason"` + Author struct { + Login string `json:"login"` + } `json:"author"` + Category struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Emoji string `json:"emoji"` + IsAnswerable bool `json:"isAnswerable"` + } `json:"category"` + Labels struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + } `json:"nodes"` + } `json:"labels"` + IsAnswered bool `json:"isAnswered"` + AnswerChosenAt time.Time `json:"answerChosenAt"` + AnswerChosenBy *struct { + Login string `json:"login"` + } `json:"answerChosenBy"` + ReactionGroups []struct { + Content string `json:"content"` + Users struct { + TotalCount int `json:"totalCount"` + } `json:"users"` + } `json:"reactionGroups"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + ClosedAt time.Time `json:"closedAt"` + Locked bool `json:"locked"` } -func (c *discussionClient) Search(_ ghrepo.Interface, _ SearchFilters, _ int) ([]Discussion, int, error) { - return nil, 0, fmt.Errorf("not implemented") +// mapDiscussion converts a GraphQL discussionNode response into the domain Discussion type. +func mapDiscussion(n discussionNode) Discussion { + d := Discussion{ + ID: n.ID, + Number: n.Number, + Title: n.Title, + URL: n.URL, + State: n.State, + StateReason: n.StateReason, + Author: DiscussionAuthor{Login: n.Author.Login}, + Category: DiscussionCategory{ + ID: n.Category.ID, + Name: n.Category.Name, + Slug: n.Category.Slug, + Emoji: n.Category.Emoji, + IsAnswerable: n.Category.IsAnswerable, + }, + Answered: n.IsAnswered, + AnswerChosenAt: n.AnswerChosenAt, + CreatedAt: n.CreatedAt, + UpdatedAt: n.UpdatedAt, + ClosedAt: n.ClosedAt, + Locked: n.Locked, + } + + if n.AnswerChosenBy != nil { + d.AnswerChosenBy = &DiscussionAuthor{Login: n.AnswerChosenBy.Login} + } + + d.Labels = make([]DiscussionLabel, len(n.Labels.Nodes)) + for i, l := range n.Labels.Nodes { + d.Labels[i] = DiscussionLabel{ID: l.ID, Name: l.Name, Color: l.Color} + } + + d.ReactionGroups = make([]ReactionGroup, len(n.ReactionGroups)) + for i, rg := range n.ReactionGroups { + d.ReactionGroups[i] = ReactionGroup{Content: rg.Content, TotalCount: rg.Users.TotalCount} + } + + return d +} + +// discussionFields is the GraphQL fragment selecting fields for discussion queries. +// It is shared by both List (repository.discussions) and Search queries. +const discussionFields = ` + id number title url state stateReason + author { login } + category { id name slug emoji isAnswerable } + labels(first: 20) { nodes { id name color } } + isAnswered answerChosenAt answerChosenBy { login } + reactionGroups { content users { totalCount } } + createdAt updatedAt closedAt locked +` + +func (c *discussionClient) List(repo ghrepo.Interface, filters ListFilters, limit int) ([]Discussion, int, error) { + type response struct { + Repository struct { + HasDiscussionsEnabled bool `json:"hasDiscussionsEnabled"` + Discussions struct { + TotalCount int `json:"totalCount"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + Nodes []discussionNode `json:"nodes"` + } `json:"discussions"` + } `json:"repository"` + } + + variables := map[string]interface{}{ + "owner": repo.RepoOwner(), + "name": repo.RepoName(), + } + + orderField := "UPDATED_AT" + orderDir := "DESC" + if filters.OrderBy != "" { + orderField = strings.ToUpper(filters.OrderBy) + "_AT" + } + if filters.Direction != "" { + orderDir = strings.ToUpper(filters.Direction) + } + variables["orderBy"] = map[string]string{ + "field": orderField, + "direction": orderDir, + } + + if filters.CategoryID != "" { + variables["categoryId"] = filters.CategoryID + } + + switch strings.ToLower(filters.State) { + case "open": + variables["states"] = []string{"OPEN"} + case "closed": + variables["states"] = []string{"CLOSED"} + } + + if filters.Answered != nil { + variables["answered"] = *filters.Answered + } + + // Build optional parameter declarations + paramParts := []string{ + "$owner: String!", + "$name: String!", + "$first: Int!", + "$after: String", + "$orderBy: DiscussionOrder", + } + argParts := []string{ + "first: $first", + "after: $after", + "orderBy: $orderBy", + } + if filters.CategoryID != "" { + paramParts = append(paramParts, "$categoryId: ID") + argParts = append(argParts, "categoryId: $categoryId") + } + if _, ok := variables["states"]; ok { + paramParts = append(paramParts, "$states: [DiscussionState!]") + argParts = append(argParts, "states: $states") + } + if filters.Answered != nil { + paramParts = append(paramParts, "$answered: Boolean") + argParts = append(argParts, "answered: $answered") + } + + query := fmt.Sprintf(`query DiscussionList(%s) { + repository(owner: $owner, name: $name) { + hasDiscussionsEnabled + discussions(%s) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { %s } + } + } + }`, strings.Join(paramParts, ", "), strings.Join(argParts, ", "), discussionFields) + + var discussions []Discussion + var totalCount int + pageLimit := limit + + for { + perPage := pageLimit + if perPage > 100 { + perPage = 100 + } + variables["first"] = perPage + + var data response + if err := c.gql.GraphQL(repo.RepoHost(), query, variables, &data); err != nil { + return nil, 0, err + } + + if !data.Repository.HasDiscussionsEnabled { + return nil, 0, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) + } + + totalCount = data.Repository.Discussions.TotalCount + for _, n := range data.Repository.Discussions.Nodes { + discussions = append(discussions, mapDiscussion(n)) + } + + pageLimit -= len(data.Repository.Discussions.Nodes) + if pageLimit <= 0 || !data.Repository.Discussions.PageInfo.HasNextPage { + break + } + variables["after"] = data.Repository.Discussions.PageInfo.EndCursor + } + + return discussions, totalCount, nil +} + +func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, limit int) ([]Discussion, int, error) { + type response struct { + Search struct { + DiscussionCount int `json:"discussionCount"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + Nodes []discussionNode `json:"nodes"` + } `json:"search"` + } + + searchTerms := []string{fmt.Sprintf("repo:%s/%s", repo.RepoOwner(), repo.RepoName())} + + switch strings.ToLower(filters.State) { + case "open": + searchTerms = append(searchTerms, "state:open") + case "closed": + searchTerms = append(searchTerms, "state:closed") + } + + if filters.Author != "" { + searchTerms = append(searchTerms, fmt.Sprintf("author:%s", filters.Author)) + } + for _, l := range filters.Labels { + searchTerms = append(searchTerms, fmt.Sprintf("label:%q", l)) + } + if filters.Category != "" { + searchTerms = append(searchTerms, fmt.Sprintf("category:%q", filters.Category)) + } + if filters.Answered != nil { + if *filters.Answered { + searchTerms = append(searchTerms, "is:answered") + } else { + searchTerms = append(searchTerms, "is:unanswered") + } + } + + orderField := "updated" + orderDir := "desc" + if filters.OrderBy != "" { + orderField = strings.ToLower(filters.OrderBy) + } + if filters.Direction != "" { + orderDir = strings.ToLower(filters.Direction) + } + searchTerms = append(searchTerms, fmt.Sprintf("sort:%s-%s", orderField, orderDir)) + + searchQuery := strings.Join(searchTerms, " ") + + query := fmt.Sprintf(`query DiscussionSearch($query: String!, $first: Int!, $after: String) { + search(query: $query, type: DISCUSSION, first: $first, after: $after) { + discussionCount + pageInfo { hasNextPage endCursor } + nodes { ... on Discussion { %s } } + } + }`, discussionFields) + + variables := map[string]interface{}{ + "query": searchQuery, + } + + var discussions []Discussion + var totalCount int + pageLimit := limit + + for { + perPage := pageLimit + if perPage > 100 { + perPage = 100 + } + variables["first"] = perPage + + var data response + if err := c.gql.GraphQL(repo.RepoHost(), query, variables, &data); err != nil { + return nil, 0, err + } + + totalCount = data.Search.DiscussionCount + for _, n := range data.Search.Nodes { + discussions = append(discussions, mapDiscussion(n)) + } + + pageLimit -= len(data.Search.Nodes) + if pageLimit <= 0 || !data.Search.PageInfo.HasNextPage { + break + } + variables["after"] = data.Search.PageInfo.EndCursor + } + + return discussions, totalCount, nil } func (c *discussionClient) GetByNumber(_ ghrepo.Interface, _ int) (*Discussion, error) { @@ -35,8 +340,42 @@ func (c *discussionClient) GetWithComments(_ ghrepo.Interface, _ int, _ int, _ s return nil, fmt.Errorf("not implemented") } -func (c *discussionClient) ListCategories(_ ghrepo.Interface) ([]DiscussionCategory, error) { - return nil, fmt.Errorf("not implemented") +func (c *discussionClient) ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error) { + var query struct { + Repository struct { + DiscussionCategories struct { + Nodes []struct { + ID string + Name string + Slug string + Emoji string + IsAnswerable bool + } + } `graphql:"discussionCategories(first: 100)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + } + + if err := c.gql.Query(repo.RepoHost(), "DiscussionCategoryList", &query, variables); err != nil { + return nil, err + } + + categories := make([]DiscussionCategory, len(query.Repository.DiscussionCategories.Nodes)) + for i, n := range query.Repository.DiscussionCategories.Nodes { + categories[i] = DiscussionCategory{ + ID: n.ID, + Name: n.Name, + Slug: n.Slug, + Emoji: n.Emoji, + IsAnswerable: n.IsAnswerable, + } + } + + return categories, nil } func (c *discussionClient) Create(_ ghrepo.Interface, _ CreateDiscussionInput) (*Discussion, error) { diff --git a/pkg/cmd/discussion/discussion.go b/pkg/cmd/discussion/discussion.go index 6db07c5d8..bd724fe69 100644 --- a/pkg/cmd/discussion/discussion.go +++ b/pkg/cmd/discussion/discussion.go @@ -2,6 +2,7 @@ package discussion import ( "github.com/MakeNowJust/heredoc" + cmdList "github.com/cli/cli/v2/pkg/cmd/discussion/list" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -29,5 +30,9 @@ func NewCmdDiscussion(f *cmdutil.Factory) *cobra.Command { cmdutil.EnableRepoOverride(cmd, f) + cmdutil.AddGroup(cmd, "General commands", + cmdList.NewCmdList(f, nil), + ) + return cmd } diff --git a/pkg/cmd/discussion/list/list.go b/pkg/cmd/discussion/list/list.go new file mode 100644 index 000000000..f82cc9806 --- /dev/null +++ b/pkg/cmd/discussion/list/list.go @@ -0,0 +1,311 @@ +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" +) + +// 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 + Order 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", + Short: "List discussions in a repository", + 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 answered discussions as JSON + $ gh discussion list --answered --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", 30, "Maximum number of discussions to fetch") + cmdutil.NilBoolFlag(cmd, &opts.Answered, "answered", "", "Filter by answered state") + cmdutil.StringEnumFlag(cmd, &opts.Order, "order", "", "updated", []string{"created", "updated"}, "Order by field") + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "List discussions in the web browser") + cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.DiscussionFields) + + return cmd +} + +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 := matchCategory(opts.Category, categories) + if err != nil { + return err + } + categoryID = cat.ID + categorySlug = cat.Slug + } + + var discussions []client.Discussion + var totalCount int + + useSearch := opts.Author != "" || len(opts.Labels) > 0 + if useSearch { + filters := client.SearchFilters{ + Author: opts.Author, + Labels: opts.Labels, + State: opts.State, + Category: categorySlug, + Answered: opts.Answered, + OrderBy: opts.Order, + } + discussions, totalCount, err = dc.Search(repo, filters, opts.Limit) + } else { + filters := client.ListFilters{ + State: opts.State, + CategoryID: categoryID, + Answered: opts.Answered, + OrderBy: opts.Order, + } + discussions, totalCount, err = dc.List(repo, filters, opts.Limit) + } + if err != nil { + return err + } + + if opts.Exporter != nil { + envelope := map[string]interface{}{ + "totalCount": totalCount, + "discussions": discussions, + } + return opts.Exporter.Write(opts.IO, envelope) + } + + if len(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(discussions), totalCount, opts.State) + fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) + } + + printDiscussions(opts, discussions, 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 matchCategory(input string, categories []client.DiscussionCategory) (*client.DiscussionCategory, error) { + for i := range categories { + if strings.EqualFold(categories[i].Slug, input) { + return &categories[i], nil + } + } + for i := range categories { + if strings.EqualFold(categories[i].Name, input) { + return &categories[i], nil + } + } + + var available strings.Builder + for _, c := range categories { + fmt.Fprintf(&available, " %s (%s)\n", c.Slug, c.Name) + } + return nil, fmt.Errorf("category not found: %s\n\nAvailable categories:\n%s", input, available.String()) +} + +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 strings.EqualFold(d.State, "CLOSED") { + idColor = cs.Gray + } + tp.AddField(fmt.Sprintf("#%d", d.Number), tableprinter.WithColor(idColor)) + } else { + tp.AddField(fmt.Sprintf("%d", d.Number)) + tp.AddField(d.State) + } + + 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) + } +} diff --git a/pkg/cmd/discussion/list/list_test.go b/pkg/cmd/discussion/list/list_test.go new file mode 100644 index 000000000..f4e6f57ab --- /dev/null +++ b/pkg/cmd/discussion/list/list_test.go @@ -0,0 +1,466 @@ +package list + +import ( + "bytes" + "testing" + "time" + + "github.com/cli/cli/v2/internal/browser" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/discussion/client" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func fixedTime() time.Time { + return time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC) +} + +func sampleDiscussions() []client.Discussion { + return []client.Discussion{ + { + Number: 42, + Title: "Bug report discussion", + URL: "https://github.com/OWNER/REPO/discussions/42", + State: "OPEN", + Author: client.DiscussionAuthor{Login: "monalisa"}, + Category: client.DiscussionCategory{ + ID: "CAT1", + Name: "General", + Slug: "general", + }, + Labels: []client.DiscussionLabel{ + {ID: "L1", Name: "bug", Color: "d73a4a"}, + }, + Answered: true, + UpdatedAt: time.Date(2025, 2, 28, 12, 0, 0, 0, time.UTC), + }, + { + Number: 41, + Title: "Feature request", + URL: "https://github.com/OWNER/REPO/discussions/41", + State: "OPEN", + Author: client.DiscussionAuthor{Login: "octocat"}, + Category: client.DiscussionCategory{ + ID: "CAT2", + Name: "Ideas", + Slug: "ideas", + }, + Labels: []client.DiscussionLabel{}, + Answered: false, + UpdatedAt: time.Date(2025, 2, 20, 12, 0, 0, 0, time.UTC), + }, + } +} + +func sampleCategories() []client.DiscussionCategory { + return []client.DiscussionCategory{ + {ID: "CAT1", Name: "General", Slug: "general", IsAnswerable: true}, + {ID: "CAT2", Name: "Ideas", Slug: "ideas", IsAnswerable: false}, + {ID: "CAT3", Name: "Show and tell", Slug: "show-and-tell", IsAnswerable: false}, + } +} + +func TestListRun_tty(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + mockClient := &client.DiscussionClientMock{ + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, limit int) ([]client.Discussion, int, error) { + return sampleDiscussions(), 2, nil + }, + } + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + State: "open", + Limit: 30, + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + + assert.Equal(t, "", stderr.String()) + out := stdout.String() + assert.Contains(t, out, "Showing 2 of 2 open discussions in OWNER/REPO") + assert.Contains(t, out, "#42") + assert.Contains(t, out, "Bug report discussion") + assert.Contains(t, out, "General") + assert.Contains(t, out, "✓") + assert.Contains(t, out, "#41") + assert.Contains(t, out, "Feature request") +} + +func TestListRun_nontty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + mockClient := &client.DiscussionClientMock{ + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, limit int) ([]client.Discussion, int, error) { + return sampleDiscussions(), 2, nil + }, + } + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + State: "open", + Limit: 30, + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + + out := stdout.String() + // Non-TTY output should not contain # prefix or header + assert.NotContains(t, out, "Showing") + assert.Contains(t, out, "42") + assert.Contains(t, out, "OPEN") + assert.Contains(t, out, "Bug report discussion") +} + +func TestListRun_json(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + + mockClient := &client.DiscussionClientMock{ + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, limit int) ([]client.Discussion, int, error) { + return sampleDiscussions(), 2, nil + }, + } + + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"number", "title"}) + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + State: "open", + Limit: 30, + Exporter: exporter, + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, `"totalCount"`) + assert.Contains(t, out, `"discussions"`) +} + +func TestListRun_web(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStderrTTY(true) + + br := &browser.Stub{} + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Browser: br, + WebMode: true, + State: "open", + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + + assert.Contains(t, stderr.String(), "Opening") + assert.Contains(t, br.BrowsedURL(), "github.com/OWNER/REPO/discussions") +} + +func TestListRun_noResults(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + mockClient := &client.DiscussionClientMock{ + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, limit int) ([]client.Discussion, int, error) { + return []client.Discussion{}, 0, nil + }, + } + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + State: "open", + Limit: 30, + Now: fixedTime, + } + + err := listRun(opts) + require.Error(t, err) + var noResultsErr cmdutil.NoResultsError + assert.ErrorAs(t, err, &noResultsErr) +} + +func TestListRun_categoryFilter(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + mockClient := &client.DiscussionClientMock{ + ListCategoriesFunc: func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + }, + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, limit int) ([]client.Discussion, int, error) { + assert.Equal(t, "CAT1", filters.CategoryID) + return sampleDiscussions()[:1], 1, nil + }, + } + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + Category: "general", + State: "open", + Limit: 30, + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Bug report discussion") +} + +func TestListRun_categoryNotFound(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + mockClient := &client.DiscussionClientMock{ + ListCategoriesFunc: func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + }, + } + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + Category: "nonexistent", + State: "open", + Limit: 30, + Now: fixedTime, + } + + err := listRun(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "category not found: nonexistent") + assert.Contains(t, err.Error(), "general (General)") +} + +func TestListRun_authorFilter(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + mockClient := &client.DiscussionClientMock{ + SearchFunc: func(repo ghrepo.Interface, filters client.SearchFilters, limit int) ([]client.Discussion, int, error) { + assert.Equal(t, "monalisa", filters.Author) + return sampleDiscussions()[:1], 1, nil + }, + } + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + Author: "monalisa", + State: "open", + Limit: 30, + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Bug report discussion") +} + +func TestListRun_labelFilter(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + mockClient := &client.DiscussionClientMock{ + SearchFunc: func(repo ghrepo.Interface, filters client.SearchFilters, limit int) ([]client.Discussion, int, error) { + assert.Equal(t, []string{"bug", "docs"}, filters.Labels) + return sampleDiscussions()[:1], 1, nil + }, + } + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + Labels: []string{"bug", "docs"}, + State: "open", + Limit: 30, + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Bug report discussion") +} + +func TestNewCmdList(t *testing.T) { + tests := []struct { + name string + args string + wantsErr bool + }{ + { + name: "no flags", + args: "", + }, + { + name: "state flag", + args: "--state closed", + }, + { + name: "label flag", + args: "--label bug --label docs", + }, + { + name: "author flag", + args: "--author monalisa", + }, + { + name: "category flag", + args: "--category general", + }, + { + name: "limit flag", + args: "--limit 10", + }, + { + name: "invalid limit", + args: "--limit 0", + wantsErr: true, + }, + { + name: "web flag", + args: "--web", + }, + { + name: "order flag", + args: "--order created", + }, + { + name: "invalid state", + args: "--state invalid", + wantsErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Browser: &browser.Stub{}, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + } + + var gotOpts *ListOptions + cmd := NewCmdList(f, func(o *ListOptions) error { + gotOpts = o + return nil + }) + + argv := []string{} + if tt.args != "" { + argv = splitArgs(tt.args) + } + cmd.SetArgs(argv) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err := cmd.ExecuteC() + + if tt.wantsErr { + require.Error(t, err) + return + } + require.NoError(t, err) + _ = gotOpts + }) + } +} + +func splitArgs(s string) []string { + var args []string + for _, part := range splitRespectingQuotes(s) { + if part != "" { + args = append(args, part) + } + } + return args +} + +func splitRespectingQuotes(s string) []string { + var result []string + var current []byte + inQuote := false + for i := 0; i < len(s); i++ { + if s[i] == '"' { + inQuote = !inQuote + continue + } + if s[i] == ' ' && !inQuote { + result = append(result, string(current)) + current = nil + continue + } + current = append(current, s[i]) + } + result = append(result, string(current)) + return result +} + +func TestListRun_closedState(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + closed := []client.Discussion{ + { + Number: 10, + Title: "Old discussion", + State: "CLOSED", + Category: client.DiscussionCategory{Name: "General"}, + Labels: []client.DiscussionLabel{}, + UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }, + } + + mockClient := &client.DiscussionClientMock{ + ListFunc: func(repo ghrepo.Interface, filters client.ListFilters, limit int) ([]client.Discussion, int, error) { + return closed, 1, nil + }, + } + + opts := &ListOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + State: "closed", + Limit: 30, + Now: fixedTime, + } + + err := listRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "closed discussions") + assert.Contains(t, out, "Old discussion") + // Verify the # prefix is present (TTY mode) + assert.Contains(t, out, "#10") +}