diff --git a/pkg/cmd/discussion/client/client.go b/pkg/cmd/discussion/client/client.go index 5c7f1bdc4..7a774deeb 100644 --- a/pkg/cmd/discussion/client/client.go +++ b/pkg/cmd/discussion/client/client.go @@ -13,6 +13,7 @@ type DiscussionClient interface { 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, after string, newest bool) (*Discussion, error) + GetCommentReplies(repo ghrepo.Interface, number int, commentID string, limit 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 b7fdf36b2..88aa7f170 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -392,6 +392,43 @@ func (c *discussionClient) GetByNumber(repo ghrepo.Interface, number int) (*Disc return &d, nil } +// discussionReplyNode is the GraphQL response shape for a reply to a discussion comment. +type discussionReplyNode 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 + } + } +} + +// mapReplyFromNode converts a discussionReplyNode into the domain DiscussionComment type. +func mapReplyFromNode(n discussionReplyNode) DiscussionComment { + rc := 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 { + rc.ReactionGroups = append(rc.ReactionGroups, ReactionGroup{ + Content: rg.Content, + TotalCount: rg.Users.TotalCount, + }) + } + return rc +} + // discussionCommentNode is the GraphQL response shape for a discussion comment // including nested replies. type discussionCommentNode struct { @@ -410,21 +447,7 @@ type discussionCommentNode struct { } 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 - } - } - } + Nodes []discussionReplyNode } `graphql:"replies(last: 4)"` } @@ -449,22 +472,7 @@ func mapCommentFromNode(n discussionCommentNode) DiscussionComment { 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 + replyComments[i] = mapReplyFromNode(r) } dc.Replies = DiscussionCommentList{ Comments: replyComments, @@ -574,6 +582,156 @@ func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, li return &d, nil } +// GetCommentReplies fetches a discussion and a single comment with its +// paginated replies. It uses the top-level node(id:) query for the comment +// because the Discussion type does not expose a comment(id:) field. +func (c *discussionClient) GetCommentReplies(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*Discussion, error) { + var query struct { + Repository struct { + HasDiscussionsEnabled bool + Discussion struct { + discussionListNode + } `graphql:"discussion(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + Node *struct { + DiscussionComment 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 + PageInfo struct { + EndCursor string + HasNextPage bool + StartCursor string + HasPreviousPage bool + } + Nodes []discussionReplyNode + } `graphql:"replies(first: $first, last: $last, after: $after, before: $before)"` + } `graphql:"... on DiscussionComment"` + } `graphql:"node(id: $commentID)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "number": githubv4.Int(number), + "commentID": githubv4.ID(commentID), + "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(), "DiscussionCommentReplies", &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()) + } + + // The query above should already error for an invalid node ID, but guard against nil. + if query.Node == nil { + return nil, fmt.Errorf("comment %s not found", commentID) + } + + src := query.Node.DiscussionComment + if src.ID == "" { + return nil, fmt.Errorf("node %s is not a discussion comment", commentID) + } + + d := mapDiscussionFromListNode(query.Repository.Discussion.discussionListNode) + + for _, rg := range query.Repository.Discussion.ReactionGroups { + d.ReactionGroups = append(d.ReactionGroups, ReactionGroup{ + Content: rg.Content, + TotalCount: rg.Users.TotalCount, + }) + } + + dc := DiscussionComment{ + ID: src.ID, + URL: src.URL, + Author: mapActorFromListNode(src.Author), + Body: src.Body, + CreatedAt: src.CreatedAt, + IsAnswer: src.IsAnswer, + UpvoteCount: src.UpvoteCount, + } + + for _, rg := range src.ReactionGroups { + dc.ReactionGroups = append(dc.ReactionGroups, ReactionGroup{ + Content: rg.Content, + TotalCount: rg.Users.TotalCount, + }) + } + + replies := make([]DiscussionComment, len(src.Replies.Nodes)) + for i, r := range src.Replies.Nodes { + replies[i] = mapReplyFromNode(r) + } + + // When using "last" (newest order), the API returns items in chronological + // order. Reverse them so the newest reply appears first. + if newest { + slices.Reverse(replies) + } + + nextCursor := "" + if newest { + if src.Replies.PageInfo.HasPreviousPage { + nextCursor = src.Replies.PageInfo.StartCursor + } + } else { + if src.Replies.PageInfo.HasNextPage { + nextCursor = src.Replies.PageInfo.EndCursor + } + } + + direction := DiscussionCommentListDirectionForward + if newest { + direction = DiscussionCommentListDirectionBackward + } + + dc.Replies = DiscussionCommentList{ + Comments: replies, + TotalCount: src.Replies.TotalCount, + Cursor: after, + NextCursor: nextCursor, + Direction: direction, + } + + d.Comments = DiscussionCommentList{ + Comments: []DiscussionComment{dc}, + TotalCount: 1, + } + + return &d, nil +} + func (c *discussionClient) ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error) { var query struct { Repository struct { diff --git a/pkg/cmd/discussion/client/client_mock.go b/pkg/cmd/discussion/client/client_mock.go index a3cec3c63..eb71f20e8 100644 --- a/pkg/cmd/discussion/client/client_mock.go +++ b/pkg/cmd/discussion/client/client_mock.go @@ -30,6 +30,9 @@ var _ DiscussionClient = &DiscussionClientMock{} // GetByNumberFunc: func(repo ghrepo.Interface, number int) (*Discussion, error) { // panic("mock out the GetByNumber method") // }, +// GetCommentRepliesFunc: func(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*Discussion, error) { +// panic("mock out the GetCommentReplies method") +// }, // GetWithCommentsFunc: func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*Discussion, error) { // panic("mock out the GetWithComments method") // }, @@ -79,6 +82,9 @@ type DiscussionClientMock struct { // GetByNumberFunc mocks the GetByNumber method. GetByNumberFunc func(repo ghrepo.Interface, number int) (*Discussion, error) + // GetCommentRepliesFunc mocks the GetCommentReplies method. + GetCommentRepliesFunc func(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*Discussion, error) + // GetWithCommentsFunc mocks the GetWithComments method. GetWithCommentsFunc func(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*Discussion, error) @@ -145,6 +151,21 @@ type DiscussionClientMock struct { // Number is the number argument value. Number int } + // GetCommentReplies holds details about calls to the GetCommentReplies method. + GetCommentReplies []struct { + // Repo is the repo argument value. + Repo ghrepo.Interface + // Number is the number argument value. + Number int + // CommentID is the commentID argument value. + CommentID string + // Limit is the limit argument value. + Limit int + // After is the after argument value. + After string + // Newest is the newest argument value. + Newest bool + } // GetWithComments holds details about calls to the GetWithComments method. GetWithComments []struct { // Repo is the repo argument value. @@ -230,20 +251,21 @@ type DiscussionClientMock struct { Input UpdateDiscussionInput } } - lockAddComment sync.RWMutex - lockClose sync.RWMutex - lockCreate sync.RWMutex - lockGetByNumber sync.RWMutex - lockGetWithComments sync.RWMutex - lockList sync.RWMutex - lockListCategories sync.RWMutex - lockLock sync.RWMutex - lockMarkAnswer sync.RWMutex - lockReopen sync.RWMutex - lockSearch sync.RWMutex - lockUnlock sync.RWMutex - lockUnmarkAnswer sync.RWMutex - lockUpdate sync.RWMutex + lockAddComment sync.RWMutex + lockClose sync.RWMutex + lockCreate sync.RWMutex + lockGetByNumber sync.RWMutex + lockGetCommentReplies sync.RWMutex + lockGetWithComments sync.RWMutex + lockList sync.RWMutex + lockListCategories sync.RWMutex + lockLock sync.RWMutex + lockMarkAnswer sync.RWMutex + lockReopen sync.RWMutex + lockSearch sync.RWMutex + lockUnlock sync.RWMutex + lockUnmarkAnswer sync.RWMutex + lockUpdate sync.RWMutex } // AddComment calls AddCommentFunc. @@ -402,6 +424,58 @@ func (mock *DiscussionClientMock) GetByNumberCalls() []struct { return calls } +// GetCommentReplies calls GetCommentRepliesFunc. +func (mock *DiscussionClientMock) GetCommentReplies(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*Discussion, error) { + if mock.GetCommentRepliesFunc == nil { + panic("DiscussionClientMock.GetCommentRepliesFunc: method is nil but DiscussionClient.GetCommentReplies was just called") + } + callInfo := struct { + Repo ghrepo.Interface + Number int + CommentID string + Limit int + After string + Newest bool + }{ + Repo: repo, + Number: number, + CommentID: commentID, + Limit: limit, + After: after, + Newest: newest, + } + mock.lockGetCommentReplies.Lock() + mock.calls.GetCommentReplies = append(mock.calls.GetCommentReplies, callInfo) + mock.lockGetCommentReplies.Unlock() + return mock.GetCommentRepliesFunc(repo, number, commentID, limit, after, newest) +} + +// GetCommentRepliesCalls gets all the calls that were made to GetCommentReplies. +// Check the length with: +// +// len(mockedDiscussionClient.GetCommentRepliesCalls()) +func (mock *DiscussionClientMock) GetCommentRepliesCalls() []struct { + Repo ghrepo.Interface + Number int + CommentID string + Limit int + After string + Newest bool +} { + var calls []struct { + Repo ghrepo.Interface + Number int + CommentID string + Limit int + After string + Newest bool + } + mock.lockGetCommentReplies.RLock() + calls = mock.calls.GetCommentReplies + mock.lockGetCommentReplies.RUnlock() + return calls +} + // GetWithComments calls GetWithCommentsFunc. func (mock *DiscussionClientMock) GetWithComments(repo ghrepo.Interface, number int, commentLimit int, after string, newest bool) (*Discussion, error) { if mock.GetWithCommentsFunc == nil { diff --git a/pkg/cmd/discussion/client/types.go b/pkg/cmd/discussion/client/types.go index a5affcb3a..1eaab5329 100644 --- a/pkg/cmd/discussion/client/types.go +++ b/pkg/cmd/discussion/client/types.go @@ -185,8 +185,37 @@ type DiscussionComment struct { func (c DiscussionComment) Export() map[string]interface{} { replies := make([]interface{}, len(c.Replies.Comments)) for i, r := range c.Replies.Comments { - replies[i] = r.Export() + replies[i] = r.ExportReply() } + reactions := make([]interface{}, len(c.ReactionGroups)) + for i, rg := range c.ReactionGroups { + reactions[i] = rg.Export() + } + repliesMap := map[string]interface{}{ + "totalCount": c.Replies.TotalCount, + "nodes": replies, + } + if c.Replies.Cursor != "" { + repliesMap["cursor"] = c.Replies.Cursor + } + if c.Replies.NextCursor != "" { + repliesMap["next"] = c.Replies.NextCursor + } + return map[string]interface{}{ + "id": c.ID, + "url": c.URL, + "author": c.Author.Export(), + "body": c.Body, + "createdAt": c.CreatedAt, + "isAnswer": c.IsAnswer, + "upvoteCount": c.UpvoteCount, + "reactionGroups": reactions, + "replies": repliesMap, + } +} + +// ExportReply returns a reply as a map for JSON output, without nested replies. +func (c DiscussionComment) ExportReply() map[string]interface{} { reactions := make([]interface{}, len(c.ReactionGroups)) for i, rg := range c.ReactionGroups { reactions[i] = rg.Export() @@ -200,10 +229,6 @@ func (c DiscussionComment) Export() map[string]interface{} { "isAnswer": c.IsAnswer, "upvoteCount": c.UpvoteCount, "reactionGroups": reactions, - "replies": map[string]interface{}{ - "totalCount": c.Replies.TotalCount, - "nodes": replies, - }, } } diff --git a/pkg/cmd/discussion/view/view.go b/pkg/cmd/discussion/view/view.go index d20a666d2..98b1d608d 100644 --- a/pkg/cmd/discussion/view/view.go +++ b/pkg/cmd/discussion/view/view.go @@ -78,6 +78,7 @@ type ViewOptions struct { DiscussionNumber int WebMode bool Comments bool + Replies string Limit int After string Order string @@ -103,6 +104,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman 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--replies%[1]s flag, show paginated replies on a specific comment. + Pass the comment node ID (e.g. %[1]sDC_abc123%[1]s) to fetch its replies. + Use %[1]s--limit%[1]s, %[1]s--after%[1]s, and %[1]s--order%[1]s to control reply pagination. + With %[1]s--web%[1]s flag, open the discussion in a web browser instead. `, "`"), Example: heredoc.Doc(` @@ -124,20 +129,34 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman # Fetch the next page of comments $ gh discussion view 123 --comments --after CURSOR + # View replies on a specific comment + $ gh discussion view 123 --replies COMMENT-ID + + # Paginate through replies + $ gh discussion view 123 --replies COMMENT-ID --limit 10 --after CURSOR + # Open in browser $ gh discussion view 123 --web `), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.MutuallyExclusive("specify only one of --comments, --replies, or --web", + opts.Comments, opts.Replies != "", opts.WebMode); err != nil { + return err + } + + repliesMode := opts.Replies != "" commentsMode := needsComments(opts) - if cmd.Flags().Changed("order") && !commentsMode { - return cmdutil.FlagErrorf("--order requires --comments") + + paginatedMode := commentsMode || repliesMode + if cmd.Flags().Changed("order") && !paginatedMode { + return cmdutil.FlagErrorf("--order requires --comments or --replies") } - if cmd.Flags().Changed("limit") && !commentsMode { - return cmdutil.FlagErrorf("--limit requires --comments") + if cmd.Flags().Changed("limit") && !paginatedMode { + return cmdutil.FlagErrorf("--limit requires --comments or --replies") } - if cmd.Flags().Changed("after") && !commentsMode { - return cmdutil.FlagErrorf("--after requires --comments") + if cmd.Flags().Changed("after") && !paginatedMode { + return cmdutil.FlagErrorf("--after requires --comments or --replies") } if opts.Limit < 1 { return cmdutil.FlagErrorf("invalid limit: %d", opts.Limit) @@ -168,9 +187,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman 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") + cmd.Flags().StringVar(&opts.Replies, "replies", "", "View replies on a specific comment by its node ID") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of comments or replies to fetch") + cmd.Flags().StringVar(&opts.After, "after", "", "Cursor for the next page") + cmdutil.StringEnumFlag(cmd, &opts.Order, "order", "", "newest", []string{"oldest", "newest"}, "Order of comments or replies") cmdutil.AddJSONFlags(cmd, &opts.Exporter, discussionFields) return cmd @@ -209,6 +229,32 @@ func viewRun(opts *ViewOptions) error { opts.IO.DetectTerminalTheme() opts.IO.StartProgressIndicator() + if opts.Replies != "" { + discussion, err := c.GetCommentReplies(repo, opts.DiscussionNumber, opts.Replies, opts.Limit, opts.After, opts.Order == "newest") + 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 len(discussion.Comments.Comments) == 0 { + return fmt.Errorf("no comment found for reply ID %s", opts.Replies) + } + comment := discussion.Comments.Comments[0] + if opts.IO.IsStdoutTTY() { + return printHumanReplies(opts, &comment) + } + return printRawReplies(opts.IO.Out, &comment) + } + var discussion *client.Discussion if needsComments(opts) { discussion, err = c.GetWithComments(repo, opts.DiscussionNumber, opts.Limit, opts.After, opts.Order == "newest") @@ -448,3 +494,39 @@ func labelList(labels []client.DiscussionLabel, cs *iostreams.ColorScheme) strin } return strings.Join(names, ", ") } + +func printHumanReplies(opts *ViewOptions, c *client.DiscussionComment) error { + out := opts.IO.Out + cs := opts.IO.ColorScheme() + + if err := printHumanComment(opts, out, *c, ""); err != nil { + return err + } + + if c.Replies.NextCursor != "" { + fmt.Fprintf(out, cs.Muted("To see more replies, pass: --after %s\n"), c.Replies.NextCursor) + fmt.Fprintln(out) + } + + return nil +} + +func printRawReplies(out io.Writer, c *client.DiscussionComment) error { + answer := "" + if c.IsAnswer { + answer = "\tanswer" + } + fmt.Fprintf(out, "comment:\t%s\t%s\t%s%s\n", c.Author.Login, c.CreatedAt.Format(time.RFC3339), c.URL, answer) + fmt.Fprintf(out, "replies:\t%d\n", c.Replies.TotalCount) + if c.Replies.NextCursor != "" { + fmt.Fprintf(out, "next:\t%s\n", c.Replies.NextCursor) + } + fmt.Fprintln(out, "--") + fmt.Fprintln(out, c.Body) + + for _, reply := range c.Replies.Comments { + printRawComment(out, reply, " ") + } + + return nil +}