diff --git a/pkg/cmd/discussion/client/client.go b/pkg/cmd/discussion/client/client.go index a794d13e1..5c7f1bdc4 100644 --- a/pkg/cmd/discussion/client/client.go +++ b/pkg/cmd/discussion/client/client.go @@ -12,7 +12,7 @@ type DiscussionClient interface { List(repo ghrepo.Interface, filters ListFilters, after string, limit int) (*DiscussionListResult, error) Search(repo ghrepo.Interface, filters SearchFilters, after string, limit int) (*DiscussionListResult, error) GetByNumber(repo ghrepo.Interface, number int) (*Discussion, error) - GetWithComments(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) + GetWithComments(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*Discussion, error) ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error) Create(repo ghrepo.Interface, input CreateDiscussionInput) (*Discussion, error) Update(repo ghrepo.Interface, input UpdateDiscussionInput) (*Discussion, error) diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index d3f8e817b..b7fdf36b2 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -3,6 +3,7 @@ package client import ( "fmt" "net/http" + "slices" "strings" "time" @@ -351,12 +352,226 @@ func (c *discussionClient) Search(repo ghrepo.Interface, filters SearchFilters, return &result, nil } -func (c *discussionClient) GetByNumber(_ ghrepo.Interface, _ int) (*Discussion, error) { - return nil, fmt.Errorf("not implemented") +func (c *discussionClient) GetByNumber(repo ghrepo.Interface, number int) (*Discussion, error) { + var query struct { + Repository struct { + HasDiscussionsEnabled bool + Discussion struct { + discussionListNode + Comments struct { + TotalCount int + } + } `graphql:"discussion(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "number": githubv4.Int(number), + } + + err := c.gql.Query(repo.RepoHost(), "DiscussionMinimal", &query, variables) + if err != nil { + return nil, err + } + if !query.Repository.HasDiscussionsEnabled { + return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) + } + + d := mapDiscussionFromListNode(query.Repository.Discussion.discussionListNode) + d.Comments = DiscussionCommentList{TotalCount: query.Repository.Discussion.Comments.TotalCount} + + for _, rg := range query.Repository.Discussion.ReactionGroups { + d.ReactionGroups = append(d.ReactionGroups, ReactionGroup{ + Content: rg.Content, + TotalCount: rg.Users.TotalCount, + }) + } + + return &d, nil } -func (c *discussionClient) GetWithComments(_ ghrepo.Interface, _ int, _ int, _ string) (*Discussion, error) { - return nil, fmt.Errorf("not implemented") +// discussionCommentNode is the GraphQL response shape for a discussion comment +// including nested replies. +type discussionCommentNode struct { + ID string + URL string `graphql:"url"` + Author actorNode + Body string + CreatedAt time.Time + IsAnswer bool + UpvoteCount int + ReactionGroups []struct { + Content string + Users struct { + TotalCount int + } + } + Replies struct { + TotalCount int + Nodes []struct { + ID string + URL string `graphql:"url"` + Author actorNode + Body string + CreatedAt time.Time + IsAnswer bool + UpvoteCount int + ReactionGroups []struct { + Content string + Users struct { + TotalCount int + } + } + } + } `graphql:"replies(last: 4)"` +} + +// mapCommentFromNode converts a discussionCommentNode into the domain DiscussionComment type. +func mapCommentFromNode(n discussionCommentNode) DiscussionComment { + dc := DiscussionComment{ + ID: n.ID, + URL: n.URL, + Author: mapActorFromListNode(n.Author), + Body: n.Body, + CreatedAt: n.CreatedAt, + IsAnswer: n.IsAnswer, + UpvoteCount: n.UpvoteCount, + } + + for _, rg := range n.ReactionGroups { + dc.ReactionGroups = append(dc.ReactionGroups, ReactionGroup{ + Content: rg.Content, + TotalCount: rg.Users.TotalCount, + }) + } + + replyComments := make([]DiscussionComment, len(n.Replies.Nodes)) + for i, r := range n.Replies.Nodes { + rc := DiscussionComment{ + ID: r.ID, + URL: r.URL, + Author: mapActorFromListNode(r.Author), + Body: r.Body, + CreatedAt: r.CreatedAt, + IsAnswer: r.IsAnswer, + UpvoteCount: r.UpvoteCount, + } + for _, rg := range r.ReactionGroups { + rc.ReactionGroups = append(rc.ReactionGroups, ReactionGroup{ + Content: rg.Content, + TotalCount: rg.Users.TotalCount, + }) + } + replyComments[i] = rc + } + dc.Replies = DiscussionCommentList{ + Comments: replyComments, + TotalCount: n.Replies.TotalCount, + Direction: DiscussionCommentListDirectionBackward, + } + + return dc +} + +func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, limit int, after string, newest bool) (*Discussion, error) { + var query struct { + Repository struct { + HasDiscussionsEnabled bool + Discussion struct { + discussionListNode + Comments struct { + TotalCount int + PageInfo struct { + EndCursor string + HasNextPage bool + StartCursor string + HasPreviousPage bool + } + Nodes []discussionCommentNode + } `graphql:"comments(first: $first, last: $last, after: $after, before: $before)"` + } `graphql:"discussion(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "number": githubv4.Int(number), + "first": (*githubv4.Int)(nil), + "last": (*githubv4.Int)(nil), + "after": (*githubv4.String)(nil), + "before": (*githubv4.String)(nil), + } + + if newest { + variables["last"] = githubv4.Int(limit) + if after != "" { + variables["before"] = githubv4.String(after) + } + } else { + variables["first"] = githubv4.Int(limit) + if after != "" { + variables["after"] = githubv4.String(after) + } + } + + err := c.gql.Query(repo.RepoHost(), "DiscussionWithComments", &query, variables) + if err != nil { + return nil, err + } + if !query.Repository.HasDiscussionsEnabled { + return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName()) + } + + src := query.Repository.Discussion + + d := mapDiscussionFromListNode(src.discussionListNode) + + for _, rg := range src.ReactionGroups { + d.ReactionGroups = append(d.ReactionGroups, ReactionGroup{ + Content: rg.Content, + TotalCount: rg.Users.TotalCount, + }) + } + + comments := make([]DiscussionComment, len(src.Comments.Nodes)) + for i, c := range src.Comments.Nodes { + comments[i] = mapCommentFromNode(c) + } + + // When using "last" (newest order), the API returns items in chronological + // order. Reverse them so the newest comment appears first. + if newest { + slices.Reverse(comments) + } + + nextCursor := "" + if newest { + if src.Comments.PageInfo.HasPreviousPage { + nextCursor = src.Comments.PageInfo.StartCursor + } + } else { + if src.Comments.PageInfo.HasNextPage { + nextCursor = src.Comments.PageInfo.EndCursor + } + } + + direction := DiscussionCommentListDirectionForward + if newest { + direction = DiscussionCommentListDirectionBackward + } + + d.Comments = DiscussionCommentList{ + Comments: comments, + TotalCount: src.Comments.TotalCount, + Cursor: after, + NextCursor: nextCursor, + Direction: direction, + } + + return &d, nil } func (c *discussionClient) ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error) { diff --git a/pkg/cmd/discussion/client/client_mock.go b/pkg/cmd/discussion/client/client_mock.go index a690f84b1..a3cec3c63 100644 --- a/pkg/cmd/discussion/client/client_mock.go +++ b/pkg/cmd/discussion/client/client_mock.go @@ -30,7 +30,7 @@ var _ DiscussionClient = &DiscussionClientMock{} // GetByNumberFunc: func(repo ghrepo.Interface, number int) (*Discussion, error) { // panic("mock out the GetByNumber method") // }, -// GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) { +// GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*Discussion, error) { // panic("mock out the GetWithComments method") // }, // ListFunc: func(repo ghrepo.Interface, filters ListFilters, after string, limit int) (*DiscussionListResult, error) { @@ -80,7 +80,7 @@ type DiscussionClientMock struct { GetByNumberFunc func(repo ghrepo.Interface, number int) (*Discussion, error) // GetWithCommentsFunc mocks the GetWithComments method. - GetWithCommentsFunc func(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) + GetWithCommentsFunc func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*Discussion, error) // ListFunc mocks the List method. ListFunc func(repo ghrepo.Interface, filters ListFilters, after string, limit int) (*DiscussionListResult, error) @@ -153,8 +153,10 @@ type DiscussionClientMock struct { Number int // CommentLimit is the commentLimit argument value. CommentLimit int - // Order is the order argument value. - Order string + // After is the after argument value. + After string + // Newest is the newest argument value. + Newest bool } // List holds details about calls to the List method. List []struct { @@ -401,7 +403,7 @@ func (mock *DiscussionClientMock) GetByNumberCalls() []struct { } // GetWithComments calls GetWithCommentsFunc. -func (mock *DiscussionClientMock) GetWithComments(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) { +func (mock *DiscussionClientMock) GetWithComments(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*Discussion, error) { if mock.GetWithCommentsFunc == nil { panic("DiscussionClientMock.GetWithCommentsFunc: method is nil but DiscussionClient.GetWithComments was just called") } @@ -409,17 +411,19 @@ func (mock *DiscussionClientMock) GetWithComments(repo ghrepo.Interface, number Repo ghrepo.Interface Number int CommentLimit int - Order string + After string + Newest bool }{ Repo: repo, Number: number, CommentLimit: commentLimit, - Order: order, + After: after, + Newest: newest, } mock.lockGetWithComments.Lock() mock.calls.GetWithComments = append(mock.calls.GetWithComments, callInfo) mock.lockGetWithComments.Unlock() - return mock.GetWithCommentsFunc(repo, number, commentLimit, order) + return mock.GetWithCommentsFunc(repo, number, commentLimit, after, newest) } // GetWithCommentsCalls gets all the calls that were made to GetWithComments. @@ -430,13 +434,15 @@ func (mock *DiscussionClientMock) GetWithCommentsCalls() []struct { Repo ghrepo.Interface Number int CommentLimit int - Order string + After string + Newest bool } { var calls []struct { Repo ghrepo.Interface Number int CommentLimit int - Order string + After string + Newest bool } mock.lockGetWithComments.RLock() calls = mock.calls.GetWithComments diff --git a/pkg/cmd/discussion/client/types.go b/pkg/cmd/discussion/client/types.go index f3c58456c..a5affcb3a 100644 --- a/pkg/cmd/discussion/client/types.go +++ b/pkg/cmd/discussion/client/types.go @@ -19,6 +19,7 @@ type Discussion struct { AnswerChosenAt time.Time AnswerChosenBy *DiscussionActor Comments DiscussionCommentList + ReactionGroups []ReactionGroup CreatedAt time.Time UpdatedAt time.Time ClosedAt time.Time @@ -44,6 +45,12 @@ func (d Discussion) ExportData(fields []string) map[string]interface{} { data[f] = d.URL case "closed": data[f] = d.Closed + case "state": + if d.Closed { + data[f] = "CLOSED" + } else { + data[f] = "OPEN" + } case "stateReason": data[f] = d.StateReason case "author": @@ -75,10 +82,23 @@ func (d Discussion) ExportData(fields []string) map[string]interface{} { for i, c := range d.Comments.Comments { comments[i] = c.Export() } - data[f] = map[string]interface{}{ + m := map[string]interface{}{ "totalCount": d.Comments.TotalCount, "nodes": comments, } + if d.Comments.Cursor != "" { + m["cursor"] = d.Comments.Cursor + } + if d.Comments.NextCursor != "" { + m["next"] = d.Comments.NextCursor + } + data[f] = m + case "reactionGroups": + reactions := make([]interface{}, len(d.ReactionGroups)) + for i, rg := range d.ReactionGroups { + reactions[i] = rg.Export() + } + data[f] = reactions case "createdAt": data[f] = d.CreatedAt case "updatedAt": @@ -158,14 +178,13 @@ type DiscussionComment struct { IsAnswer bool UpvoteCount int ReactionGroups []ReactionGroup - Replies []DiscussionComment - TotalReplies int + Replies DiscussionCommentList } // Export returns the comment as a map for JSON output. func (c DiscussionComment) Export() map[string]interface{} { - replies := make([]interface{}, len(c.Replies)) - for i, r := range c.Replies { + replies := make([]interface{}, len(c.Replies.Comments)) + for i, r := range c.Replies.Comments { replies[i] = r.Export() } reactions := make([]interface{}, len(c.ReactionGroups)) @@ -181,15 +200,27 @@ func (c DiscussionComment) Export() map[string]interface{} { "isAnswer": c.IsAnswer, "upvoteCount": c.UpvoteCount, "reactionGroups": reactions, - "replies": replies, - "totalReplies": c.TotalReplies, + "replies": map[string]interface{}{ + "totalCount": c.Replies.TotalCount, + "nodes": replies, + }, } } +type DiscussionCommentListDirection string + +const ( + DiscussionCommentListDirectionForward DiscussionCommentListDirection = "forward" + DiscussionCommentListDirectionBackward DiscussionCommentListDirection = "backward" +) + // DiscussionCommentList represents a paginated list of comments on a discussion. type DiscussionCommentList struct { Comments []DiscussionComment TotalCount int + Cursor string + NextCursor string + Direction DiscussionCommentListDirection } // ReactionGroup represents a set of reactions of the same type. diff --git a/pkg/cmd/discussion/discussion.go b/pkg/cmd/discussion/discussion.go index a9bf9b981..a54763895 100644 --- a/pkg/cmd/discussion/discussion.go +++ b/pkg/cmd/discussion/discussion.go @@ -3,6 +3,7 @@ package discussion import ( "github.com/MakeNowJust/heredoc" cmdList "github.com/cli/cli/v2/pkg/cmd/discussion/list" + cmdView "github.com/cli/cli/v2/pkg/cmd/discussion/view" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -36,5 +37,9 @@ func NewCmdDiscussion(f *cmdutil.Factory) *cobra.Command { cmdList.NewCmdList(f, nil), ) + cmdutil.AddGroup(cmd, "Targeted commands", + cmdView.NewCmdView(f, nil), + ) + return cmd } diff --git a/pkg/cmd/discussion/shared/lookup.go b/pkg/cmd/discussion/shared/lookup.go new file mode 100644 index 000000000..e754568f0 --- /dev/null +++ b/pkg/cmd/discussion/shared/lookup.go @@ -0,0 +1,43 @@ +package shared + +import ( + "fmt" + "net/url" + "regexp" + "strconv" + + "github.com/cli/cli/v2/internal/ghrepo" +) + +var discussionURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/discussions/(\d+)$`) + +// ParseDiscussionArg parses a discussion number or URL from a command argument. +// It returns the discussion number and, if the argument was a URL, a repo override. +func ParseDiscussionArg(arg string) (int, ghrepo.Interface, error) { + if num, err := strconv.Atoi(arg); err == nil { + return num, nil, nil + } + + if len(arg) > 1 && arg[0] == '#' { + if num, err := strconv.Atoi(arg[1:]); err == nil { + return num, nil, nil + } + } + + u, err := url.Parse(arg) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return 0, nil, fmt.Errorf("invalid discussion argument: %q", arg) + } + + // Note that an HTTP URL is also okay, because we're just using the URL to find + // the discussion number, repo and host, and we wont be unsecure HTTP API calls. + + m := discussionURLRE.FindStringSubmatch(u.Path) + if m == nil { + return 0, nil, fmt.Errorf("invalid discussion URL: %q", arg) + } + + num, _ := strconv.Atoi(m[3]) + repo := ghrepo.NewWithHost(m[1], m[2], u.Hostname()) + return num, repo, nil +} diff --git a/pkg/cmd/discussion/shared/lookup_test.go b/pkg/cmd/discussion/shared/lookup_test.go new file mode 100644 index 000000000..0670efdd9 --- /dev/null +++ b/pkg/cmd/discussion/shared/lookup_test.go @@ -0,0 +1,119 @@ +package shared + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseDiscussionArg(t *testing.T) { + tests := []struct { + name string + arg string + wantNum int + wantOwner string + wantRepo string + wantHost string + wantErr string + }{ + { + name: "empty", + arg: "", + wantErr: `invalid discussion argument: ""`, + }, + { + name: "whitespaces", + arg: " ", + wantErr: `invalid discussion argument: " "`, + }, + { + name: "invalid string", + arg: "not-a-number", + wantErr: `invalid discussion argument: "not-a-number"`, + }, + { + name: "hash only", + arg: "#", + wantErr: `invalid discussion argument: "#"`, + }, + { + name: "hash non-numeric", + arg: "#abc", + wantErr: `invalid discussion argument: "#abc"`, + }, + { + name: "URL with wrong path", + arg: "https://github.com/owner/repo/issues/10", + wantErr: `invalid discussion URL: "https://github.com/owner/repo/issues/10"`, + }, + { + name: "URL missing number", + arg: "https://github.com/owner/repo/discussions/", + wantErr: `invalid discussion URL: "https://github.com/owner/repo/discussions/"`, + }, + { + name: "zero", + arg: "0", + wantNum: 0, + }, + { + name: "plain number", + arg: "42", + wantNum: 42, + }, + { + name: "hash number", + arg: "#99", + wantNum: 99, + }, + { + name: "HTTPS URL", + arg: "https://github.com/cli/cli/discussions/123", + wantNum: 123, + wantOwner: "cli", + wantRepo: "cli", + wantHost: "github.com", + }, + { + name: "HTTP URL", + arg: "http://github.com/owner/repo/discussions/7", + wantNum: 7, + wantOwner: "owner", + wantRepo: "repo", + wantHost: "github.com", + }, + { + name: "GHES URL", + arg: "https://git.example.com/org/project/discussions/55", + wantNum: 55, + wantOwner: "org", + wantRepo: "project", + wantHost: "git.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + num, repo, err := ParseDiscussionArg(tt.arg) + + if tt.wantErr != "" { + require.Error(t, err) + assert.EqualError(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantNum, num) + + if tt.wantOwner != "" || tt.wantRepo != "" || tt.wantHost != "" { + require.NotNil(t, repo) + assert.Equal(t, tt.wantOwner, repo.RepoOwner()) + assert.Equal(t, tt.wantRepo, repo.RepoName()) + assert.Equal(t, tt.wantHost, repo.RepoHost()) + } else { + assert.Nil(t, repo) + } + }) + } +} diff --git a/pkg/cmd/discussion/view/view.go b/pkg/cmd/discussion/view/view.go new file mode 100644 index 000000000..d20a666d2 --- /dev/null +++ b/pkg/cmd/discussion/view/view.go @@ -0,0 +1,450 @@ +package view + +import ( + "fmt" + "io" + "slices" + "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/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/cli/cli/v2/pkg/markdown" + "github.com/spf13/cobra" +) + +var discussionFields = []string{ + "id", + "number", + "title", + "body", + "url", + "closed", + "state", + "stateReason", + "author", + "category", + "labels", + "answered", + "answerChosenAt", + "answerChosenBy", + "comments", + "reactionGroups", + "createdAt", + "updatedAt", + "closedAt", + "locked", +} + +var reactionEmoji = map[string]string{ + "THUMBS_UP": "\U0001f44d", + "THUMBS_DOWN": "\U0001f44e", + "LAUGH": "\U0001f604", + "HOORAY": "\U0001f389", + "CONFUSED": "\U0001f615", + "HEART": "\u2764\ufe0f", + "ROCKET": "\U0001f680", + "EYES": "\U0001f440", +} + +func reactionGroupList(groups []client.ReactionGroup) string { + var parts []string + for _, g := range groups { + if g.TotalCount == 0 { + continue + } + emoji := reactionEmoji[g.Content] + if emoji == "" { + emoji = g.Content + } + parts = append(parts, fmt.Sprintf("%s %d", emoji, g.TotalCount)) + } + return strings.Join(parts, " • ") +} + +// ViewOptions holds the configuration for the view command. +type ViewOptions struct { + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Browser browser.Browser + Client func() (client.DiscussionClient, error) + + DiscussionNumber int + WebMode bool + Comments bool + Limit int + After string + Order string + Exporter cmdutil.Exporter + Now func() time.Time +} + +// NewCmdView creates the "discussion view" command. +func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { + opts := &ViewOptions{ + IO: f.IOStreams, + Browser: f.Browser, + Now: time.Now, + } + + cmd := &cobra.Command{ + Use: "view { | }", + Short: "View a discussion (preview)", + Long: heredoc.Docf(` + Display the title, body, and other information about a discussion. + + With %[1]s--comments%[1]s flag, show threaded comments on the discussion. + Use %[1]s--order%[1]s to control comment ordering (oldest or newest first). + Use %[1]s--limit%[1]s and %[1]s--after%[1]s for paginating through comments. + + With %[1]s--web%[1]s flag, open the discussion in a web browser instead. + `, "`"), + Example: heredoc.Doc(` + # View a discussion by number + $ gh discussion view 123 + + # View a discussion by URL + $ gh discussion view https://github.com/OWNER/REPO/discussions/123 + + # View with comments + $ gh discussion view 123 --comments + + # View with oldest comments first + $ gh discussion view 123 --comments --order oldest + + # Limit to 10 comments + $ gh discussion view 123 --comments --limit 10 + + # Fetch the next page of comments + $ gh discussion view 123 --comments --after CURSOR + + # Open in browser + $ gh discussion view 123 --web + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + commentsMode := needsComments(opts) + if cmd.Flags().Changed("order") && !commentsMode { + return cmdutil.FlagErrorf("--order requires --comments") + } + if cmd.Flags().Changed("limit") && !commentsMode { + return cmdutil.FlagErrorf("--limit requires --comments") + } + if cmd.Flags().Changed("after") && !commentsMode { + return cmdutil.FlagErrorf("--after requires --comments") + } + if opts.Limit < 1 { + return cmdutil.FlagErrorf("invalid limit: %d", opts.Limit) + } + + number, repo, err := shared.ParseDiscussionArg(args[0]) + if err != nil { + return cmdutil.FlagErrorf("%s", err) + } + + if repo != nil { + opts.BaseRepo = func() (ghrepo.Interface, error) { + return repo, nil + } + } else { + opts.BaseRepo = f.BaseRepo + } + + opts.DiscussionNumber = number + opts.Client = shared.DiscussionClientFunc(f) + + if runF != nil { + return runF(opts) + } + return viewRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open a discussion in the browser") + cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "View discussion comments") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of comments to fetch") + cmd.Flags().StringVar(&opts.After, "after", "", "Cursor for the next page of comments") + cmdutil.StringEnumFlag(cmd, &opts.Order, "order", "", "newest", []string{"oldest", "newest"}, "Order of comments") + cmdutil.AddJSONFlags(cmd, &opts.Exporter, discussionFields) + + return cmd +} + +// exporterNeedsComments returns true when the JSON exporter requests the comments field. +func exporterNeedsComments(exporter cmdutil.Exporter) bool { + return slices.Contains(exporter.Fields(), "comments") +} + +// needsComments returns true when the command should fetch full comment data, +// either because --comments was set or because --json requested the comments field. +func needsComments(opts *ViewOptions) bool { + return opts.Comments || opts.Exporter != nil && exporterNeedsComments(opts.Exporter) +} + +func viewRun(opts *ViewOptions) error { + repo, err := opts.BaseRepo() + if err != nil { + return err + } + + if opts.WebMode { + openURL := ghrepo.GenerateRepoURL(repo, "discussions/%d", opts.DiscussionNumber) + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(openURL)) + } + return opts.Browser.Browse(openURL) + } + + c, err := opts.Client() + if err != nil { + return err + } + + opts.IO.DetectTerminalTheme() + opts.IO.StartProgressIndicator() + + var discussion *client.Discussion + if needsComments(opts) { + discussion, err = c.GetWithComments(repo, opts.DiscussionNumber, opts.Limit, opts.After, opts.Order == "newest") + } else { + discussion, err = c.GetByNumber(repo, opts.DiscussionNumber) + } + + opts.IO.StopProgressIndicator() + + if err != nil { + return err + } + + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, discussion) + } + + if err := opts.IO.StartPager(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) + } + defer opts.IO.StopPager() + + if opts.IO.IsStdoutTTY() { + return printHumanView(opts, discussion) + } + + return printRawView(opts.IO.Out, discussion, opts.Comments) +} + +func printHumanView(opts *ViewOptions, d *client.Discussion) error { + out := opts.IO.Out + cs := opts.IO.ColorScheme() + + numberStr := fmt.Sprintf("#%d", d.Number) + if !d.Closed { + numberStr = cs.Green(numberStr) + } else { + numberStr = cs.Muted(numberStr) + } + fmt.Fprintf(out, "%s %s\n", cs.Bold(d.Title), numberStr) + + state := "Open" + stateColor := cs.Green + if d.Closed { + state = "Closed" + stateColor = cs.Muted + } + + verb := "Started by" + if d.Category.IsAnswerable { + verb = "Asked by" + } + + fmt.Fprintf(out, "%s · %s · %s %s · %s · %s\n", + stateColor(state), + d.Category.Name, + verb, + d.Author.Login, + text.FuzzyAgo(opts.Now(), d.CreatedAt), + text.Pluralize(d.Comments.TotalCount, "comment"), + ) + + if labels := labelList(d.Labels, cs); labels != "" { + fmt.Fprint(out, cs.Bold("Labels: ")) + fmt.Fprintln(out, labels) + } + + var md string + if d.Body == "" { + md = fmt.Sprintf("\n %s\n\n", cs.Muted("No description provided")) + } else { + var err error + md, err = markdown.Render(d.Body, + markdown.WithTheme(opts.IO.TerminalTheme()), + markdown.WithWrap(opts.IO.TerminalWidth())) + if err != nil { + return err + } + } + fmt.Fprintf(out, "\n%s\n", md) + + if reactions := reactionGroupList(d.ReactionGroups); reactions != "" { + fmt.Fprintln(out, reactions) + fmt.Fprintln(out) + } + + // Comments section + if opts.Comments && d.Comments.TotalCount > 0 { + fmt.Fprintln(out, cs.Bold("Comments")) + fmt.Fprintln(out) + + for _, c := range d.Comments.Comments { + if err := printHumanComment(opts, out, c, ""); err != nil { + return err + } + } + + if shown := len(d.Comments.Comments); shown < d.Comments.TotalCount { + remaining := d.Comments.TotalCount - shown + age := "more" + if d.Comments.Direction == client.DiscussionCommentListDirectionForward { + age = "newer" + } else if d.Comments.Direction == client.DiscussionCommentListDirectionBackward { + age = "older" + } + fmt.Fprintf(out, cs.Muted(" And %d %s comments\n"), remaining, age) + fmt.Fprintln(out) + } + + if d.Comments.NextCursor != "" { + fmt.Fprintf(out, cs.Muted("To see more comments, pass: --after %s\n"), d.Comments.NextCursor) + fmt.Fprintln(out) + } + } + + fmt.Fprintf(out, cs.Muted("View this discussion on GitHub: %s\n"), d.URL) + + return nil +} + +func printRawView(out io.Writer, d *client.Discussion, showComments bool) error { + fmt.Fprintf(out, "title:\t%s\n", d.Title) + state := "OPEN" + if d.Closed { + state = "CLOSED" + } + fmt.Fprintf(out, "state:\t%s\n", state) + fmt.Fprintf(out, "category:\t%s\n", d.Category.Name) + fmt.Fprintf(out, "author:\t%s\n", d.Author.Login) + fmt.Fprintf(out, "labels:\t%s\n", labelList(d.Labels, nil)) + fmt.Fprintf(out, "comments:\t%d\n", d.Comments.TotalCount) + if showComments && d.Comments.NextCursor != "" { + fmt.Fprintf(out, "next:\t%s\n", d.Comments.NextCursor) + } + fmt.Fprintf(out, "number:\t%d\n", d.Number) + fmt.Fprintf(out, "url:\t%s\n", d.URL) + fmt.Fprintln(out, "--") + fmt.Fprintln(out, d.Body) + + if showComments { + for _, c := range d.Comments.Comments { + printRawComment(out, c, "") + } + } + + return nil +} + +func printHumanComment(opts *ViewOptions, out io.Writer, c client.DiscussionComment, indent string) error { + cs := opts.IO.ColorScheme() + now := opts.Now() + + header := fmt.Sprintf("%s%s commented %s", + indent, + cs.Bold(c.Author.Login), + text.FuzzyAgo(now, c.CreatedAt), + ) + if c.IsAnswer { + header += " " + cs.Green("✓ Answer") + } + fmt.Fprintln(out, header) + + if c.Body != "" { + md, err := markdown.Render(c.Body, + markdown.WithTheme(opts.IO.TerminalTheme()), + markdown.WithWrap(opts.IO.TerminalWidth())) + if err != nil { + return err + } + if indent != "" { + md = text.Indent(md, indent) + } + fmt.Fprint(out, md) + } + + if reactions := reactionGroupList(c.ReactionGroups); reactions != "" { + fmt.Fprintf(out, "%s%s\n", indent, reactions) + } + + fmt.Fprintln(out) + + for _, reply := range c.Replies.Comments { + if err := printHumanComment(opts, out, reply, indent+" "); err != nil { + return err + } + } + + if shown := len(c.Replies.Comments); shown < c.Replies.TotalCount { + directionLabel := "more" + if c.Replies.Direction == client.DiscussionCommentListDirectionForward { + directionLabel = "newer" + } else if c.Replies.Direction == client.DiscussionCommentListDirectionBackward { + directionLabel = "older" + } + fmt.Fprintf(out, "%s %s\n\n", indent, cs.Muted(fmt.Sprintf("And %d %s replies", c.Replies.TotalCount-shown, directionLabel))) + } + + return nil +} + +func printRawComment(out io.Writer, c client.DiscussionComment, indent string) { + answer := "" + if c.IsAnswer { + answer = "\tanswer" + } + fmt.Fprintf(out, "%scomment:\t%s\t%s\t%s%s\n", indent, c.Author.Login, c.CreatedAt.Format(time.RFC3339), c.URL, answer) + fmt.Fprintf(out, "%s--\n", indent) + if indent != "" { + fmt.Fprint(out, text.Indent(c.Body, indent)) + } else { + fmt.Fprint(out, c.Body) + } + fmt.Fprintln(out) + + for _, reply := range c.Replies.Comments { + printRawComment(out, reply, indent+" ") + } +} + +func labelList(labels []client.DiscussionLabel, cs *iostreams.ColorScheme) string { + if len(labels) == 0 { + return "" + } + + sortedLabels := slices.Clone(labels) + slices.SortStableFunc(sortedLabels, func(i, j client.DiscussionLabel) int { + return strings.Compare(i.Name, j.Name) + }) + + names := make([]string, len(sortedLabels)) + for i, l := range sortedLabels { + if cs == nil { + names[i] = l.Name + } else { + names[i] = cs.Label(l.Color, l.Name) + } + } + return strings.Join(names, ", ") +} diff --git a/pkg/cmd/discussion/view/view_test.go b/pkg/cmd/discussion/view/view_test.go new file mode 100644 index 000000000..1ae739168 --- /dev/null +++ b/pkg/cmd/discussion/view/view_test.go @@ -0,0 +1,862 @@ +package view + +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 testDiscussion() *client.Discussion { + return &client.Discussion{ + ID: "D_123", + Number: 123, + Title: "How to authenticate with SSO?", + Body: "I need help with SSO authentication.", + URL: "https://github.com/OWNER/REPO/discussions/123", + Closed: false, + Author: client.DiscussionActor{Login: "monalisa"}, + Category: client.DiscussionCategory{ + Name: "Q&A", Slug: "q-a", IsAnswerable: true, + }, + Labels: []client.DiscussionLabel{{Name: "help-wanted", Color: "0075ca"}}, + Answered: false, + Comments: client.DiscussionCommentList{TotalCount: 3}, + ReactionGroups: []client.ReactionGroup{ + {Content: "THUMBS_UP", TotalCount: 5}, + {Content: "ROCKET", TotalCount: 2}, + }, + CreatedAt: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + } +} + +func TestNewCmdView(t *testing.T) { + tests := []struct { + name string + args []string + wantNum int + wantErr string + }{ + { + name: "number argument", + args: []string{"123"}, + wantNum: 123, + }, + { + name: "hash number argument", + args: []string{"#456"}, + wantNum: 456, + }, + { + name: "URL argument", + args: []string{"https://github.com/OWNER/REPO/discussions/789"}, + wantNum: 789, + }, + { + name: "invalid argument", + args: []string{"not-a-number"}, + wantErr: "invalid discussion argument", + }, + { + name: "no arguments", + args: []string{}, + wantErr: "accepts 1 arg(s), received 0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + ios, _, _, _ := iostreams.Test() + f.IOStreams = ios + f.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + } + f.Browser = &browser.Stub{} + + var gotOpts *ViewOptions + cmd := NewCmdView(f, func(opts *ViewOptions) error { + gotOpts = opts + return nil + }) + + cmd.SetArgs(tt.args) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantNum, gotOpts.DiscussionNumber) + }) + } +} + +func TestViewRun_tty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + d := testDiscussion() + mock := &client.DiscussionClientMock{ + GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Now: func() time.Time { return time.Date(2025, 3, 1, 1, 0, 0, 0, time.UTC) }, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "How to authenticate with SSO?") + assert.Contains(t, out, "#123") + assert.Contains(t, out, "Q&A") + assert.Contains(t, out, "Asked by") + assert.Contains(t, out, "monalisa") + assert.Contains(t, out, "3 comments") + assert.Contains(t, out, "help-wanted") + assert.Contains(t, out, "View this discussion on GitHub") +} + +func TestViewRun_nontty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussion() + mock := &client.DiscussionClientMock{ + GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "title:\tHow to authenticate with SSO?") + assert.Contains(t, out, "state:\tOPEN") + assert.Contains(t, out, "category:\tQ&A") + assert.Contains(t, out, "author:\tmonalisa") + assert.Contains(t, out, "labels:\thelp-wanted") + assert.Contains(t, out, "number:\t123") + assert.Contains(t, out, "--") + assert.Contains(t, out, "I need help with SSO authentication.") +} + +func TestViewRun_json(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussionWithComments() + mock := &client.DiscussionClientMock{ + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + return d, nil + }, + } + + exporter := cmdutil.NewJSONExporter() + exporter.SetFields(discussionFields) + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Limit: 30, + Order: "newest", + Exporter: exporter, + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, `"title"`) + assert.Contains(t, out, `"number"`) + assert.Contains(t, out, "How to authenticate with SSO?") +} + +func TestViewRun_web(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + b := &browser.Stub{} + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Browser: b, + DiscussionNumber: 123, + WebMode: true, + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + b.Verify(t, "https://github.com/OWNER/REPO/discussions/123") + assert.Contains(t, stderr.String(), "Opening") +} + +func TestViewRun_urlArg(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussion() + d.URL = "https://github.com/OTHER/REPO/discussions/42" + d.Number = 42 + + mock := &client.DiscussionClientMock{ + GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + assert.Equal(t, "OTHER", repo.RepoOwner()) + assert.Equal(t, "REPO", repo.RepoName()) + assert.Equal(t, 42, number) + return d, nil + }, + } + + f := &cmdutil.Factory{} + f.IOStreams = ios + f.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + } + f.Browser = &browser.Stub{} + + var gotOpts *ViewOptions + cmd := NewCmdView(f, func(opts *ViewOptions) error { + gotOpts = opts + opts.Client = func() (client.DiscussionClient, error) { + return mock, nil + } + return viewRun(opts) + }) + + cmd.SetArgs([]string{"https://github.com/OTHER/REPO/discussions/42"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + require.NoError(t, err) + assert.Equal(t, 42, gotOpts.DiscussionNumber) + + out := stdout.String() + assert.Contains(t, out, "number:\t42") +} + +func TestViewRun_answerable(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + d := testDiscussion() + d.Category.IsAnswerable = true + + mock := &client.DiscussionClientMock{ + GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Now: func() time.Time { return time.Date(2025, 3, 1, 1, 0, 0, 0, time.UTC) }, + } + + err := viewRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Asked by") +} + +func TestViewRun_notAnswerable(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + d := testDiscussion() + d.Category.Name = "General" + d.Category.IsAnswerable = false + + mock := &client.DiscussionClientMock{ + GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Now: func() time.Time { return time.Date(2025, 3, 1, 1, 0, 0, 0, time.UTC) }, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "Started by") + assert.NotContains(t, out, "Asked by") +} + +func testDiscussionWithComments() *client.Discussion { + d := testDiscussion() + d.Comments = client.DiscussionCommentList{ + TotalCount: 2, + Comments: []client.DiscussionComment{ + { + ID: "C_1", + URL: "https://github.com/OWNER/REPO/discussions/123#discussioncomment-1", + Author: client.DiscussionActor{Login: "octocat"}, + Body: "This is a comment", + CreatedAt: time.Date(2025, 3, 2, 0, 0, 0, 0, time.UTC), + IsAnswer: true, + ReactionGroups: []client.ReactionGroup{ + {Content: "THUMBS_UP", TotalCount: 3}, + }, + Replies: client.DiscussionCommentList{ + TotalCount: 5, + Comments: []client.DiscussionComment{ + { + ID: "C_1_R1", + URL: "https://github.com/OWNER/REPO/discussions/123#discussioncomment-2", + Author: client.DiscussionActor{Login: "hubot"}, + Body: "Thanks!", + CreatedAt: time.Date(2025, 3, 2, 1, 0, 0, 0, time.UTC), + }, + }, + }, + }, + { + ID: "C_2", + URL: "https://github.com/OWNER/REPO/discussions/123#discussioncomment-3", + Author: client.DiscussionActor{Login: "monalisa"}, + Body: "Another comment", + CreatedAt: time.Date(2025, 3, 3, 0, 0, 0, 0, time.UTC), + }, + }, + } + return d +} + +func TestViewRun_comments_tty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + d := testDiscussionWithComments() + mock := &client.DiscussionClientMock{ + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + assert.Equal(t, 30, commentLimit) + assert.Equal(t, false, newest) + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: true, + Limit: 30, + Order: "oldest", + Now: func() time.Time { return time.Date(2025, 3, 4, 0, 0, 0, 0, time.UTC) }, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "Comments") + assert.Contains(t, out, "octocat") + assert.Contains(t, out, "✓ Answer") + assert.Contains(t, out, "This is a comment") + assert.Contains(t, out, "hubot") + assert.Contains(t, out, "Thanks!") + assert.Contains(t, out, "And 4 more replies") + assert.Contains(t, out, "monalisa") + assert.Contains(t, out, "Another comment") +} + +func TestViewRun_comments_nontty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussionWithComments() + mock := &client.DiscussionClientMock{ + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: true, + Limit: 30, + Order: "oldest", + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "comment:\toctocat\t") + assert.Contains(t, out, "answer") + assert.Contains(t, out, "This is a comment") + assert.Contains(t, out, "comment:\thubot\t") + assert.Contains(t, out, "comment:\tmonalisa\t") +} + +func TestViewRun_comments_json(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussionWithComments() + mock := &client.DiscussionClientMock{ + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + return d, nil + }, + } + + exporter := cmdutil.NewJSONExporter() + exporter.SetFields(discussionFields) + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: true, + Limit: 30, + Order: "oldest", + Exporter: exporter, + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, `"totalCount"`) + assert.Contains(t, out, `"isAnswer":true`) + assert.Contains(t, out, `"octocat"`) +} + +func TestNewCmdView_orderWithoutComments(t *testing.T) { + f := &cmdutil.Factory{} + ios, _, _, _ := iostreams.Test() + f.IOStreams = ios + f.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + } + f.Browser = &browser.Stub{} + + cmd := NewCmdView(f, func(opts *ViewOptions) error { + return nil + }) + + cmd.SetArgs([]string{"123", "--order", "newest"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--order requires --comments") +} + +func TestViewRun_noComments_usesGetByNumber(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussion() + mock := &client.DiscussionClientMock{ + GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: false, + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + assert.Equal(t, 1, len(mock.GetByNumberCalls())) + assert.Equal(t, 0, len(mock.GetWithCommentsCalls())) +} + +func TestNewCmdView_limitWithoutComments(t *testing.T) { + f := &cmdutil.Factory{} + ios, _, _, _ := iostreams.Test() + f.IOStreams = ios + f.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + } + f.Browser = &browser.Stub{} + + cmd := NewCmdView(f, func(opts *ViewOptions) error { + return nil + }) + + cmd.SetArgs([]string{"123", "--limit", "10"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--limit requires --comments") +} + +func TestNewCmdView_afterWithoutComments(t *testing.T) { + f := &cmdutil.Factory{} + ios, _, _, _ := iostreams.Test() + f.IOStreams = ios + f.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + } + f.Browser = &browser.Stub{} + + cmd := NewCmdView(f, func(opts *ViewOptions) error { + return nil + }) + + cmd.SetArgs([]string{"123", "--after", "CURSOR_ABC"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--after requires --comments") +} + +func TestNewCmdView_invalidLimit(t *testing.T) { + f := &cmdutil.Factory{} + ios, _, _, _ := iostreams.Test() + f.IOStreams = ios + f.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + } + f.Browser = &browser.Stub{} + + cmd := NewCmdView(f, func(opts *ViewOptions) error { + return nil + }) + + cmd.SetArgs([]string{"123", "--comments", "--limit", "0"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid limit") +} + +func TestViewRun_commentsWithPagination_tty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + d := testDiscussionWithComments() + d.Comments.NextCursor = "NEXT_CURSOR_123" + + mock := &client.DiscussionClientMock{ + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + assert.Equal(t, 10, commentLimit) + assert.Equal(t, "CURSOR_ABC", after) + assert.Equal(t, false, newest) + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: true, + Limit: 10, + After: "CURSOR_ABC", + Order: "oldest", + Now: func() time.Time { return time.Date(2025, 3, 4, 0, 0, 0, 0, time.UTC) }, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "To see more comments, pass: --after NEXT_CURSOR_123") +} + +func TestViewRun_commentsWithPagination_nontty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussionWithComments() + d.Comments.NextCursor = "NEXT_CURSOR_456" + + mock := &client.DiscussionClientMock{ + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: true, + Limit: 30, + Order: "oldest", + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "next:\tNEXT_CURSOR_456") +} + +func TestViewRun_commentsWithPagination_json(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussionWithComments() + d.Comments.Cursor = "PREV_CURSOR" + d.Comments.NextCursor = "NEXT_CURSOR_789" + + mock := &client.DiscussionClientMock{ + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + return d, nil + }, + } + + exporter := cmdutil.NewJSONExporter() + exporter.SetFields(discussionFields) + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: true, + Limit: 30, + Order: "oldest", + Exporter: exporter, + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, `"cursor":"PREV_CURSOR"`) + assert.Contains(t, out, `"next":"NEXT_CURSOR_789"`) +} + +func TestViewRun_noPaginationCursor_tty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + d := testDiscussionWithComments() + + mock := &client.DiscussionClientMock{ + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: true, + Limit: 30, + Order: "oldest", + Now: func() time.Time { return time.Date(2025, 3, 4, 0, 0, 0, 0, time.UTC) }, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.NotContains(t, out, "--after") +} + +func TestViewRun_jsonComments_usesGetWithComments(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussionWithComments() + mock := &client.DiscussionClientMock{ + GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*client.Discussion, error) { + return d, nil + }, + } + + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"comments"}) + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: false, + Limit: 30, + Order: "newest", + Exporter: exporter, + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + // --json comments should use GetWithComments even without --comments flag + assert.Equal(t, 0, len(mock.GetByNumberCalls())) + assert.Equal(t, 1, len(mock.GetWithCommentsCalls())) + + out := stdout.String() + assert.Contains(t, out, `"totalCount"`) + assert.Contains(t, out, `"octocat"`) +} + +func TestViewRun_jsonWithoutComments_usesGetByNumber(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + d := testDiscussion() + mock := &client.DiscussionClientMock{ + GetByNumberFunc: func(repo ghrepo.Interface, number int) (*client.Discussion, error) { + return d, nil + }, + } + + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"title", "number"}) + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Comments: false, + Exporter: exporter, + Now: time.Now, + } + + err := viewRun(opts) + require.NoError(t, err) + + // --json title,number should NOT fetch comments + assert.Equal(t, 1, len(mock.GetByNumberCalls())) + assert.Equal(t, 0, len(mock.GetWithCommentsCalls())) +}