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>
This commit is contained in:
Max Beizer 2026-04-02 12:53:30 -05:00
parent 65f5b21121
commit 90449b5197
No known key found for this signature in database
4 changed files with 1127 additions and 6 deletions

View file

@ -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) {

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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")
}