Merge pull request #7193 from notomo/add-issue-create-editor
Add `issue create --editor`
This commit is contained in:
commit
9b7ee3acef
13 changed files with 461 additions and 42 deletions
|
|
@ -15,18 +15,19 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
aliasesKey = "aliases"
|
||||
browserKey = "browser"
|
||||
editorKey = "editor"
|
||||
gitProtocolKey = "git_protocol"
|
||||
hostsKey = "hosts"
|
||||
httpUnixSocketKey = "http_unix_socket"
|
||||
oauthTokenKey = "oauth_token"
|
||||
pagerKey = "pager"
|
||||
promptKey = "prompt"
|
||||
userKey = "user"
|
||||
usersKey = "users"
|
||||
versionKey = "version"
|
||||
aliasesKey = "aliases"
|
||||
browserKey = "browser"
|
||||
editorKey = "editor"
|
||||
gitProtocolKey = "git_protocol"
|
||||
hostsKey = "hosts"
|
||||
httpUnixSocketKey = "http_unix_socket"
|
||||
oauthTokenKey = "oauth_token"
|
||||
pagerKey = "pager"
|
||||
promptKey = "prompt"
|
||||
preferEditorPromptKey = "prefer_editor_prompt"
|
||||
userKey = "user"
|
||||
usersKey = "users"
|
||||
versionKey = "version"
|
||||
)
|
||||
|
||||
func NewConfig() (gh.Config, error) {
|
||||
|
|
@ -137,6 +138,11 @@ func (c *cfg) Prompt(hostname string) gh.ConfigEntry {
|
|||
return c.GetOrDefault(hostname, promptKey).Unwrap()
|
||||
}
|
||||
|
||||
func (c *cfg) PreferEditorPrompt(hostname string) gh.ConfigEntry {
|
||||
// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
|
||||
return c.GetOrDefault(hostname, preferEditorPromptKey).Unwrap()
|
||||
}
|
||||
|
||||
func (c *cfg) Version() o.Option[string] {
|
||||
return c.get("", versionKey)
|
||||
}
|
||||
|
|
@ -509,6 +515,8 @@ git_protocol: https
|
|||
editor:
|
||||
# When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
|
||||
prompt: enabled
|
||||
# Preference for editor-based interactive prompting. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
|
||||
prefer_editor_prompt: disabled
|
||||
# A pager program to send command output to, e.g. "less". If blank, will refer to environment. Set the value to "cat" to disable the pager.
|
||||
pager:
|
||||
# Aliases allow you to create nicknames for gh commands
|
||||
|
|
@ -555,6 +563,15 @@ var Options = []ConfigOption{
|
|||
return c.Prompt(hostname).Value
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: preferEditorPromptKey,
|
||||
Description: "toggle preference for editor-based interactive prompting in the terminal",
|
||||
DefaultValue: "disabled",
|
||||
AllowedValues: []string{"enabled", "disabled"},
|
||||
CurrentValue: func(c gh.Config, hostname string) string {
|
||||
return c.PreferEditorPrompt(hostname).Value
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: pagerKey,
|
||||
Description: "the terminal pager program to send standard output to",
|
||||
|
|
|
|||
|
|
@ -70,6 +70,9 @@ func NewFromString(cfgStr string) *ghmock.ConfigMock {
|
|||
mock.PromptFunc = func(hostname string) gh.ConfigEntry {
|
||||
return cfg.Prompt(hostname)
|
||||
}
|
||||
mock.PreferEditorPromptFunc = func(hostname string) gh.ConfigEntry {
|
||||
return cfg.PreferEditorPrompt(hostname)
|
||||
}
|
||||
mock.VersionFunc = func() o.Option[string] {
|
||||
return cfg.Version()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ type Config interface {
|
|||
Pager(hostname string) ConfigEntry
|
||||
// Prompt returns the configured prompt, optionally scoped by host.
|
||||
Prompt(hostname string) ConfigEntry
|
||||
// PreferEditorPrompt returns the configured editor-based prompt, optionally scoped by host.
|
||||
PreferEditorPrompt(hostname string) ConfigEntry
|
||||
|
||||
// Aliases provides persistent storage and modification of command aliases.
|
||||
Aliases() AliasConfig
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ var _ gh.Config = &ConfigMock{}
|
|||
// PagerFunc: func(hostname string) gh.ConfigEntry {
|
||||
// panic("mock out the Pager method")
|
||||
// },
|
||||
// PreferEditorPromptFunc: func(hostname string) gh.ConfigEntry {
|
||||
// panic("mock out the PreferEditorPrompt method")
|
||||
// },
|
||||
// PromptFunc: func(hostname string) gh.ConfigEntry {
|
||||
// panic("mock out the Prompt method")
|
||||
// },
|
||||
|
|
@ -98,6 +101,9 @@ type ConfigMock struct {
|
|||
// PagerFunc mocks the Pager method.
|
||||
PagerFunc func(hostname string) gh.ConfigEntry
|
||||
|
||||
// PreferEditorPromptFunc mocks the PreferEditorPrompt method.
|
||||
PreferEditorPromptFunc func(hostname string) gh.ConfigEntry
|
||||
|
||||
// PromptFunc mocks the Prompt method.
|
||||
PromptFunc func(hostname string) gh.ConfigEntry
|
||||
|
||||
|
|
@ -158,6 +164,11 @@ type ConfigMock struct {
|
|||
// Hostname is the hostname argument value.
|
||||
Hostname string
|
||||
}
|
||||
// PreferEditorPrompt holds details about calls to the PreferEditorPrompt method.
|
||||
PreferEditorPrompt []struct {
|
||||
// Hostname is the hostname argument value.
|
||||
Hostname string
|
||||
}
|
||||
// Prompt holds details about calls to the Prompt method.
|
||||
Prompt []struct {
|
||||
// Hostname is the hostname argument value.
|
||||
|
|
@ -179,20 +190,21 @@ type ConfigMock struct {
|
|||
Write []struct {
|
||||
}
|
||||
}
|
||||
lockAliases sync.RWMutex
|
||||
lockAuthentication sync.RWMutex
|
||||
lockBrowser sync.RWMutex
|
||||
lockCacheDir sync.RWMutex
|
||||
lockEditor sync.RWMutex
|
||||
lockGetOrDefault sync.RWMutex
|
||||
lockGitProtocol sync.RWMutex
|
||||
lockHTTPUnixSocket sync.RWMutex
|
||||
lockMigrate sync.RWMutex
|
||||
lockPager sync.RWMutex
|
||||
lockPrompt sync.RWMutex
|
||||
lockSet sync.RWMutex
|
||||
lockVersion sync.RWMutex
|
||||
lockWrite sync.RWMutex
|
||||
lockAliases sync.RWMutex
|
||||
lockAuthentication sync.RWMutex
|
||||
lockBrowser sync.RWMutex
|
||||
lockCacheDir sync.RWMutex
|
||||
lockEditor sync.RWMutex
|
||||
lockGetOrDefault sync.RWMutex
|
||||
lockGitProtocol sync.RWMutex
|
||||
lockHTTPUnixSocket sync.RWMutex
|
||||
lockMigrate sync.RWMutex
|
||||
lockPager sync.RWMutex
|
||||
lockPreferEditorPrompt sync.RWMutex
|
||||
lockPrompt sync.RWMutex
|
||||
lockSet sync.RWMutex
|
||||
lockVersion sync.RWMutex
|
||||
lockWrite sync.RWMutex
|
||||
}
|
||||
|
||||
// Aliases calls AliasesFunc.
|
||||
|
|
@ -504,6 +516,38 @@ func (mock *ConfigMock) PagerCalls() []struct {
|
|||
return calls
|
||||
}
|
||||
|
||||
// PreferEditorPrompt calls PreferEditorPromptFunc.
|
||||
func (mock *ConfigMock) PreferEditorPrompt(hostname string) gh.ConfigEntry {
|
||||
if mock.PreferEditorPromptFunc == nil {
|
||||
panic("ConfigMock.PreferEditorPromptFunc: method is nil but Config.PreferEditorPrompt was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
Hostname string
|
||||
}{
|
||||
Hostname: hostname,
|
||||
}
|
||||
mock.lockPreferEditorPrompt.Lock()
|
||||
mock.calls.PreferEditorPrompt = append(mock.calls.PreferEditorPrompt, callInfo)
|
||||
mock.lockPreferEditorPrompt.Unlock()
|
||||
return mock.PreferEditorPromptFunc(hostname)
|
||||
}
|
||||
|
||||
// PreferEditorPromptCalls gets all the calls that were made to PreferEditorPrompt.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.PreferEditorPromptCalls())
|
||||
func (mock *ConfigMock) PreferEditorPromptCalls() []struct {
|
||||
Hostname string
|
||||
} {
|
||||
var calls []struct {
|
||||
Hostname string
|
||||
}
|
||||
mock.lockPreferEditorPrompt.RLock()
|
||||
calls = mock.calls.PreferEditorPrompt
|
||||
mock.lockPreferEditorPrompt.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Prompt calls PromptFunc.
|
||||
func (mock *ConfigMock) Prompt(hostname string) gh.ConfigEntry {
|
||||
if mock.PromptFunc == nil {
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ func Test_listRun(t *testing.T) {
|
|||
cfg.Set("HOST", "git_protocol", "ssh")
|
||||
cfg.Set("HOST", "editor", "/usr/bin/vim")
|
||||
cfg.Set("HOST", "prompt", "disabled")
|
||||
cfg.Set("HOST", "prefer_editor_prompt", "enabled")
|
||||
cfg.Set("HOST", "pager", "less")
|
||||
cfg.Set("HOST", "http_unix_socket", "")
|
||||
cfg.Set("HOST", "browser", "brave")
|
||||
|
|
@ -93,6 +94,7 @@ func Test_listRun(t *testing.T) {
|
|||
stdout: `git_protocol=ssh
|
||||
editor=/usr/bin/vim
|
||||
prompt=disabled
|
||||
prefer_editor_prompt=enabled
|
||||
pager=less
|
||||
http_unix_socket=
|
||||
browser=brave
|
||||
|
|
|
|||
|
|
@ -18,16 +18,18 @@ import (
|
|||
)
|
||||
|
||||
type CreateOptions struct {
|
||||
HttpClient func() (*http.Client, error)
|
||||
Config func() (gh.Config, error)
|
||||
IO *iostreams.IOStreams
|
||||
BaseRepo func() (ghrepo.Interface, error)
|
||||
Browser browser.Browser
|
||||
Prompter prShared.Prompt
|
||||
HttpClient func() (*http.Client, error)
|
||||
Config func() (gh.Config, error)
|
||||
IO *iostreams.IOStreams
|
||||
BaseRepo func() (ghrepo.Interface, error)
|
||||
Browser browser.Browser
|
||||
Prompter prShared.Prompt
|
||||
TitledEditSurvey func(string, string) (string, string, error)
|
||||
|
||||
RootDirOverride string
|
||||
|
||||
HasRepoOverride bool
|
||||
EditorMode bool
|
||||
WebMode bool
|
||||
RecoverFile string
|
||||
|
||||
|
|
@ -44,11 +46,12 @@ type CreateOptions struct {
|
|||
|
||||
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
|
||||
opts := &CreateOptions{
|
||||
IO: f.IOStreams,
|
||||
HttpClient: f.HttpClient,
|
||||
Config: f.Config,
|
||||
Browser: f.Browser,
|
||||
Prompter: f.Prompter,
|
||||
IO: f.IOStreams,
|
||||
HttpClient: f.HttpClient,
|
||||
Config: f.Config,
|
||||
Browser: f.Browser,
|
||||
Prompter: f.Prompter,
|
||||
TitledEditSurvey: prShared.TitledEditSurvey(&prShared.UserEditor{Config: f.Config, IO: f.IOStreams}),
|
||||
}
|
||||
|
||||
var bodyFile string
|
||||
|
|
@ -77,6 +80,20 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
|||
opts.BaseRepo = f.BaseRepo
|
||||
opts.HasRepoOverride = cmd.Flags().Changed("repo")
|
||||
|
||||
if err := cmdutil.MutuallyExclusive(
|
||||
"specify only one of `--editor` or `--web`",
|
||||
opts.EditorMode,
|
||||
opts.WebMode,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.EditorMode = !opts.WebMode && (opts.EditorMode || config.PreferEditorPrompt("").Value == "enabled")
|
||||
|
||||
titleProvided := cmd.Flags().Changed("title")
|
||||
bodyProvided := cmd.Flags().Changed("body")
|
||||
if bodyFile != "" {
|
||||
|
|
@ -96,11 +113,14 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
|||
return errors.New("`--template` is not supported when using `--body` or `--body-file`")
|
||||
}
|
||||
|
||||
opts.Interactive = !(titleProvided && bodyProvided)
|
||||
opts.Interactive = !opts.EditorMode && !(titleProvided && bodyProvided)
|
||||
|
||||
if opts.Interactive && !opts.IO.CanPrompt() {
|
||||
return cmdutil.FlagErrorf("must provide `--title` and `--body` when not running interactively")
|
||||
}
|
||||
if opts.EditorMode && !opts.IO.CanPrompt() {
|
||||
return errors.New("--editor or enabled prefer_editor_prompt configuration are not supported in non-tty mode")
|
||||
}
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
|
|
@ -112,6 +132,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
|||
cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Supply a title. Will prompt for one otherwise.")
|
||||
cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.")
|
||||
cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)")
|
||||
cmd.Flags().BoolVarP(&opts.EditorMode, "editor", "e", false, "Skip prompts and open the text editor to write the title and body in. The first line is the title and the rest text is the body.")
|
||||
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the browser to create an issue")
|
||||
cmd.Flags().StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`. Use \"@me\" to self-assign.")
|
||||
cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`")
|
||||
|
|
@ -285,6 +306,25 @@ func createRun(opts *CreateOptions) (err error) {
|
|||
return
|
||||
}
|
||||
} else {
|
||||
if opts.EditorMode {
|
||||
if opts.Template != "" {
|
||||
var template prShared.Template
|
||||
template, err = tpl.Select(opts.Template)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if tb.Title == "" {
|
||||
tb.Title = template.Title()
|
||||
}
|
||||
templateNameForSubmit = template.NameForSubmit()
|
||||
tb.Body = string(template.Body())
|
||||
}
|
||||
|
||||
tb.Title, tb.Body, err = opts.TitledEditSurvey(tb.Title, tb.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if tb.Title == "" {
|
||||
err = fmt.Errorf("title can't be blank")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ func TestNewCmdCreate(t *testing.T) {
|
|||
tty bool
|
||||
stdin string
|
||||
cli string
|
||||
config string
|
||||
wantsErr bool
|
||||
wantsOpts CreateOptions
|
||||
}{
|
||||
|
|
@ -125,6 +126,77 @@ func TestNewCmdCreate(t *testing.T) {
|
|||
cli: `-t mytitle --template "bug report" --body-file "body_file.md"`,
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "editor by cli",
|
||||
tty: true,
|
||||
cli: "--editor",
|
||||
wantsErr: false,
|
||||
wantsOpts: CreateOptions{
|
||||
Title: "",
|
||||
Body: "",
|
||||
RecoverFile: "",
|
||||
WebMode: false,
|
||||
EditorMode: true,
|
||||
Interactive: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "editor by config",
|
||||
tty: true,
|
||||
cli: "",
|
||||
config: "prefer_editor_prompt: enabled",
|
||||
wantsErr: false,
|
||||
wantsOpts: CreateOptions{
|
||||
Title: "",
|
||||
Body: "",
|
||||
RecoverFile: "",
|
||||
WebMode: false,
|
||||
EditorMode: true,
|
||||
Interactive: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "editor and template",
|
||||
tty: true,
|
||||
cli: `--editor --template "bug report"`,
|
||||
wantsErr: false,
|
||||
wantsOpts: CreateOptions{
|
||||
Title: "",
|
||||
Body: "",
|
||||
RecoverFile: "",
|
||||
WebMode: false,
|
||||
EditorMode: true,
|
||||
Template: "bug report",
|
||||
Interactive: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "editor and web",
|
||||
tty: true,
|
||||
cli: "--editor --web",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "can use web even though editor is enabled by config",
|
||||
tty: true,
|
||||
cli: `--web --title mytitle --body "issue body"`,
|
||||
config: "prefer_editor_prompt: enabled",
|
||||
wantsErr: false,
|
||||
wantsOpts: CreateOptions{
|
||||
Title: "mytitle",
|
||||
Body: "issue body",
|
||||
RecoverFile: "",
|
||||
WebMode: true,
|
||||
EditorMode: false,
|
||||
Interactive: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "editor with non-tty",
|
||||
tty: false,
|
||||
cli: "--editor",
|
||||
wantsErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -138,6 +210,12 @@ func TestNewCmdCreate(t *testing.T) {
|
|||
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: ios,
|
||||
Config: func() (gh.Config, error) {
|
||||
if tt.config != "" {
|
||||
return config.NewFromString(tt.config), nil
|
||||
}
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
}
|
||||
|
||||
var opts *CreateOptions
|
||||
|
|
@ -310,6 +388,72 @@ func Test_createRun(t *testing.T) {
|
|||
},
|
||||
wantsErr: "cannot open in browser: maximum URL length exceeded",
|
||||
},
|
||||
{
|
||||
name: "editor",
|
||||
httpStubs: func(r *httpmock.Registry) {
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query RepositoryInfo\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": {
|
||||
"id": "REPOID",
|
||||
"hasIssuesEnabled": true
|
||||
} } }`))
|
||||
r.Register(
|
||||
httpmock.GraphQL(`mutation IssueCreate\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "createIssue": { "issue": {
|
||||
"URL": "https://github.com/OWNER/REPO/issues/12"
|
||||
} } } }
|
||||
`, func(inputs map[string]interface{}) {
|
||||
assert.Equal(t, "title", inputs["title"])
|
||||
assert.Equal(t, "body", inputs["body"])
|
||||
}))
|
||||
},
|
||||
opts: CreateOptions{
|
||||
EditorMode: true,
|
||||
TitledEditSurvey: func(string, string) (string, string, error) { return "title", "body", nil },
|
||||
},
|
||||
wantsStdout: "https://github.com/OWNER/REPO/issues/12\n",
|
||||
wantsStderr: "\nCreating issue in OWNER/REPO\n\n",
|
||||
},
|
||||
{
|
||||
name: "editor and template",
|
||||
httpStubs: func(r *httpmock.Registry) {
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query RepositoryInfo\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": {
|
||||
"id": "REPOID",
|
||||
"hasIssuesEnabled": true
|
||||
} } }`))
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query IssueTemplates\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "issueTemplates": [
|
||||
{ "name": "Bug report",
|
||||
"title": "bug: ",
|
||||
"body": "Does not work :((" }
|
||||
] } } }`),
|
||||
)
|
||||
r.Register(
|
||||
httpmock.GraphQL(`mutation IssueCreate\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "createIssue": { "issue": {
|
||||
"URL": "https://github.com/OWNER/REPO/issues/12"
|
||||
} } } }
|
||||
`, func(inputs map[string]interface{}) {
|
||||
assert.Equal(t, "bug: ", inputs["title"])
|
||||
assert.Equal(t, "Does not work :((", inputs["body"])
|
||||
}))
|
||||
},
|
||||
opts: CreateOptions{
|
||||
EditorMode: true,
|
||||
Template: "Bug report",
|
||||
TitledEditSurvey: func(title string, body string) (string, string, error) { return title, body, nil },
|
||||
},
|
||||
wantsStdout: "https://github.com/OWNER/REPO/issues/12\n",
|
||||
wantsStderr: "\nCreating issue in OWNER/REPO\n\n",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/pkg/surveyext"
|
||||
)
|
||||
|
||||
type Action int
|
||||
|
|
@ -317,3 +320,40 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Editor interface {
|
||||
Edit(filename, initialValue string) (string, error)
|
||||
}
|
||||
|
||||
type UserEditor struct {
|
||||
IO *iostreams.IOStreams
|
||||
Config func() (gh.Config, error)
|
||||
}
|
||||
|
||||
func (e *UserEditor) Edit(filename, initialValue string) (string, error) {
|
||||
editorCommand, err := cmdutil.DetermineEditor(e.Config)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return surveyext.Edit(editorCommand, filename, initialValue, e.IO.In, e.IO.Out, e.IO.ErrOut)
|
||||
}
|
||||
|
||||
const editorHintMarker = "------------------------ >8 ------------------------"
|
||||
const editorHint = `
|
||||
Please Enter the title on the first line and the body on subsequent lines.
|
||||
Lines below dotted lines will be ignored, and an empty title aborts the creation process.`
|
||||
|
||||
func TitledEditSurvey(editor Editor) func(string, string) (string, string, error) {
|
||||
return func(initialTitle, initialBody string) (string, string, error) {
|
||||
initialValue := strings.Join([]string{initialTitle, initialBody, editorHintMarker, editorHint}, "\n")
|
||||
titleAndBody, err := editor.Edit("*.md", initialValue)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
titleAndBody = strings.ReplaceAll(titleAndBody, "\r\n", "\n")
|
||||
titleAndBody, _, _ = strings.Cut(titleAndBody, editorHintMarker)
|
||||
title, body, _ := strings.Cut(titleAndBody, "\n")
|
||||
return title, strings.TrimSuffix(body, "\n"), nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,3 +123,38 @@ func TestMetadataSurvey_keepExisting(t *testing.T) {
|
|||
assert.Equal(t, []string{"good first issue"}, state.Labels)
|
||||
assert.Equal(t, []string{"The road to 1.0"}, state.Projects)
|
||||
}
|
||||
|
||||
func TestTitledEditSurvey_cleanupHint(t *testing.T) {
|
||||
var editorInitialText string
|
||||
editor := &testEditor{
|
||||
edit: func(s string) (string, error) {
|
||||
editorInitialText = s
|
||||
return `editedTitle
|
||||
editedBody
|
||||
------------------------ >8 ------------------------
|
||||
|
||||
Please Enter the title on the first line and the body on subsequent lines.
|
||||
Lines below dotted lines will be ignored, and an empty title aborts the creation process.`, nil
|
||||
},
|
||||
}
|
||||
|
||||
title, body, err := TitledEditSurvey(editor)("initialTitle", "initialBody")
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, `initialTitle
|
||||
initialBody
|
||||
------------------------ >8 ------------------------
|
||||
|
||||
Please Enter the title on the first line and the body on subsequent lines.
|
||||
Lines below dotted lines will be ignored, and an empty title aborts the creation process.`, editorInitialText)
|
||||
assert.Equal(t, "editedTitle", title)
|
||||
assert.Equal(t, "editedBody", body)
|
||||
}
|
||||
|
||||
type testEditor struct {
|
||||
edit func(string) (string, error)
|
||||
}
|
||||
|
||||
func (e testEditor) Edit(filename, text string) (string, error) {
|
||||
return e.edit(text)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@ import (
|
|||
)
|
||||
|
||||
type issueTemplate struct {
|
||||
Gname string `graphql:"name"`
|
||||
Gbody string `graphql:"body"`
|
||||
Gname string `graphql:"name"`
|
||||
Gbody string `graphql:"body"`
|
||||
Gtitle string `graphql:"title"`
|
||||
}
|
||||
|
||||
type pullRequestTemplate struct {
|
||||
|
|
@ -37,6 +38,10 @@ func (t *issueTemplate) Body() []byte {
|
|||
return []byte(t.Gbody)
|
||||
}
|
||||
|
||||
func (t *issueTemplate) Title() string {
|
||||
return t.Gtitle
|
||||
}
|
||||
|
||||
func (t *pullRequestTemplate) Name() string {
|
||||
return t.Gname
|
||||
}
|
||||
|
|
@ -49,6 +54,10 @@ func (t *pullRequestTemplate) Body() []byte {
|
|||
return []byte(t.Gbody)
|
||||
}
|
||||
|
||||
func (t *pullRequestTemplate) Title() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func listIssueTemplates(httpClient *http.Client, repo ghrepo.Interface) ([]Template, error) {
|
||||
var query struct {
|
||||
Repository struct {
|
||||
|
|
@ -109,6 +118,7 @@ type Template interface {
|
|||
Name() string
|
||||
NameForSubmit() string
|
||||
Body() []byte
|
||||
Title() string
|
||||
}
|
||||
|
||||
type iprompter interface {
|
||||
|
|
@ -294,3 +304,7 @@ func (t *filesystemTemplate) NameForSubmit() string {
|
|||
func (t *filesystemTemplate) Body() []byte {
|
||||
return githubtemplate.ExtractContents(t.path)
|
||||
}
|
||||
|
||||
func (t *filesystemTemplate) Title() string {
|
||||
return githubtemplate.ExtractTitle(t.path)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ func TestTemplateManager_hasAPI(t *testing.T) {
|
|||
httpmock.GraphQL(`query IssueTemplates\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{
|
||||
"issueTemplates": [
|
||||
{"name": "Bug report", "body": "I found a problem"},
|
||||
{"name": "Feature request", "body": "I need a feature"}
|
||||
{"name": "Bug report", "body": "I found a problem", "title": "bug: "},
|
||||
{"name": "Feature request", "body": "I need a feature", "title": "request: "}
|
||||
]
|
||||
}}}`))
|
||||
|
||||
|
|
@ -62,6 +62,7 @@ func TestTemplateManager_hasAPI(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Feature request", tpl.NameForSubmit())
|
||||
assert.Equal(t, "I need a feature", string(tpl.Body()))
|
||||
assert.Equal(t, "request: ", tpl.Title())
|
||||
}
|
||||
|
||||
func TestTemplateManager_hasAPI_PullRequest(t *testing.T) {
|
||||
|
|
@ -112,6 +113,7 @@ func TestTemplateManager_hasAPI_PullRequest(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", tpl.NameForSubmit())
|
||||
assert.Equal(t, "I fixed a problem", string(tpl.Body()))
|
||||
assert.Equal(t, "", tpl.Title())
|
||||
}
|
||||
|
||||
func TestTemplateManagerSelect(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -98,6 +98,21 @@ func ExtractName(filePath string) string {
|
|||
return path.Base(filePath)
|
||||
}
|
||||
|
||||
// ExtractTitle returns the title of the template from YAML front-matter
|
||||
func ExtractTitle(filePath string) string {
|
||||
contents, err := os.ReadFile(filePath)
|
||||
frontmatterBoundaries := detectFrontmatter(contents)
|
||||
if err == nil && frontmatterBoundaries[0] == 0 {
|
||||
templateData := struct {
|
||||
Title string
|
||||
}{}
|
||||
if err := yaml.Unmarshal(contents[0:frontmatterBoundaries[1]], &templateData); err == nil && templateData.Title != "" {
|
||||
return templateData.Title
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ExtractContents returns the template contents without the YAML front-matter
|
||||
func ExtractContents(filePath string) []byte {
|
||||
contents, err := os.ReadFile(filePath)
|
||||
|
|
|
|||
|
|
@ -319,6 +319,67 @@ about: This is how you report bugs
|
|||
}
|
||||
}
|
||||
|
||||
func TestExtractTitle(t *testing.T) {
|
||||
tmpfile, err := os.CreateTemp(t.TempDir(), "gh-cli")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tmpfile.Close()
|
||||
|
||||
type args struct {
|
||||
filePath string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
prepare string
|
||||
args args
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Complete front-matter",
|
||||
prepare: `---
|
||||
name: Bug Report
|
||||
title: 'bug: '
|
||||
about: This is how you report bugs
|
||||
---
|
||||
|
||||
**Template contents**
|
||||
`,
|
||||
args: args{
|
||||
filePath: tmpfile.Name(),
|
||||
},
|
||||
want: "bug: ",
|
||||
},
|
||||
{
|
||||
name: "Incomplete front-matter",
|
||||
prepare: `---
|
||||
about: This is how you report bugs
|
||||
---
|
||||
`,
|
||||
args: args{
|
||||
filePath: tmpfile.Name(),
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "No front-matter",
|
||||
prepare: `name: This is not yaml!`,
|
||||
args: args{
|
||||
filePath: tmpfile.Name(),
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_ = os.WriteFile(tmpfile.Name(), []byte(tt.prepare), 0600)
|
||||
if got := ExtractTitle(tt.args.filePath); got != tt.want {
|
||||
t.Errorf("ExtractTitle() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContents(t *testing.T) {
|
||||
tmpfile, err := os.CreateTemp(t.TempDir(), "gh-cli")
|
||||
if err != nil {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue