Merge pull request #13295 from cli/babakks/discussion-view-replies

feat(discussion view): add `--replies COMMENT-ID` experience
This commit is contained in:
Babak K. Shandiz 2026-04-27 19:10:30 +01:00 committed by GitHub
commit 99a099928e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 399 additions and 59 deletions

View file

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

View file

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

View file

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

View file

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

View file

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