Merge pull request #13214 from maxbeizer/discussion-view-comments

Add `discussion view --comments` with threaded display
This commit is contained in:
Babak K. Shandiz 2026-04-27 09:23:13 +01:00 committed by GitHub
commit 9baeaf2043
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1753 additions and 22 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {<number> | <url>}",
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, ", ")
}

View file

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