Add discussion view --comments with threaded display

Implement the --comments flag for discussion view, showing threaded
comments with replies.

Features:
- --comments flag fetches and displays discussion comments
- --order flag (oldest/newest) controls comment ordering
- Answer badge (✓ Answer) on marked answer comments
- Threaded replies with indentation
- Truncation messages when more replies exist than fetched
- TTY: markdown-rendered comments with author/timestamp/reactions
- Non-TTY: stable tab-delimited format for scripting
- JSON: populated comment nodes via ExportData

Implementation:
- GetWithComments uses raw GraphQL to dynamically switch between
  first/last based on ordering. Fetches 30 comments with 4 replies
  each. Explicitly reverses for newest-first ordering.
- --order without --comments returns a flag error
- Reuses existing shared.ReactionGroupList for reaction display

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Max Beizer 2026-04-17 14:51:44 -05:00 committed by Babak K. Shandiz
parent d6e63f63d3
commit ca84d4c6a3
No known key found for this signature in database
GPG key ID: 9472CAEFF56C742E
3 changed files with 567 additions and 5 deletions

View file

@ -396,8 +396,251 @@ func (c *discussionClient) GetByNumber(repo ghrepo.Interface, number int) (*Disc
return &d, nil
}
func (c *discussionClient) GetWithComments(_ ghrepo.Interface, _ int, _ int, _ string) (*Discussion, error) {
return nil, fmt.Errorf("not implemented")
func (c *discussionClient) GetWithComments(repo ghrepo.Interface, number int, commentLimit int, order string) (*Discussion, error) {
// Build the comments field with first/last based on order.
// "oldest" uses first (chronological), "newest" uses last (reverse chronological).
commentDirection := "first"
if order == "newest" {
commentDirection = "last"
}
query := fmt.Sprintf(`query DiscussionWithComments($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
hasDiscussionsEnabled
discussion(number: $number) {
id
number
title
body
url
closed
stateReason
author { login ... on User { id name } ... on Bot { id } }
category { id name slug emoji isAnswerable }
labels(first: 20) { nodes { id name color } }
isAnswered
answerChosenAt
answerChosenBy { login ... on User { id name } ... on Bot { id } }
reactionGroups { content users { totalCount } }
createdAt
updatedAt
closedAt
locked
comments(%s: %d) {
totalCount
nodes {
id
url
author { login ... on User { id name } ... on Bot { id } }
body
createdAt
isAnswer
upvoteCount
reactionGroups { content users { totalCount } }
replies(first: 4) {
totalCount
nodes {
id
url
author { login ... on User { id name } ... on Bot { id } }
body
createdAt
isAnswer
upvoteCount
reactionGroups { content users { totalCount } }
}
}
}
}
}
}
}`, commentDirection, commentLimit)
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"name": repo.RepoName(),
"number": number,
}
type actorJSON struct {
Login string `json:"login"`
ID string `json:"id"`
Name string `json:"name"`
}
type reactionGroupJSON struct {
Content string `json:"content"`
Users struct {
TotalCount int `json:"totalCount"`
} `json:"users"`
}
type commentJSON struct {
ID string `json:"id"`
URL string `json:"url"`
Author actorJSON `json:"author"`
Body string `json:"body"`
CreatedAt time.Time `json:"createdAt"`
IsAnswer bool `json:"isAnswer"`
UpvoteCount int `json:"upvoteCount"`
ReactionGroups []reactionGroupJSON `json:"reactionGroups"`
Replies *struct {
TotalCount int `json:"totalCount"`
Nodes []commentJSON `json:"nodes"`
} `json:"replies"`
}
type response struct {
Repository struct {
HasDiscussionsEnabled bool `json:"hasDiscussionsEnabled"`
Discussion *struct {
ID string `json:"id"`
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
Closed bool `json:"closed"`
StateReason string `json:"stateReason"`
Author actorJSON `json:"author"`
Category struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Emoji string `json:"emoji"`
IsAnswerable bool `json:"isAnswerable"`
} `json:"category"`
Labels struct {
Nodes []struct {
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
} `json:"nodes"`
} `json:"labels"`
IsAnswered bool `json:"isAnswered"`
AnswerChosenAt time.Time `json:"answerChosenAt"`
AnswerChosenBy *actorJSON `json:"answerChosenBy"`
ReactionGroups []reactionGroupJSON `json:"reactionGroups"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ClosedAt time.Time `json:"closedAt"`
Locked bool `json:"locked"`
Comments struct {
TotalCount int `json:"totalCount"`
Nodes []commentJSON `json:"nodes"`
} `json:"comments"`
} `json:"discussion"`
} `json:"repository"`
}
var data response
err := c.gql.GraphQL(repo.RepoHost(), query, variables, &data)
if err != nil {
return nil, err
}
if !data.Repository.HasDiscussionsEnabled {
return nil, fmt.Errorf("the '%s/%s' repository has discussions disabled", repo.RepoOwner(), repo.RepoName())
}
if data.Repository.Discussion == nil {
return nil, fmt.Errorf("discussion #%d not found in '%s/%s'", number, repo.RepoOwner(), repo.RepoName())
}
src := data.Repository.Discussion
mapActor := func(a actorJSON) DiscussionActor {
return DiscussionActor{ID: a.ID, Login: a.Login, Name: a.Name}
}
mapReactions := func(groups []reactionGroupJSON) []ReactionGroup {
out := make([]ReactionGroup, len(groups))
for i, rg := range groups {
out[i] = ReactionGroup{Content: rg.Content, TotalCount: rg.Users.TotalCount}
}
return out
}
mapComment := func(c commentJSON) DiscussionComment {
dc := DiscussionComment{
ID: c.ID,
URL: c.URL,
Author: mapActor(c.Author),
Body: c.Body,
CreatedAt: c.CreatedAt,
IsAnswer: c.IsAnswer,
UpvoteCount: c.UpvoteCount,
ReactionGroups: mapReactions(c.ReactionGroups),
}
if c.Replies != nil {
dc.TotalReplies = c.Replies.TotalCount
for _, r := range c.Replies.Nodes {
dc.Replies = append(dc.Replies, DiscussionComment{
ID: r.ID,
URL: r.URL,
Author: mapActor(r.Author),
Body: r.Body,
CreatedAt: r.CreatedAt,
IsAnswer: r.IsAnswer,
UpvoteCount: r.UpvoteCount,
ReactionGroups: mapReactions(r.ReactionGroups),
})
}
}
return dc
}
d := Discussion{
ID: src.ID,
Number: src.Number,
Title: src.Title,
Body: src.Body,
URL: src.URL,
Closed: src.Closed,
StateReason: src.StateReason,
Author: mapActor(src.Author),
Category: DiscussionCategory{
ID: src.Category.ID,
Name: src.Category.Name,
Slug: src.Category.Slug,
Emoji: src.Category.Emoji,
IsAnswerable: src.Category.IsAnswerable,
},
Answered: src.IsAnswered,
AnswerChosenAt: src.AnswerChosenAt,
ReactionGroups: mapReactions(src.ReactionGroups),
CreatedAt: src.CreatedAt,
UpdatedAt: src.UpdatedAt,
ClosedAt: src.ClosedAt,
Locked: src.Locked,
}
if src.AnswerChosenBy != nil {
a := mapActor(*src.AnswerChosenBy)
d.AnswerChosenBy = &a
}
d.Labels = make([]DiscussionLabel, len(src.Labels.Nodes))
for i, l := range src.Labels.Nodes {
d.Labels[i] = DiscussionLabel{ID: l.ID, Name: l.Name, Color: l.Color}
}
comments := make([]DiscussionComment, len(src.Comments.Nodes))
for i, c := range src.Comments.Nodes {
comments[i] = mapComment(c)
}
// When using "last" (newest order), the API returns items in chronological
// order. Reverse them so the newest comment appears first.
if order == "newest" {
for i, j := 0, len(comments)-1; i < j; i, j = i+1, j-1 {
comments[i], comments[j] = comments[j], comments[i]
}
}
d.Comments = DiscussionCommentList{
Comments: comments,
TotalCount: src.Comments.TotalCount,
}
return &d, nil
}
func (c *discussionClient) ListCategories(repo ghrepo.Interface) ([]DiscussionCategory, error) {

View file

@ -28,6 +28,8 @@ type ViewOptions struct {
DiscussionNumber int
WebMode bool
Comments bool
Order string
Exporter cmdutil.Exporter
Now func() time.Time
}
@ -46,6 +48,9 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
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).
With %[1]s--web%[1]s flag, open the discussion in a web browser instead.
`, "`"),
Example: heredoc.Doc(`
@ -55,11 +60,21 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
# 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 newest comments first
$ gh discussion view 123 --comments --order newest
# Open in browser
$ gh discussion view 123 --web
`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("order") && !opts.Comments {
return cmdutil.FlagErrorf("--order requires --comments")
}
number, repo, err := shared.ParseDiscussionArg(args[0])
if err != nil {
return err
@ -84,6 +99,8 @@ 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")
cmdutil.StringEnumFlag(cmd, &opts.Order, "order", "", "oldest", []string{"oldest", "newest"}, "Order of comments")
cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.DiscussionFields)
return cmd
@ -111,7 +128,12 @@ func viewRun(opts *ViewOptions) error {
opts.IO.DetectTerminalTheme()
opts.IO.StartProgressIndicator()
discussion, err := c.GetByNumber(repo, opts.DiscussionNumber)
var discussion *client.Discussion
if opts.Comments {
discussion, err = c.GetWithComments(repo, opts.DiscussionNumber, 30, opts.Order)
} else {
discussion, err = c.GetByNumber(repo, opts.DiscussionNumber)
}
opts.IO.StopProgressIndicator()
@ -132,7 +154,7 @@ func viewRun(opts *ViewOptions) error {
return printHumanView(opts, discussion)
}
return printRawView(opts.IO.Out, discussion)
return printRawView(opts.IO.Out, discussion, opts.Comments)
}
func printHumanView(opts *ViewOptions, d *client.Discussion) error {
@ -192,12 +214,29 @@ func printHumanView(opts *ViewOptions, d *client.Discussion) error {
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 {
fmt.Fprintf(out, cs.Muted(" And %d more comments\n"), d.Comments.TotalCount-shown)
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) error {
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 {
@ -212,9 +251,81 @@ func printRawView(out io.Writer, d *client.Discussion) error {
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 := shared.ReactionGroupList(c.ReactionGroups); reactions != "" {
fmt.Fprintf(out, "%s%s\n", indent, reactions)
}
fmt.Fprintln(out)
for _, reply := range c.Replies {
if err := printHumanComment(opts, out, reply, indent+" "); err != nil {
return err
}
}
if shown := len(c.Replies); shown < c.TotalReplies {
fmt.Fprintf(out, "%s %s\n\n", indent, cs.Muted(fmt.Sprintf("And %d more replies", c.TotalReplies-shown)))
}
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 {
printRawComment(out, reply, indent+" ")
}
}
func labelList(labels []client.DiscussionLabel, cs *iostreams.ColorScheme) string {
if len(labels) == 0 {
return ""

View file

@ -351,3 +351,211 @@ func TestViewRun_notAnswerable(t *testing.T) {
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.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),
},
},
TotalReplies: 5,
},
{
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, order string) (*client.Discussion, error) {
assert.Equal(t, 30, commentLimit)
assert.Equal(t, "oldest", order)
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,
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, order string) (*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,
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, order string) (*client.Discussion, error) {
return d, nil
},
}
exporter := cmdutil.NewJSONExporter()
exporter.SetFields(shared.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,
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()))
}