Merge branch 'trunk' into gh-cache-command
This commit is contained in:
commit
2e5a728338
48 changed files with 1901 additions and 586 deletions
|
|
@ -3,6 +3,7 @@ package alias
|
|||
import (
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
deleteCmd "github.com/cli/cli/v2/pkg/cmd/alias/delete"
|
||||
importCmd "github.com/cli/cli/v2/pkg/cmd/alias/imports"
|
||||
listCmd "github.com/cli/cli/v2/pkg/cmd/alias/list"
|
||||
setCmd "github.com/cli/cli/v2/pkg/cmd/alias/set"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
|
|
@ -23,6 +24,7 @@ func NewCmdAlias(f *cmdutil.Factory) *cobra.Command {
|
|||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
cmd.AddCommand(deleteCmd.NewCmdDelete(f, nil))
|
||||
cmd.AddCommand(importCmd.NewCmdImport(f, nil))
|
||||
cmd.AddCommand(listCmd.NewCmdList(f, nil))
|
||||
cmd.AddCommand(setCmd.NewCmdSet(f, nil))
|
||||
|
||||
|
|
|
|||
195
pkg/cmd/alias/imports/import.go
Normal file
195
pkg/cmd/alias/imports/import.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
package imports
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type ImportOptions struct {
|
||||
Config func() (config.Config, error)
|
||||
IO *iostreams.IOStreams
|
||||
|
||||
Filename string
|
||||
OverwriteExisting bool
|
||||
|
||||
existingCommand func(string) bool
|
||||
}
|
||||
|
||||
func NewCmdImport(f *cmdutil.Factory, runF func(*ImportOptions) error) *cobra.Command {
|
||||
opts := &ImportOptions{
|
||||
IO: f.IOStreams,
|
||||
Config: f.Config,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "import [<filename> | -]",
|
||||
Short: "Import aliases from a YAML file",
|
||||
Long: heredoc.Doc(`
|
||||
Import aliases from the contents of a YAML file.
|
||||
|
||||
Aliases should be defined as a map in YAML, where the keys represent aliases and
|
||||
the values represent the corresponding expansions. An example file should look like
|
||||
the following:
|
||||
|
||||
bugs: issue list --label=bug
|
||||
igrep: '!gh issue list --label="$1" | grep "$2"'
|
||||
features: |-
|
||||
issue list
|
||||
--label=enhancement
|
||||
|
||||
Use "-" to read aliases (in YAML format) from standard input.
|
||||
|
||||
The output from the gh command "alias list" can be used to produce a YAML file
|
||||
containing your aliases, which you can use to import them from one machine to
|
||||
another. Run "gh help alias list" to learn more.
|
||||
`),
|
||||
Example: heredoc.Doc(`
|
||||
# Import aliases from a file
|
||||
$ gh alias import aliases.yml
|
||||
|
||||
# Import aliases from standard input
|
||||
$ gh alias import -
|
||||
`),
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 1 {
|
||||
return cmdutil.FlagErrorf("too many arguments")
|
||||
}
|
||||
if len(args) == 0 && opts.IO.IsStdinTTY() {
|
||||
return cmdutil.FlagErrorf("no filename passed and nothing on STDIN")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Filename = "-"
|
||||
if len(args) > 0 {
|
||||
opts.Filename = args[0]
|
||||
}
|
||||
|
||||
opts.existingCommand = shared.ExistingCommandFunc(f, cmd)
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
|
||||
return importRun(opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.OverwriteExisting, "clobber", false, "Overwrite existing aliases of the same name")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func importRun(opts *ImportOptions) error {
|
||||
cs := opts.IO.ColorScheme()
|
||||
cfg, err := opts.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aliasCfg := cfg.Aliases()
|
||||
|
||||
b, err := cmdutil.ReadFile(opts.Filename, opts.IO.In)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aliasMap := map[string]string{}
|
||||
if err = yaml.Unmarshal(b, &aliasMap); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isTerminal := opts.IO.IsStdoutTTY()
|
||||
if isTerminal {
|
||||
if opts.Filename == "-" {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "- Importing aliases from standard input\n")
|
||||
} else {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "- Importing aliases from file %q\n", opts.Filename)
|
||||
}
|
||||
}
|
||||
|
||||
var msg strings.Builder
|
||||
|
||||
for _, alias := range getSortedKeys(aliasMap) {
|
||||
if opts.existingCommand(alias) {
|
||||
msg.WriteString(
|
||||
fmt.Sprintf("%s Could not import alias %s: already a gh command\n",
|
||||
cs.FailureIcon(),
|
||||
cs.Bold(alias),
|
||||
),
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
expansion := aliasMap[alias]
|
||||
|
||||
if !(strings.HasPrefix(expansion, "!") || opts.existingCommand(expansion)) {
|
||||
msg.WriteString(
|
||||
fmt.Sprintf("%s Could not import alias %s: expansion does not correspond to a gh command\n",
|
||||
cs.FailureIcon(),
|
||||
cs.Bold(alias),
|
||||
),
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := aliasCfg.Get(alias); err == nil {
|
||||
if opts.OverwriteExisting {
|
||||
aliasCfg.Add(alias, expansion)
|
||||
|
||||
msg.WriteString(
|
||||
fmt.Sprintf("%s Changed alias %s\n",
|
||||
cs.WarningIcon(),
|
||||
cs.Bold(alias),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
msg.WriteString(
|
||||
fmt.Sprintf("%s Could not import alias %s: name already taken\n",
|
||||
cs.FailureIcon(),
|
||||
cs.Bold(alias),
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
aliasCfg.Add(alias, expansion)
|
||||
|
||||
msg.WriteString(
|
||||
fmt.Sprintf("%s Added alias %s\n",
|
||||
cs.SuccessIcon(),
|
||||
cs.Bold(alias),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := cfg.Write(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if isTerminal {
|
||||
fmt.Fprintln(opts.IO.ErrOut, msg.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSortedKeys(m map[string]string) []string {
|
||||
keys := []string{}
|
||||
for key := range m {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
346
pkg/cmd/alias/imports/import_test.go
Normal file
346
pkg/cmd/alias/imports/import_test.go
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
package imports
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/extensions"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/google/shlex"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewCmdImport(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cli string
|
||||
tty bool
|
||||
wants ImportOptions
|
||||
wantsError string
|
||||
}{
|
||||
{
|
||||
name: "no filename and stdin tty",
|
||||
cli: "",
|
||||
tty: true,
|
||||
wants: ImportOptions{
|
||||
Filename: "",
|
||||
OverwriteExisting: false,
|
||||
},
|
||||
wantsError: "no filename passed and nothing on STDIN",
|
||||
},
|
||||
{
|
||||
name: "no filename and stdin is not tty",
|
||||
cli: "",
|
||||
tty: false,
|
||||
wants: ImportOptions{
|
||||
Filename: "-",
|
||||
OverwriteExisting: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stdin arg",
|
||||
cli: "-",
|
||||
wants: ImportOptions{
|
||||
Filename: "-",
|
||||
OverwriteExisting: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple filenames",
|
||||
cli: "aliases1 aliases2",
|
||||
wants: ImportOptions{
|
||||
Filename: "aliases1 aliases2",
|
||||
OverwriteExisting: false,
|
||||
},
|
||||
wantsError: "too many arguments",
|
||||
},
|
||||
{
|
||||
name: "clobber flag",
|
||||
cli: "aliases --clobber",
|
||||
wants: ImportOptions{
|
||||
Filename: "aliases",
|
||||
OverwriteExisting: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
ios.SetStdinTTY(tt.tty)
|
||||
f := &cmdutil.Factory{IOStreams: ios}
|
||||
|
||||
argv, err := shlex.Split(tt.cli)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var gotOpts *ImportOptions
|
||||
cmd := NewCmdImport(f, func(opts *ImportOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs(argv)
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
|
||||
_, err = cmd.ExecuteC()
|
||||
if tt.wantsError != "" {
|
||||
assert.EqualError(t, err, tt.wantsError)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.wants.Filename, gotOpts.Filename)
|
||||
assert.Equal(t, tt.wants.OverwriteExisting, gotOpts.OverwriteExisting)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRun(t *testing.T) {
|
||||
tmpFile := filepath.Join(t.TempDir(), "aliases.yml")
|
||||
importFileMsg := fmt.Sprintf(`- Importing aliases from file %q`, tmpFile)
|
||||
importStdinMsg := "- Importing aliases from standard input"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
opts *ImportOptions
|
||||
stdin string
|
||||
fileContents string
|
||||
initConfig string
|
||||
wantConfig string
|
||||
wantStderr string
|
||||
}{
|
||||
{
|
||||
name: "with no existing aliases",
|
||||
opts: &ImportOptions{
|
||||
Filename: tmpFile,
|
||||
OverwriteExisting: false,
|
||||
},
|
||||
fileContents: heredoc.Doc(`
|
||||
co: pr checkout
|
||||
igrep: '!gh issue list --label="$1" | grep "$2"'
|
||||
`),
|
||||
wantConfig: heredoc.Doc(`
|
||||
aliases:
|
||||
co: pr checkout
|
||||
igrep: '!gh issue list --label="$1" | grep "$2"'
|
||||
`),
|
||||
wantStderr: importFileMsg + "\n✓ Added alias co\n✓ Added alias igrep\n\n",
|
||||
},
|
||||
{
|
||||
name: "with existing aliases",
|
||||
opts: &ImportOptions{
|
||||
Filename: tmpFile,
|
||||
OverwriteExisting: false,
|
||||
},
|
||||
fileContents: heredoc.Doc(`
|
||||
users: |-
|
||||
api graphql -F name="$1" -f query='
|
||||
query ($name: String!) {
|
||||
user(login: $name) {
|
||||
name
|
||||
}
|
||||
}'
|
||||
co: pr checkout
|
||||
`),
|
||||
initConfig: heredoc.Doc(`
|
||||
aliases:
|
||||
igrep: '!gh issue list --label="$1" | grep "$2"'
|
||||
editor: vim
|
||||
`),
|
||||
wantConfig: heredoc.Doc(`
|
||||
aliases:
|
||||
igrep: '!gh issue list --label="$1" | grep "$2"'
|
||||
co: pr checkout
|
||||
users: |-
|
||||
api graphql -F name="$1" -f query='
|
||||
query ($name: String!) {
|
||||
user(login: $name) {
|
||||
name
|
||||
}
|
||||
}'
|
||||
editor: vim
|
||||
`),
|
||||
wantStderr: importFileMsg + "\n✓ Added alias co\n✓ Added alias users\n\n",
|
||||
},
|
||||
{
|
||||
name: "from stdin",
|
||||
opts: &ImportOptions{
|
||||
Filename: "-",
|
||||
OverwriteExisting: false,
|
||||
},
|
||||
stdin: heredoc.Doc(`
|
||||
co: pr checkout
|
||||
features: |-
|
||||
issue list
|
||||
--label=enhancement
|
||||
igrep: '!gh issue list --label="$1" | grep "$2"'
|
||||
`),
|
||||
wantConfig: heredoc.Doc(`
|
||||
aliases:
|
||||
co: pr checkout
|
||||
features: |-
|
||||
issue list
|
||||
--label=enhancement
|
||||
igrep: '!gh issue list --label="$1" | grep "$2"'
|
||||
`),
|
||||
wantStderr: importStdinMsg + "\n✓ Added alias co\n✓ Added alias features\n✓ Added alias igrep\n\n",
|
||||
},
|
||||
{
|
||||
name: "already taken aliases",
|
||||
opts: &ImportOptions{
|
||||
Filename: tmpFile,
|
||||
OverwriteExisting: false,
|
||||
},
|
||||
fileContents: heredoc.Doc(`
|
||||
co: pr checkout -R cool/repo
|
||||
igrep: '!gh issue list --label="$1" | grep "$2"'
|
||||
`),
|
||||
initConfig: heredoc.Doc(`
|
||||
aliases:
|
||||
co: pr checkout
|
||||
editor: vim
|
||||
`),
|
||||
wantConfig: heredoc.Doc(`
|
||||
aliases:
|
||||
co: pr checkout
|
||||
igrep: '!gh issue list --label="$1" | grep "$2"'
|
||||
editor: vim
|
||||
`),
|
||||
wantStderr: importFileMsg + "\nX Could not import alias co: name already taken\n✓ Added alias igrep\n\n",
|
||||
},
|
||||
{
|
||||
name: "override aliases",
|
||||
opts: &ImportOptions{
|
||||
Filename: tmpFile,
|
||||
OverwriteExisting: true,
|
||||
},
|
||||
fileContents: heredoc.Doc(`
|
||||
co: pr checkout -R cool/repo
|
||||
igrep: '!gh issue list --label="$1" | grep "$2"'
|
||||
`),
|
||||
initConfig: heredoc.Doc(`
|
||||
aliases:
|
||||
co: pr checkout
|
||||
editor: vim
|
||||
`),
|
||||
wantConfig: heredoc.Doc(`
|
||||
aliases:
|
||||
co: pr checkout -R cool/repo
|
||||
igrep: '!gh issue list --label="$1" | grep "$2"'
|
||||
editor: vim
|
||||
`),
|
||||
wantStderr: importFileMsg + "\n! Changed alias co\n✓ Added alias igrep\n\n",
|
||||
},
|
||||
{
|
||||
name: "alias is a gh command",
|
||||
opts: &ImportOptions{
|
||||
Filename: tmpFile,
|
||||
OverwriteExisting: false,
|
||||
},
|
||||
fileContents: heredoc.Doc(`
|
||||
pr: pr checkout
|
||||
issue: issue list
|
||||
api: api graphql
|
||||
`),
|
||||
wantStderr: strings.Join(
|
||||
[]string{
|
||||
importFileMsg,
|
||||
"X Could not import alias api: already a gh command",
|
||||
"X Could not import alias issue: already a gh command",
|
||||
"X Could not import alias pr: already a gh command\n\n",
|
||||
},
|
||||
"\n",
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "invalid expansion",
|
||||
opts: &ImportOptions{
|
||||
Filename: tmpFile,
|
||||
OverwriteExisting: false,
|
||||
},
|
||||
fileContents: heredoc.Doc(`
|
||||
alias1:
|
||||
alias2: ps checkout
|
||||
`),
|
||||
wantStderr: strings.Join(
|
||||
[]string{
|
||||
importFileMsg,
|
||||
"X Could not import alias alias1: expansion does not correspond to a gh command",
|
||||
"X Could not import alias alias2: expansion does not correspond to a gh command\n\n",
|
||||
},
|
||||
"\n",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.fileContents != "" {
|
||||
err := os.WriteFile(tmpFile, []byte(tt.fileContents), 0600)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
ios, stdin, _, stderr := iostreams.Test()
|
||||
ios.SetStdinTTY(true)
|
||||
ios.SetStdoutTTY(true)
|
||||
ios.SetStderrTTY(true)
|
||||
tt.opts.IO = ios
|
||||
|
||||
readConfigs := config.StubWriteConfig(t)
|
||||
cfg := config.NewFromString(tt.initConfig)
|
||||
tt.opts.Config = func() (config.Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Create fake command factory for testing.
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: ios,
|
||||
ExtensionManager: &extensions.ExtensionManagerMock{
|
||||
ListFunc: func() []extensions.Extension {
|
||||
return []extensions.Extension{}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create fake command structure for testing.
|
||||
rootCmd := &cobra.Command{}
|
||||
prCmd := &cobra.Command{Use: "pr"}
|
||||
prCmd.AddCommand(&cobra.Command{Use: "checkout"})
|
||||
prCmd.AddCommand(&cobra.Command{Use: "status"})
|
||||
rootCmd.AddCommand(prCmd)
|
||||
issueCmd := &cobra.Command{Use: "issue"}
|
||||
issueCmd.AddCommand(&cobra.Command{Use: "list"})
|
||||
rootCmd.AddCommand(issueCmd)
|
||||
apiCmd := &cobra.Command{Use: "api"}
|
||||
apiCmd.AddCommand(&cobra.Command{Use: "graphql"})
|
||||
rootCmd.AddCommand(apiCmd)
|
||||
|
||||
tt.opts.existingCommand = shared.ExistingCommandFunc(f, rootCmd)
|
||||
|
||||
if tt.stdin != "" {
|
||||
stdin.WriteString(tt.stdin)
|
||||
}
|
||||
|
||||
err := importRun(tt.opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
configOut := bytes.Buffer{}
|
||||
readConfigs(&configOut, io.Discard)
|
||||
|
||||
assert.Equal(t, tt.wantStderr, stderr.String())
|
||||
assert.Equal(t, tt.wantConfig, configOut.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -7,9 +7,9 @@ import (
|
|||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/google/shlex"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ type SetOptions struct {
|
|||
Expansion string
|
||||
IsShell bool
|
||||
|
||||
validCommand func(string) bool
|
||||
existingCommand func(string) bool
|
||||
}
|
||||
|
||||
func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command {
|
||||
|
|
@ -70,29 +70,12 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
|
|||
opts.Name = args[0]
|
||||
opts.Expansion = args[1]
|
||||
|
||||
opts.validCommand = func(args string) bool {
|
||||
split, err := shlex.Split(args)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
rootCmd := cmd.Root()
|
||||
cmd, _, err := rootCmd.Traverse(split)
|
||||
if err == nil && cmd != rootCmd {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, ext := range f.ExtensionManager.List() {
|
||||
if ext.Name() == split[0] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
opts.existingCommand = shared.ExistingCommandFunc(f, cmd)
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
|
||||
return setRun(opts)
|
||||
},
|
||||
}
|
||||
|
|
@ -127,11 +110,11 @@ func setRun(opts *SetOptions) error {
|
|||
}
|
||||
isShell = strings.HasPrefix(expansion, "!")
|
||||
|
||||
if opts.validCommand(opts.Name) {
|
||||
if opts.existingCommand(opts.Name) {
|
||||
return fmt.Errorf("could not create alias: %q is already a gh command", opts.Name)
|
||||
}
|
||||
|
||||
if !isShell && !opts.validCommand(expansion) {
|
||||
if !isShell && !opts.existingCommand(expansion) {
|
||||
return fmt.Errorf("could not create alias: %s does not correspond to a gh command", expansion)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ func runCommand(cfg config.Config, isTTY bool, cli string, in string) (*test.Cmd
|
|||
|
||||
cmd := NewCmdSet(factory, nil)
|
||||
|
||||
// fake command nesting structure needed for validCommand
|
||||
// Create fake command structure for testing.
|
||||
rootCmd := &cobra.Command{}
|
||||
rootCmd.AddCommand(cmd)
|
||||
prCmd := &cobra.Command{Use: "pr"}
|
||||
|
|
|
|||
32
pkg/cmd/alias/shared/validations.go
Normal file
32
pkg/cmd/alias/shared/validations.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package shared
|
||||
|
||||
import (
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/google/shlex"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ExistingCommandFunc returns a function that will check if the given string
|
||||
// corresponds to an existing command.
|
||||
func ExistingCommandFunc(f *cmdutil.Factory, cmd *cobra.Command) func(string) bool {
|
||||
return func(args string) bool {
|
||||
split, err := shlex.Split(args)
|
||||
if err != nil || len(split) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
rootCmd := cmd.Root()
|
||||
cmd, _, err = rootCmd.Traverse(split)
|
||||
if err == nil && cmd != rootCmd {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, ext := range f.ExtensionManager.List() {
|
||||
if ext.Name() == split[0] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
40
pkg/cmd/alias/shared/validations_test.go
Normal file
40
pkg/cmd/alias/shared/validations_test.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package shared
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/extensions"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestExistingCommandFunc(t *testing.T) {
|
||||
// Create fake command factory for testing.
|
||||
factory := &cmdutil.Factory{
|
||||
ExtensionManager: &extensions.ExtensionManagerMock{
|
||||
ListFunc: func() []extensions.Extension {
|
||||
return []extensions.Extension{}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create fake command structure for testing.
|
||||
issueCmd := &cobra.Command{Use: "issue"}
|
||||
prCmd := &cobra.Command{Use: "pr"}
|
||||
prCmd.AddCommand(&cobra.Command{Use: "checkout"})
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.AddCommand(prCmd)
|
||||
cmd.AddCommand(issueCmd)
|
||||
|
||||
f := ExistingCommandFunc(factory, cmd)
|
||||
|
||||
assert.True(t, f("pr"))
|
||||
assert.True(t, f("pr checkout"))
|
||||
assert.True(t, f("issue"))
|
||||
|
||||
assert.False(t, f("ps"))
|
||||
assert.False(t, f("checkout"))
|
||||
assert.False(t, f("repo list"))
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
|
|
@ -176,7 +177,8 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) {
|
|||
progressLabel = "Deleting codespaces"
|
||||
}
|
||||
|
||||
return a.RunWithProgress(progressLabel, func() error {
|
||||
var deletedCodespaces uint32
|
||||
err = a.RunWithProgress(progressLabel, func() error {
|
||||
var g errgroup.Group
|
||||
for _, c := range codespacesToDelete {
|
||||
codespaceName := c.Name
|
||||
|
|
@ -185,15 +187,23 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) {
|
|||
a.errLogger.Printf("error deleting codespace %q: %v\n", codespaceName, err)
|
||||
return err
|
||||
}
|
||||
atomic.AddUint32(&deletedCodespaces, 1)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return errors.New("some codespaces failed to delete")
|
||||
return fmt.Errorf("%d codespace(s) failed to delete", len(codespacesToDelete)-int(deletedCodespaces))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if a.io.IsStdoutTTY() && deletedCodespaces > 0 {
|
||||
successMsg := fmt.Sprintf("%d codespace(s) deleted successfully\n", deletedCodespaces)
|
||||
fmt.Fprint(a.io.ErrOut, successMsg)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func confirmDeletion(p prompter, apiCodespace *api.Codespace, isInteractive bool) (bool, error) {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ func TestDelete(t *testing.T) {
|
|||
codespaces []*api.Codespace
|
||||
confirms map[string]bool
|
||||
deleteErr error
|
||||
wantErr bool
|
||||
wantErr string
|
||||
wantDeleted []string
|
||||
wantStdout string
|
||||
wantStderr string
|
||||
|
|
@ -42,7 +42,7 @@ func TestDelete(t *testing.T) {
|
|||
},
|
||||
},
|
||||
wantDeleted: []string{"hubot-robawt-abc"},
|
||||
wantStdout: "",
|
||||
wantStderr: "1 codespace(s) deleted successfully\n",
|
||||
},
|
||||
{
|
||||
name: "by repo",
|
||||
|
|
@ -70,7 +70,7 @@ func TestDelete(t *testing.T) {
|
|||
},
|
||||
},
|
||||
wantDeleted: []string{"monalisa-spoonknife-123", "monalisa-spoonknife-c4f3"},
|
||||
wantStdout: "",
|
||||
wantStderr: "2 codespace(s) deleted successfully\n",
|
||||
},
|
||||
{
|
||||
name: "unused",
|
||||
|
|
@ -93,7 +93,7 @@ func TestDelete(t *testing.T) {
|
|||
},
|
||||
},
|
||||
wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-c4f3"},
|
||||
wantStdout: "",
|
||||
wantStderr: "2 codespace(s) deleted successfully\n",
|
||||
},
|
||||
{
|
||||
name: "deletion failed",
|
||||
|
|
@ -109,12 +109,13 @@ func TestDelete(t *testing.T) {
|
|||
},
|
||||
},
|
||||
deleteErr: errors.New("aborted by test"),
|
||||
wantErr: true,
|
||||
wantErr: "2 codespace(s) failed to delete",
|
||||
wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-123"},
|
||||
wantStderr: heredoc.Doc(`
|
||||
error deleting codespace "hubot-robawt-abc": aborted by test
|
||||
error deleting codespace "monalisa-spoonknife-123": aborted by test
|
||||
`),
|
||||
wantStdout: "",
|
||||
},
|
||||
{
|
||||
name: "with confirm",
|
||||
|
|
@ -149,7 +150,7 @@ func TestDelete(t *testing.T) {
|
|||
"Codespace hubot-robawt-abc has unsaved changes. OK to delete?": true,
|
||||
},
|
||||
wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-c4f3"},
|
||||
wantStdout: "",
|
||||
wantStderr: "2 codespace(s) deleted successfully\n",
|
||||
},
|
||||
{
|
||||
name: "deletion for org codespace by admin succeeds",
|
||||
|
|
@ -174,7 +175,7 @@ func TestDelete(t *testing.T) {
|
|||
},
|
||||
},
|
||||
wantDeleted: []string{"monalisa-spoonknife-123"},
|
||||
wantStdout: "",
|
||||
wantStderr: "1 codespace(s) deleted successfully\n",
|
||||
},
|
||||
{
|
||||
name: "deletion for org codespace by admin fails for codespace not found",
|
||||
|
|
@ -200,7 +201,8 @@ func TestDelete(t *testing.T) {
|
|||
},
|
||||
wantDeleted: []string{},
|
||||
wantStdout: "",
|
||||
wantErr: true,
|
||||
wantErr: "error fetching codespace information: " +
|
||||
"codespace not found for user johnDoe with name monalisa-spoonknife-123",
|
||||
},
|
||||
{
|
||||
name: "deletion for org codespace succeeds without username",
|
||||
|
|
@ -215,7 +217,7 @@ func TestDelete(t *testing.T) {
|
|||
},
|
||||
},
|
||||
wantDeleted: []string{"monalisa-spoonknife-123"},
|
||||
wantStdout: "",
|
||||
wantStderr: "1 codespace(s) deleted successfully\n",
|
||||
},
|
||||
{
|
||||
name: "by repo owner",
|
||||
|
|
@ -253,6 +255,7 @@ func TestDelete(t *testing.T) {
|
|||
},
|
||||
},
|
||||
wantDeleted: []string{"octocat-spoonknife-123", "octocat-spoonknife-c4f3"},
|
||||
wantStderr: "2 codespace(s) deleted successfully\n",
|
||||
wantStdout: "",
|
||||
},
|
||||
}
|
||||
|
|
@ -306,8 +309,8 @@ func TestDelete(t *testing.T) {
|
|||
ios.SetStdoutTTY(true)
|
||||
app := NewApp(ios, nil, apiMock, nil, nil)
|
||||
err := app.Delete(context.Background(), opts)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("delete() error = %v, wantErr %v", err, tt.wantErr)
|
||||
if (err != nil) && tt.wantErr != err.Error() {
|
||||
t.Errorf("delete() error = %v, wantErr = %v", err, tt.wantErr)
|
||||
}
|
||||
for _, listArgs := range apiMock.ListCodespacesCalls() {
|
||||
if listArgs.Opts.OrgName != "" && listArgs.Opts.UserName == "" {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -129,6 +130,12 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
|||
|
||||
opts.TitleProvided = cmd.Flags().Changed("title")
|
||||
opts.RepoOverride, _ = cmd.Flags().GetString("repo")
|
||||
// Workaround: Due to the way this command is implemented, we need to manually check GH_REPO.
|
||||
// Commands should use the standard BaseRepoOverride functionality to handle this behavior instead.
|
||||
if opts.RepoOverride == "" {
|
||||
opts.RepoOverride = os.Getenv("GH_REPO")
|
||||
}
|
||||
|
||||
noMaintainerEdit, _ := cmd.Flags().GetBool("no-maintainer-edit")
|
||||
opts.MaintainerCanModify = !noMaintainerEdit
|
||||
|
||||
|
|
@ -318,8 +325,6 @@ func createRun(opts *CreateOptions) (err error) {
|
|||
|
||||
if template != nil {
|
||||
templateContent = string(template.Body())
|
||||
} else {
|
||||
templateContent = string(tpl.LegacyBody())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@
|
|||
"headRepositoryOwner": {
|
||||
"login": "OWNER"
|
||||
},
|
||||
"isCrossRepository": false
|
||||
"isCrossRepository": false,
|
||||
"autoMergeRequest": null
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -31,7 +32,8 @@
|
|||
"state": "OPEN",
|
||||
"url": "https://github.com/cli/cli/pull/8",
|
||||
"headRefName": "strawberries",
|
||||
"isDraft": false
|
||||
"isDraft": false,
|
||||
"autoMergeRequest": null
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -46,7 +48,17 @@
|
|||
"state": "OPEN",
|
||||
"url": "https://github.com/cli/cli/pull/9",
|
||||
"headRefName": "apples",
|
||||
"isDraft": false
|
||||
"isDraft": false,
|
||||
"autoMergeRequest": {
|
||||
"authorEmail": null,
|
||||
"commitBody": null,
|
||||
"commitHeadline": null,
|
||||
"mergeMethod": "SQUASH",
|
||||
"enabledAt": "2020-08-27T19:00:12Z",
|
||||
"enabledBy": {
|
||||
"login": "hubot"
|
||||
}
|
||||
}
|
||||
} }, {
|
||||
"node": {
|
||||
"number": 11,
|
||||
|
|
@ -54,7 +66,8 @@
|
|||
"state": "OPEN",
|
||||
"url": "https://github.com/cli/cli/pull/1",
|
||||
"headRefName": "figs",
|
||||
"isDraft": true
|
||||
"isDraft": true,
|
||||
"autoMergeRequest": null
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ func pullRequestFragment(hostname string, conflictStatus bool) (string, error) {
|
|||
fields := []string{
|
||||
"number", "title", "state", "url", "isDraft", "isCrossRepository",
|
||||
"headRefName", "headRepositoryOwner", "mergeStateStatus",
|
||||
"statusCheckRollup", "requiresStrictStatusChecks",
|
||||
"statusCheckRollup", "requiresStrictStatusChecks", "autoMergeRequest",
|
||||
}
|
||||
|
||||
if conflictStatus {
|
||||
|
|
|
|||
|
|
@ -284,6 +284,10 @@ func printPrs(io *iostreams.IOStreams, totalCount int, prs ...api.PullRequest) {
|
|||
}
|
||||
}
|
||||
|
||||
if pr.AutoMergeRequest != nil {
|
||||
fmt.Fprintf(w, " %s", cs.Green("✓ Auto-merge enabled"))
|
||||
}
|
||||
|
||||
} else {
|
||||
fmt.Fprintf(w, " - %s", shared.StateTitleWithColor(cs, pr))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ func TestPRStatus(t *testing.T) {
|
|||
|
||||
expectedPrs := []*regexp.Regexp{
|
||||
regexp.MustCompile(`#8.*\[strawberries\]`),
|
||||
regexp.MustCompile(`#9.*\[apples\]`),
|
||||
regexp.MustCompile(`#9.*\[apples\].*✓ Auto-merge enabled`),
|
||||
regexp.MustCompile(`#10.*\[blueberries\]`),
|
||||
regexp.MustCompile(`#11.*\[figs\]`),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"data": {
|
||||
"repository": {
|
||||
"pullRequest": {
|
||||
"number": 12,
|
||||
"title": "Blueberries are from a fork",
|
||||
"state": "OPEN",
|
||||
"body": "**blueberries taste good**",
|
||||
"url": "https://github.com/OWNER/REPO/pull/12",
|
||||
"author": {
|
||||
"login": "nobody"
|
||||
},
|
||||
"autoMergeRequest": {
|
||||
"authorEmail": null,
|
||||
"commitBody": null,
|
||||
"commitHeadline": null,
|
||||
"mergeMethod": "SQUASH",
|
||||
"enabledAt": "2020-08-27T19:00:12Z",
|
||||
"enabledBy": {
|
||||
"login": "hubot"
|
||||
}
|
||||
},
|
||||
"additions": 100,
|
||||
"deletions": 10,
|
||||
"reviewRequests": {
|
||||
"nodes": [],
|
||||
"totalcount": 0
|
||||
},
|
||||
"assignees": {
|
||||
"nodes": [],
|
||||
"totalcount": 0
|
||||
},
|
||||
"labels": {
|
||||
"nodes": [],
|
||||
"totalcount": 0
|
||||
},
|
||||
"projectcards": {
|
||||
"nodes": [],
|
||||
"totalcount": 0
|
||||
},
|
||||
"milestone": {},
|
||||
"commits": {
|
||||
"totalCount": 12
|
||||
},
|
||||
"baseRefName": "master",
|
||||
"headRefName": "blueberries",
|
||||
"headRepositoryOwner": {
|
||||
"login": "hubot"
|
||||
},
|
||||
"isCrossRepository": true,
|
||||
"isDraft": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -77,7 +77,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
|
|||
}
|
||||
|
||||
var defaultFields = []string{
|
||||
"url", "number", "title", "state", "body", "author",
|
||||
"url", "number", "title", "state", "body", "author", "autoMergeRequest",
|
||||
"isDraft", "maintainerCanModify", "mergeable", "additions", "deletions", "commitsCount",
|
||||
"baseRefName", "headRefName", "headRepositoryOwner", "headRepository", "isCrossRepository",
|
||||
"reviewRequests", "reviews", "assignees", "labels", "projectCards", "milestone",
|
||||
|
|
@ -157,6 +157,15 @@ func printRawPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error {
|
|||
fmt.Fprintf(out, "url:\t%s\n", pr.URL)
|
||||
fmt.Fprintf(out, "additions:\t%s\n", cs.Green(strconv.Itoa(pr.Additions)))
|
||||
fmt.Fprintf(out, "deletions:\t%s\n", cs.Red(strconv.Itoa(pr.Deletions)))
|
||||
var autoMerge string
|
||||
if pr.AutoMergeRequest == nil {
|
||||
autoMerge = "disabled"
|
||||
} else {
|
||||
autoMerge = fmt.Sprintf("enabled\t%s\t%s",
|
||||
pr.AutoMergeRequest.EnabledBy.Login,
|
||||
strings.ToLower(pr.AutoMergeRequest.MergeMethod))
|
||||
}
|
||||
fmt.Fprintf(out, "auto-merge:\t%s\n", autoMerge)
|
||||
|
||||
fmt.Fprintln(out, "--")
|
||||
fmt.Fprintln(out, pr.Body)
|
||||
|
|
@ -223,6 +232,29 @@ func printHumanPrPreview(opts *ViewOptions, pr *api.PullRequest) error {
|
|||
fmt.Fprintln(out, pr.Milestone.Title)
|
||||
}
|
||||
|
||||
// Auto-Merge status
|
||||
autoMerge := pr.AutoMergeRequest
|
||||
if autoMerge != nil {
|
||||
var mergeMethod string
|
||||
switch autoMerge.MergeMethod {
|
||||
case "MERGE":
|
||||
mergeMethod = "a merge commit"
|
||||
case "REBASE":
|
||||
mergeMethod = "rebase and merge"
|
||||
case "SQUASH":
|
||||
mergeMethod = "squash and merge"
|
||||
default:
|
||||
mergeMethod = fmt.Sprintf("an unknown merge method (%s)", autoMerge.MergeMethod)
|
||||
}
|
||||
fmt.Fprintf(out,
|
||||
"%s %s by %s, using %s\n",
|
||||
cs.Bold("Auto-merge:"),
|
||||
cs.Green("enabled"),
|
||||
autoMerge.EnabledBy.Login,
|
||||
mergeMethod,
|
||||
)
|
||||
}
|
||||
|
||||
// Body
|
||||
var md string
|
||||
var err error
|
||||
|
|
|
|||
|
|
@ -314,6 +314,25 @@ func TestPRView_Preview_nontty(t *testing.T) {
|
|||
`\*\*blueberries taste good\*\*`,
|
||||
},
|
||||
},
|
||||
"PR with auto-merge enabled": {
|
||||
branch: "master",
|
||||
args: "12",
|
||||
fixtures: map[string]string{
|
||||
"PullRequestByNumber": "./fixtures/prViewPreviewWithAutoMergeEnabled.json",
|
||||
},
|
||||
expectedOutputs: []string{
|
||||
`title:\tBlueberries are from a fork\n`,
|
||||
`state:\tOPEN\n`,
|
||||
`author:\tnobody\n`,
|
||||
`labels:\t\n`,
|
||||
`assignees:\t\n`,
|
||||
`projects:\t\n`,
|
||||
`milestone:\t\n`,
|
||||
`additions:\t100\n`,
|
||||
`deletions:\t10\n`,
|
||||
`auto-merge:\tenabled\thubot\tsquash\n`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
|
|
@ -504,6 +523,20 @@ func TestPRView_Preview(t *testing.T) {
|
|||
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
|
||||
},
|
||||
},
|
||||
"PR with auto-merge enabled": {
|
||||
branch: "master",
|
||||
args: "12",
|
||||
fixtures: map[string]string{
|
||||
"PullRequestByNumber": "./fixtures/prViewPreviewWithAutoMergeEnabled.json",
|
||||
},
|
||||
expectedOutputs: []string{
|
||||
`Blueberries are from a fork #12\n`,
|
||||
`Open.*nobody wants to merge 12 commits into master from blueberries . about X years ago`,
|
||||
`Auto-merge:.*enabled.* by hubot, using squash and merge`,
|
||||
`blueberries taste good`,
|
||||
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ func downloadRun(opts *DownloadOptions) error {
|
|||
return err
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
opts.IO.StartProgressIndicatorWithLabel("Finding assets to download")
|
||||
defer opts.IO.StopProgressIndicator()
|
||||
|
||||
ctx := context.Background()
|
||||
|
|
@ -196,7 +196,7 @@ func downloadRun(opts *DownloadOptions) error {
|
|||
stdout: opts.IO.Out,
|
||||
}
|
||||
|
||||
return downloadAssets(&dest, httpClient, toDownload, opts.Concurrency, isArchive)
|
||||
return downloadAssets(&dest, httpClient, toDownload, opts.Concurrency, isArchive, opts.IO)
|
||||
}
|
||||
|
||||
func matchAny(patterns []string, name string) bool {
|
||||
|
|
@ -208,7 +208,7 @@ func matchAny(patterns []string, name string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload []shared.ReleaseAsset, numWorkers int, isArchive bool) error {
|
||||
func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload []shared.ReleaseAsset, numWorkers int, isArchive bool, io *iostreams.IOStreams) error {
|
||||
if numWorkers == 0 {
|
||||
return errors.New("the number of concurrent workers needs to be greater than 0")
|
||||
}
|
||||
|
|
@ -223,6 +223,7 @@ func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload
|
|||
for w := 1; w <= numWorkers; w++ {
|
||||
go func() {
|
||||
for a := range jobs {
|
||||
io.StartProgressIndicatorWithLabel(fmt.Sprintf("Downloading %s", a.Name))
|
||||
results <- downloadAsset(dest, httpClient, a.APIURL, a.Name, isArchive)
|
||||
}
|
||||
}()
|
||||
|
|
@ -240,6 +241,8 @@ func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload
|
|||
}
|
||||
}
|
||||
|
||||
io.StopProgressIndicator()
|
||||
|
||||
return downloadError
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -730,7 +730,7 @@ func interactiveRepoInfo(client *http.Client, hostname string, prompter iprompte
|
|||
name = fmt.Sprintf("%s/%s", owner, name)
|
||||
}
|
||||
|
||||
description, err := prompter.Input("Description", defaultName)
|
||||
description, err := prompter.Input("Description", "")
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ func NewCmdSetDefault(f *cmdutil.Factory, runF func(*SetDefaultOptions) error) *
|
|||
}
|
||||
}
|
||||
|
||||
if !opts.IO.CanPrompt() && opts.Repo == nil {
|
||||
if !opts.ViewMode && !opts.IO.CanPrompt() && opts.Repo == nil {
|
||||
return cmdutil.FlagErrorf("repository required when not running interactively")
|
||||
}
|
||||
|
||||
|
|
@ -119,10 +119,10 @@ func setDefaultRun(opts *SetDefaultOptions) error {
|
|||
currentDefaultRepo, _ := remotes.ResolvedRemote()
|
||||
|
||||
if opts.ViewMode {
|
||||
if currentDefaultRepo == nil {
|
||||
fmt.Fprintln(opts.IO.Out, "no default repository has been set; use `gh repo set-default` to select one")
|
||||
} else {
|
||||
if currentDefaultRepo != nil {
|
||||
fmt.Fprintln(opts.IO.Out, displayRemoteRepoName(currentDefaultRepo))
|
||||
} else if opts.IO.IsStdoutTTY() {
|
||||
fmt.Fprintln(opts.IO.Out, "no default repository has been set; use `gh repo set-default` to select one")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,7 +166,8 @@ func TestDefaultRun(t *testing.T) {
|
|||
wantStdout: "no default repository has been set\n",
|
||||
},
|
||||
{
|
||||
name: "view mode no current default",
|
||||
name: "tty view mode no current default",
|
||||
tty: true,
|
||||
opts: SetDefaultOptions{ViewMode: true},
|
||||
remotes: []*context.Remote{
|
||||
{
|
||||
|
|
@ -176,6 +177,16 @@ func TestDefaultRun(t *testing.T) {
|
|||
},
|
||||
wantStdout: "no default repository has been set; use `gh repo set-default` to select one\n",
|
||||
},
|
||||
{
|
||||
name: "view mode no current default",
|
||||
opts: SetDefaultOptions{ViewMode: true},
|
||||
remotes: []*context.Remote{
|
||||
{
|
||||
Remote: &git.Remote{Name: "origin"},
|
||||
Repo: repo1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "view mode with base resolved current default",
|
||||
opts: SetDefaultOptions{ViewMode: true},
|
||||
|
|
|
|||
|
|
@ -139,8 +139,8 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, args []string) {
|
|||
if c := findCommand(command, "actions"); c != nil {
|
||||
helpTopics = append(helpTopics, rpad(c.Name()+":", namePadding)+c.Short)
|
||||
}
|
||||
for topic, params := range HelpTopics {
|
||||
helpTopics = append(helpTopics, rpad(topic+":", namePadding)+params["short"])
|
||||
for _, helpTopic := range HelpTopics {
|
||||
helpTopics = append(helpTopics, rpad(helpTopic.name+":", namePadding)+helpTopic.short)
|
||||
}
|
||||
sort.Strings(helpTopics)
|
||||
helpEntries = append(helpEntries, helpEntry{"HELP TOPICS", strings.Join(helpTopics, "\n")})
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ import (
|
|||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func referenceHelpFn(io *iostreams.IOStreams) func(*cobra.Command, []string) {
|
||||
// longPager provides a pager over a commands Long message.
|
||||
// It is currently only used for the reference command
|
||||
func longPager(io *iostreams.IOStreams) func(*cobra.Command, []string) {
|
||||
return func(cmd *cobra.Command, args []string) {
|
||||
wrapWidth := 0
|
||||
if io.IsStdoutTTY() {
|
||||
|
|
@ -38,7 +40,7 @@ func referenceHelpFn(io *iostreams.IOStreams) func(*cobra.Command, []string) {
|
|||
}
|
||||
}
|
||||
|
||||
func referenceLong(cmd *cobra.Command) string {
|
||||
func stringifyReference(cmd *cobra.Command) string {
|
||||
buf := bytes.NewBufferString("# gh reference\n\n")
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden {
|
||||
|
|
|
|||
|
|
@ -10,10 +10,18 @@ import (
|
|||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var HelpTopics = map[string]map[string]string{
|
||||
"mintty": {
|
||||
"short": "Information about using gh with MinTTY",
|
||||
"long": heredoc.Doc(`
|
||||
type helpTopic struct {
|
||||
name string
|
||||
short string
|
||||
long string
|
||||
example string
|
||||
}
|
||||
|
||||
var HelpTopics = []helpTopic{
|
||||
{
|
||||
name: "mintty",
|
||||
short: "Information about using gh with MinTTY",
|
||||
long: heredoc.Doc(`
|
||||
MinTTY is the terminal emulator that comes by default with Git
|
||||
for Windows. It has known issues with gh's ability to prompt a
|
||||
user for input.
|
||||
|
|
@ -30,9 +38,10 @@ var HelpTopics = map[string]map[string]string{
|
|||
NOTE: this can lead to some UI bugs.
|
||||
`),
|
||||
},
|
||||
"environment": {
|
||||
"short": "Environment variables that can be used with gh",
|
||||
"long": heredoc.Doc(`
|
||||
{
|
||||
name: "environment",
|
||||
short: "Environment variables that can be used with gh",
|
||||
long: heredoc.Doc(`
|
||||
GH_TOKEN, GITHUB_TOKEN (in order of precedence): an authentication token for github.com
|
||||
API requests. Setting this avoids being prompted to authenticate and takes precedence over
|
||||
previously stored credentials.
|
||||
|
|
@ -41,7 +50,8 @@ var HelpTopics = map[string]map[string]string{
|
|||
token for API requests to GitHub Enterprise. When setting this, also set GH_HOST.
|
||||
|
||||
GH_HOST: specify the GitHub hostname for commands that would otherwise assume the
|
||||
"github.com" host when not in a context of an existing repository.
|
||||
"github.com" host when not in a context of an existing repository. When setting this,
|
||||
also set GH_ENTERPRISE_TOKEN.
|
||||
|
||||
GH_REPO: specify the GitHub repository in the "[HOST/]OWNER/REPO" format for commands
|
||||
that otherwise operate on a local repository.
|
||||
|
|
@ -91,12 +101,14 @@ var HelpTopics = map[string]map[string]string{
|
|||
its own path such as in the cygwin terminal.
|
||||
`),
|
||||
},
|
||||
"reference": {
|
||||
"short": "A comprehensive reference of all gh commands",
|
||||
{
|
||||
name: "reference",
|
||||
short: "A comprehensive reference of all gh commands",
|
||||
},
|
||||
"formatting": {
|
||||
"short": "Formatting options for JSON data exported from gh",
|
||||
"long": heredoc.Docf(`
|
||||
{
|
||||
name: "formatting",
|
||||
short: "Formatting options for JSON data exported from gh",
|
||||
long: heredoc.Docf(`
|
||||
By default, the result of %[1]sgh%[1]s commands are output in line-based plain text format.
|
||||
Some commands support passing the %[1]s--json%[1]s flag, which converts the output to JSON format.
|
||||
Once in JSON, the output can be further formatted according to a required formatting string by
|
||||
|
|
@ -132,7 +144,7 @@ var HelpTopics = map[string]map[string]string{
|
|||
|
||||
To learn more about Go templates, see: <https://golang.org/pkg/text/template/>.
|
||||
`, "`"),
|
||||
"example": heredoc.Doc(`
|
||||
example: heredoc.Doc(`
|
||||
# default output format
|
||||
$ gh pr list
|
||||
Showing 23 of 23 open pull requests in cli/cli
|
||||
|
|
@ -242,9 +254,10 @@ var HelpTopics = map[string]map[string]string{
|
|||
mislav COMMENTED This is going along great! Thanks for working on this ❤️
|
||||
`),
|
||||
},
|
||||
"exit-codes": {
|
||||
"short": "Exit codes used by gh",
|
||||
"long": heredoc.Doc(`
|
||||
{
|
||||
name: "exit-codes",
|
||||
short: "Exit codes used by gh",
|
||||
long: heredoc.Doc(`
|
||||
gh follows normal conventions regarding exit codes.
|
||||
|
||||
- If a command completes successfully, the exit code will be 0
|
||||
|
|
@ -262,30 +275,31 @@ var HelpTopics = map[string]map[string]string{
|
|||
},
|
||||
}
|
||||
|
||||
func NewHelpTopic(ios *iostreams.IOStreams, topic string) *cobra.Command {
|
||||
func NewCmdHelpTopic(ios *iostreams.IOStreams, ht helpTopic) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: topic,
|
||||
Short: HelpTopics[topic]["short"],
|
||||
Long: HelpTopics[topic]["long"],
|
||||
Example: HelpTopics[topic]["example"],
|
||||
Use: ht.name,
|
||||
Short: ht.short,
|
||||
Long: ht.long,
|
||||
Example: ht.example,
|
||||
Hidden: true,
|
||||
Annotations: map[string]string{
|
||||
"markdown:generate": "true",
|
||||
"markdown:basename": "gh_help_" + topic,
|
||||
"markdown:basename": "gh_help_" + ht.name,
|
||||
},
|
||||
}
|
||||
|
||||
cmd.SetHelpFunc(func(c *cobra.Command, args []string) {
|
||||
helpTopicHelpFunc(ios.Out, c, args)
|
||||
})
|
||||
cmd.SetUsageFunc(func(c *cobra.Command) error {
|
||||
return helpTopicUsageFunc(ios.ErrOut, c)
|
||||
})
|
||||
|
||||
cmd.SetHelpFunc(func(c *cobra.Command, _ []string) {
|
||||
helpTopicHelpFunc(ios.Out, c)
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func helpTopicHelpFunc(w io.Writer, command *cobra.Command, args []string) {
|
||||
func helpTopicHelpFunc(w io.Writer, command *cobra.Command) {
|
||||
fmt.Fprint(w, command.Long)
|
||||
if command.Example != "" {
|
||||
fmt.Fprintf(w, "\n\nEXAMPLES\n")
|
||||
|
|
|
|||
|
|
@ -4,76 +4,85 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewHelpTopic(t *testing.T) {
|
||||
func TestCmdHelpTopic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
topic string
|
||||
args []string
|
||||
flags []string
|
||||
wantsErr bool
|
||||
name string
|
||||
topic helpTopic
|
||||
args []string
|
||||
flags []string
|
||||
errorAssertion require.ErrorAssertionFunc
|
||||
expectedAnnotations map[string]string
|
||||
}{
|
||||
{
|
||||
name: "valid topic",
|
||||
topic: "environment",
|
||||
args: []string{},
|
||||
flags: []string{},
|
||||
wantsErr: false,
|
||||
name: "when there are no args or flags, prints the long message to stdout",
|
||||
topic: helpTopic{name: "test-topic", long: "test-topic-long"},
|
||||
args: []string{},
|
||||
flags: []string{},
|
||||
errorAssertion: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "invalid topic",
|
||||
topic: "invalid",
|
||||
args: []string{},
|
||||
flags: []string{},
|
||||
wantsErr: false,
|
||||
name: "when an arg is provided, it is ignored and help is printed",
|
||||
topic: helpTopic{name: "test-topic"},
|
||||
args: []string{"anything"},
|
||||
flags: []string{},
|
||||
errorAssertion: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "more than zero args",
|
||||
topic: "environment",
|
||||
args: []string{"invalid"},
|
||||
flags: []string{},
|
||||
wantsErr: false,
|
||||
name: "when a flag is provided, returns an error",
|
||||
topic: helpTopic{name: "test-topic"},
|
||||
args: []string{},
|
||||
flags: []string{"--anything"},
|
||||
errorAssertion: require.Error,
|
||||
},
|
||||
{
|
||||
name: "more than zero flags",
|
||||
topic: "environment",
|
||||
args: []string{},
|
||||
flags: []string{"--invalid"},
|
||||
wantsErr: true,
|
||||
name: "when there is an example, include it in the stdout",
|
||||
topic: helpTopic{name: "test-topic", example: "test-topic-example"},
|
||||
args: []string{},
|
||||
flags: []string{},
|
||||
errorAssertion: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "help arg",
|
||||
topic: "environment",
|
||||
args: []string{"help"},
|
||||
flags: []string{},
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "help flag",
|
||||
topic: "environment",
|
||||
args: []string{},
|
||||
flags: []string{"--help"},
|
||||
wantsErr: false,
|
||||
name: "sets important markdown annotations on the command",
|
||||
topic: helpTopic{name: "test-topic"},
|
||||
args: []string{},
|
||||
flags: []string{},
|
||||
errorAssertion: require.NoError,
|
||||
expectedAnnotations: map[string]string{
|
||||
"markdown:generate": "true",
|
||||
"markdown:basename": "gh_help_test-topic",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, _, stderr := iostreams.Test()
|
||||
t.Parallel()
|
||||
|
||||
cmd := NewHelpTopic(ios, tt.topic)
|
||||
ios, _, stdout, _ := iostreams.Test()
|
||||
|
||||
cmd := NewCmdHelpTopic(ios, tt.topic)
|
||||
cmd.SetArgs(append(tt.args, tt.flags...))
|
||||
cmd.SetOut(stderr)
|
||||
cmd.SetErr(stderr)
|
||||
|
||||
_, err := cmd.ExecuteC()
|
||||
if tt.wantsErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
tt.errorAssertion(t, err)
|
||||
|
||||
if tt.topic.long != "" {
|
||||
require.Contains(t, stdout.String(), tt.topic.long)
|
||||
}
|
||||
|
||||
if tt.topic.example != "" {
|
||||
require.Contains(t, stdout.String(), tt.topic.example)
|
||||
}
|
||||
|
||||
if len(tt.expectedAnnotations) > 0 {
|
||||
require.Equal(t, cmd.Annotations, tt.expectedAnnotations)
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,18 +127,26 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
|
|||
cmd.AddCommand(cacheCmd.NewCmdCache(&repoResolvingCmdFactory))
|
||||
|
||||
// Help topics
|
||||
cmd.AddCommand(NewHelpTopic(f.IOStreams, "environment"))
|
||||
cmd.AddCommand(NewHelpTopic(f.IOStreams, "formatting"))
|
||||
cmd.AddCommand(NewHelpTopic(f.IOStreams, "mintty"))
|
||||
cmd.AddCommand(NewHelpTopic(f.IOStreams, "exit-codes"))
|
||||
referenceCmd := NewHelpTopic(f.IOStreams, "reference")
|
||||
referenceCmd.SetHelpFunc(referenceHelpFn(f.IOStreams))
|
||||
cmd.AddCommand(referenceCmd)
|
||||
var referenceCmd *cobra.Command
|
||||
for _, ht := range HelpTopics {
|
||||
helpTopicCmd := NewCmdHelpTopic(f.IOStreams, ht)
|
||||
cmd.AddCommand(helpTopicCmd)
|
||||
|
||||
// See bottom of the function for why we explicitly care about the reference cmd
|
||||
if ht.name == "reference" {
|
||||
referenceCmd = helpTopicCmd
|
||||
}
|
||||
}
|
||||
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
// this needs to appear last:
|
||||
referenceCmd.Long = referenceLong(cmd)
|
||||
// The reference command produces paged output that displays information on every other command.
|
||||
// Therefore, we explicitly set the Long text and HelpFunc here after all other commands are registered.
|
||||
// We experimented with producing the paged output dynamically when the HelpFunc is called but since
|
||||
// doc generation makes use of the Long text, it is simpler to just be explicit here that this command
|
||||
// is special.
|
||||
referenceCmd.Long = stringifyReference(cmd)
|
||||
referenceCmd.SetHelpFunc(longPager(f.IOStreams))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue