diff --git a/pkg/cmd/discussion/client/client_impl.go b/pkg/cmd/discussion/client/client_impl.go index d3f8e817b..ed5536fac 100644 --- a/pkg/cmd/discussion/client/client_impl.go +++ b/pkg/cmd/discussion/client/client_impl.go @@ -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) { diff --git a/pkg/cmd/discussion/discussion.go b/pkg/cmd/discussion/discussion.go index a9bf9b981..a54763895 100644 --- a/pkg/cmd/discussion/discussion.go +++ b/pkg/cmd/discussion/discussion.go @@ -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 } diff --git a/pkg/cmd/discussion/shared/display.go b/pkg/cmd/discussion/shared/display.go new file mode 100644 index 000000000..ea866fcb2 --- /dev/null +++ b/pkg/cmd/discussion/shared/display.go @@ -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, " • ") +} diff --git a/pkg/cmd/discussion/shared/lookup.go b/pkg/cmd/discussion/shared/lookup.go new file mode 100644 index 000000000..1196dd477 --- /dev/null +++ b/pkg/cmd/discussion/shared/lookup.go @@ -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 +} diff --git a/pkg/cmd/discussion/view/view.go b/pkg/cmd/discussion/view/view.go new file mode 100644 index 000000000..788b7d766 --- /dev/null +++ b/pkg/cmd/discussion/view/view.go @@ -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 { | }", + 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, ", ") +} diff --git a/pkg/cmd/discussion/view/view_test.go b/pkg/cmd/discussion/view/view_test.go new file mode 100644 index 000000000..9694fcaa8 --- /dev/null +++ b/pkg/cmd/discussion/view/view_test.go @@ -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") +}