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