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:
Max Beizer 2026-05-06 14:52:07 -05:00
parent 41f74c4571
commit e471f3f8f1
No known key found for this signature in database
3 changed files with 501 additions and 0 deletions

View 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
}

View 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")
}

View file

@ -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),
)