diff --git a/pkg/cmd/discussion/create/create.go b/pkg/cmd/discussion/create/create.go new file mode 100644 index 000000000..8504c2c4c --- /dev/null +++ b/pkg/cmd/discussion/create/create.go @@ -0,0 +1,161 @@ +package create + +import ( + "fmt" + "net/http" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "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/spf13/cobra" +) + +// CreateOptions holds the configuration for the discussion create command. +type CreateOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + BaseRepo func() (ghrepo.Interface, error) + Client func() (client.DiscussionClient, error) + Prompter prompter.Prompter + + Title string + Body string + Category string + Labels []string +} + +// NewCmdCreate returns a cobra command for creating a GitHub Discussion. +func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { + opts := &CreateOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Prompter: f.Prompter, + Client: shared.DiscussionClientFunc(f), + } + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new discussion", + Long: heredoc.Doc(` + Create a new GitHub Discussion in a repository. + + With '--title' and '--category', a discussion is created non-interactively. + Omitting either flag triggers interactive prompts when connected to a terminal. + + The '--body' flag provides the discussion body. Without it you will be + prompted to enter one in your default editor. + `), + Example: heredoc.Doc(` + # Create interactively + $ gh discussion create + + # Create non-interactively + $ gh discussion create --title "My question" --category "Q&A" --body "Details here" + `), + Args: cmdutil.NoArgsQuoteReminder, + RunE: func(cmd *cobra.Command, args []string) error { + opts.BaseRepo = f.BaseRepo + if runF != nil { + return runF(opts) + } + return createRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Title for the discussion") + cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Body for the discussion") + cmd.Flags().StringVarP(&opts.Category, "category", "c", "", "Category name or slug for the discussion") + cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Labels to apply to the discussion") + + return cmd +} + +func createRun(opts *CreateOptions) error { + repo, err := opts.BaseRepo() + if err != nil { + return err + } + + c, err := opts.Client() + if err != nil { + return err + } + + categories, err := c.ListCategories(repo) + if err != nil { + return fmt.Errorf("fetching categories: %w", err) + } + + interactive := opts.IO.CanPrompt() + + if opts.Title == "" { + if !interactive { + return cmdutil.FlagErrorf("--title required when not running interactively") + } + opts.Title, err = opts.Prompter.Input("Discussion title", "") + if err != nil { + return err + } + } + if strings.TrimSpace(opts.Title) == "" { + return cmdutil.FlagErrorf("title cannot be blank") + } + + var category *client.DiscussionCategory + if opts.Category != "" { + category, err = shared.MatchCategory(opts.Category, categories) + if err != nil { + return err + } + } else { + if !interactive { + return cmdutil.FlagErrorf("--category required when not running interactively") + } + names := make([]string, len(categories)) + for i, cat := range categories { + names[i] = cat.Name + } + idx, err := opts.Prompter.Select("Discussion category", "", names) + if err != nil { + return err + } + category = &categories[idx] + } + + if opts.Body == "" { + if !interactive { + return cmdutil.FlagErrorf("--body required when not running interactively") + } + opts.Body, err = opts.Prompter.MarkdownEditor("Discussion body", "", true) + if err != nil { + return err + } + } + + input := client.CreateDiscussionInput{ + CategoryID: category.ID, + Title: opts.Title, + Body: opts.Body, + Labels: opts.Labels, + } + + discussion, err := c.Create(repo, input) + if err != nil { + return fmt.Errorf("creating discussion: %w", err) + } + + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "%s Created discussion #%d: %s\n", + cs.SuccessIcon(), discussion.Number, discussion.URL) + } else { + fmt.Fprintln(opts.IO.Out, discussion.URL) + } + + return nil +} diff --git a/pkg/cmd/discussion/create/create_test.go b/pkg/cmd/discussion/create/create_test.go new file mode 100644 index 000000000..6b5ba12c1 --- /dev/null +++ b/pkg/cmd/discussion/create/create_test.go @@ -0,0 +1,338 @@ +package create + +import ( + "bytes" + "fmt" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmd/discussion/client" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sampleCategories() []client.DiscussionCategory { + return []client.DiscussionCategory{ + {ID: "CAT1", Name: "General", Slug: "general"}, + {ID: "CAT2", Name: "Q&A", Slug: "q-a"}, + {ID: "CAT3", Name: "Show and tell", Slug: "show-and-tell"}, + } +} + +func sampleDiscussion() *client.Discussion { + return &client.Discussion{ + Number: 5, + Title: "My question", + URL: "https://github.com/OWNER/REPO/discussions/5", + } +} + +func TestNewCmdCreate(t *testing.T) { + tests := []struct { + name string + args string + wantOpts CreateOptions + wantErr string + }{ + { + name: "no flags", + args: "", + wantOpts: CreateOptions{}, + }, + { + name: "title flag", + args: "--title 'My question'", + wantOpts: CreateOptions{ + Title: "My question", + }, + }, + { + name: "all flags", + args: "--title 'My question' --body 'Details' --category 'Q&A' --label bug", + wantOpts: CreateOptions{ + Title: "My question", + Body: "Details", + Category: "Q&A", + Labels: []string{"bug"}, + }, + }, + { + name: "extra args", + args: "extra", + wantErr: "unknown argument", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + var capturedOpts *CreateOptions + cmd := NewCmdCreate(f, func(opts *CreateOptions) error { + capturedOpts = opts + return nil + }) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantOpts.Title, capturedOpts.Title) + assert.Equal(t, tt.wantOpts.Body, capturedOpts.Body) + assert.Equal(t, tt.wantOpts.Category, capturedOpts.Category) + assert.Equal(t, tt.wantOpts.Labels, capturedOpts.Labels) + }) + } +} + +func TestCreateRun_nonInteractive(t *testing.T) { + tests := []struct { + name string + opts CreateOptions + wantErr string + wantOut string + setupMock func(*client.DiscussionClientMock) + }{ + { + name: "creates discussion successfully", + opts: CreateOptions{ + Title: "My question", + Body: "Details", + Category: "Q&A", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + m.CreateFunc = func(repo ghrepo.Interface, input client.CreateDiscussionInput) (*client.Discussion, error) { + assert.Equal(t, "CAT2", input.CategoryID) + assert.Equal(t, "My question", input.Title) + assert.Equal(t, "Details", input.Body) + return sampleDiscussion(), nil + } + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "creates with label", + opts: CreateOptions{ + Title: "Feature request", + Body: "Details", + Category: "general", + Labels: []string{"enhancement"}, + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + m.CreateFunc = func(repo ghrepo.Interface, input client.CreateDiscussionInput) (*client.Discussion, error) { + assert.Equal(t, []string{"enhancement"}, input.Labels) + return sampleDiscussion(), nil + } + }, + wantOut: "https://github.com/OWNER/REPO/discussions/5\n", + }, + { + name: "missing title returns error", + opts: CreateOptions{ + Body: "Details", + Category: "General", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + }, + wantErr: "--title required when not running interactively", + }, + { + name: "missing category returns error", + opts: CreateOptions{ + Title: "My question", + Body: "Details", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + }, + wantErr: "--category required when not running interactively", + }, + { + name: "missing body returns error", + opts: CreateOptions{ + Title: "My question", + Category: "General", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + }, + wantErr: "--body required when not running interactively", + }, + { + name: "unknown category returns error", + opts: CreateOptions{ + Title: "My question", + Body: "Details", + Category: "nonexistent", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + }, + wantErr: `unknown category: "nonexistent"`, + }, + { + name: "ListCategories error propagates", + opts: CreateOptions{ + Title: "My question", + Body: "Details", + Category: "General", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return nil, fmt.Errorf("network error") + } + }, + wantErr: "fetching categories: network error", + }, + { + name: "Create error propagates", + opts: CreateOptions{ + Title: "My question", + Body: "Details", + Category: "General", + }, + setupMock: func(m *client.DiscussionClientMock) { + m.ListCategoriesFunc = func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + } + m.CreateFunc = func(repo ghrepo.Interface, input client.CreateDiscussionInput) (*client.Discussion, error) { + return nil, fmt.Errorf("mutation failed") + } + }, + wantErr: "creating discussion: mutation failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + // non-interactive: no TTY + + mockClient := &client.DiscussionClientMock{} + tt.setupMock(mockClient) + + opts := tt.opts + opts.IO = ios + opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } + opts.Client = func() (client.DiscussionClient, error) { return mockClient, nil } + + err := createRun(&opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantOut, stdout.String()) + }) + } +} + +func TestCreateRun_tty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + + mockClient := &client.DiscussionClientMock{ + ListCategoriesFunc: func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + }, + CreateFunc: func(repo ghrepo.Interface, input client.CreateDiscussionInput) (*client.Discussion, error) { + assert.Equal(t, "My question", input.Title) + assert.Equal(t, "CAT1", input.CategoryID) + assert.Equal(t, "Some body text", input.Body) + return sampleDiscussion(), nil + }, + } + + pm := &prompter.PrompterMock{ + InputFunc: func(prompt, defaultValue string) (string, error) { + return "My question", nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + assert.Equal(t, []string{"General", "Q&A", "Show and tell"}, options) + return 0, nil + }, + MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { + return "Some body text", nil + }, + } + + opts := &CreateOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + Prompter: pm, + } + + err := createRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Created discussion #5") + assert.Contains(t, stdout.String(), "https://github.com/OWNER/REPO/discussions/5") +} + +func TestCreateRun_tty_partialFlags(t *testing.T) { + // Title and body provided, category via prompt + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + + mockClient := &client.DiscussionClientMock{ + ListCategoriesFunc: func(repo ghrepo.Interface) ([]client.DiscussionCategory, error) { + return sampleCategories(), nil + }, + CreateFunc: func(repo ghrepo.Interface, input client.CreateDiscussionInput) (*client.Discussion, error) { + assert.Equal(t, "Pre-filled title", input.Title) + assert.Equal(t, "CAT2", input.CategoryID) + assert.Equal(t, "Pre-filled body", input.Body) + return sampleDiscussion(), nil + }, + } + + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 1, nil // select Q&A + }, + } + + opts := &CreateOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + Client: func() (client.DiscussionClient, error) { return mockClient, nil }, + Prompter: pm, + Title: "Pre-filled title", + Body: "Pre-filled body", + } + + err := createRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Created discussion #5") +} diff --git a/pkg/cmd/discussion/discussion.go b/pkg/cmd/discussion/discussion.go index a54763895..7e8d1f252 100644 --- a/pkg/cmd/discussion/discussion.go +++ b/pkg/cmd/discussion/discussion.go @@ -2,6 +2,7 @@ package discussion import ( "github.com/MakeNowJust/heredoc" + cmdCreate "github.com/cli/cli/v2/pkg/cmd/discussion/create" 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" @@ -34,6 +35,7 @@ func NewCmdDiscussion(f *cmdutil.Factory) *cobra.Command { cmdutil.EnableRepoOverride(cmd, f) cmdutil.AddGroup(cmd, "General commands", + cmdCreate.NewCmdCreate(f, nil), cmdList.NewCmdList(f, nil), )