Add discussion view command

Implement `gh discussion view` for viewing a single discussion with:

- Number or URL argument via shared.ParseDiscussionArg
- TTY output: title, metadata (state, category, author, age, comment
  count), labels, markdown-rendered body, reactions
- Context-aware author attribution: "Asked by" for answerable
  categories (Q&A), "Started by" for others
- Non-TTY output: key-value pairs matching `gh issue view` format
- JSON output via Exporter (Discussion.ExportData)
- --web flag to open in browser
- Pager support for TTY output

Also adds:
- GetByNumber client method with not-found detection
- shared.ParseDiscussionArg for number/URL/#number parsing
- shared.ReactionGroupList for emoji reaction display

Comment threading (--comments) is deferred to the next PR.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Max Beizer 2026-04-03 13:16:12 -05:00 committed by Babak K. Shandiz
parent d7276c7ef9
commit d9e9751823
No known key found for this signature in database
GPG key ID: 9472CAEFF56C742E
6 changed files with 712 additions and 2 deletions

View file

@ -351,8 +351,53 @@ 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) {
query := fmt.Sprintf(`query DiscussionByNumber($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
hasDiscussionsEnabled
discussion(number: $number) {
%s
body
comments { totalCount }
}
}
}`, discussionFields)
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"name": repo.RepoName(),
"number": number,
}
type response struct {
Repository struct {
HasDiscussionsEnabled bool `json:"hasDiscussionsEnabled"`
Discussion *struct {
discussionNode
Body string `json:"body"`
Comments struct {
TotalCount int `json:"totalCount"`
} `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())
}
d := mapDiscussion(data.Repository.Discussion.discussionNode)
d.Body = data.Repository.Discussion.Body
d.Comments = DiscussionCommentList{TotalCount: data.Repository.Discussion.Comments.TotalCount}
return &d, nil
}
func (c *discussionClient) GetWithComments(_ ghrepo.Interface, _ int, _ int, _ string) (*Discussion, error) {

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,35 @@
package shared
import (
"fmt"
"strings"
"github.com/cli/cli/v2/pkg/cmd/discussion/client"
)
var reactionEmoji = map[string]string{
"THUMBS_UP": "\U0001f44d",
"THUMBS_DOWN": "\U0001f44e",
"LAUGH": "\U0001f604",
"HOORAY": "\U0001f389",
"CONFUSED": "\U0001f615",
"HEART": "\u2764\ufe0f",
"ROCKET": "\U0001f680",
"EYES": "\U0001f440",
}
// ReactionGroupList formats reaction groups for display.
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, " • ")
}

View file

@ -0,0 +1,40 @@
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: %s", arg)
}
m := discussionURLRE.FindStringSubmatch(u.Path)
if m == nil {
return 0, nil, fmt.Errorf("invalid discussion URL: %s", arg)
}
num, _ := strconv.Atoi(m[3])
repo := ghrepo.NewWithHost(m[1], m[2], u.Hostname())
return num, repo, nil
}

View file

@ -0,0 +1,232 @@
package view
import (
"fmt"
"io"
"sort"
"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"
)
// 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
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",
Long: heredoc.Docf(`
Display the title, body, and other information about a discussion.
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
# Open in browser
$ gh discussion view 123 --web
`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
number, repo, err := shared.ParseDiscussionArg(args[0])
if err != nil {
return 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")
cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.DiscussionFields)
return cmd
}
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()
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)
}
func printHumanView(opts *ViewOptions, d *client.Discussion) error {
out := opts.IO.Out
cs := opts.IO.ColorScheme()
numberStr := fmt.Sprintf("#%d", d.Number)
if d.State == "OPEN" {
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.State != "OPEN" {
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 := shared.ReactionGroupList(d.ReactionGroups); reactions != "" {
fmt.Fprintln(out, reactions)
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 {
fmt.Fprintf(out, "title:\t%s\n", d.Title)
fmt.Fprintf(out, "state:\t%s\n", d.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)
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)
return nil
}
func labelList(labels []client.DiscussionLabel, cs *iostreams.ColorScheme) string {
if len(labels) == 0 {
return ""
}
sort.SliceStable(labels, func(i, j int) bool {
return strings.ToLower(labels[i].Name) < strings.ToLower(labels[j].Name)
})
names := make([]string, len(labels))
for i, l := range labels {
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,353 @@
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/cmd/discussion/shared"
"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",
State: "OPEN",
Author: client.DiscussionAuthor{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 := testDiscussion()
mock := &client.DiscussionClientMock{
GetByNumberFunc: func(repo ghrepo.Interface, number int) (*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,
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")
}