feat: add gh discussion create command
Implements the 'gh discussion create' CLI command, wiring up the already-merged Create client method. Supports: - --title/-t, --body/-b, --category/-c, --label/-l flags - Interactive prompting when TTY and required flags are missing - Non-TTY mode requiring all flags - TTY and non-TTY output formats Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
41f74c4571
commit
e471f3f8f1
3 changed files with 501 additions and 0 deletions
161
pkg/cmd/discussion/create/create.go
Normal file
161
pkg/cmd/discussion/create/create.go
Normal file
|
|
@ -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
|
||||
}
|
||||
338
pkg/cmd/discussion/create/create_test.go
Normal file
338
pkg/cmd/discussion/create/create_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue