diff --git a/command/alias.go b/command/alias.go deleted file mode 100644 index 64b6ab19e..000000000 --- a/command/alias.go +++ /dev/null @@ -1,233 +0,0 @@ -package command - -import ( - "fmt" - "sort" - "strings" - - "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/pkg/iostreams" - "github.com/cli/cli/utils" - "github.com/google/shlex" - "github.com/spf13/cobra" -) - -func init() { - aliasCmd.AddCommand(aliasSetCmd) - aliasCmd.AddCommand(aliasListCmd) - aliasCmd.AddCommand(aliasDeleteCmd) - - aliasSetCmd.Flags().BoolP("shell", "s", false, "Declare an alias to be passed through a shell interpreter") -} - -var aliasCmd = &cobra.Command{ - Use: "alias", - Short: "Create command shortcuts", - Long: heredoc.Doc(` - Aliases can be used to make shortcuts for gh commands or to compose multiple commands. - - Run "gh help alias set" to learn more. - `), -} - -var aliasSetCmd = &cobra.Command{ - Use: "set ", - Short: "Create a shortcut for a gh command", - Long: heredoc.Doc(` - Declare a word as a command alias that will expand to the specified command(s). - - The expansion may specify additional arguments and flags. If the expansion - includes positional placeholders such as '$1', '$2', etc., any extra arguments - that follow the invocation of an alias will be inserted appropriately. - - If '--shell' is specified, the alias will be run through a shell interpreter (sh). This allows you - to compose commands with "|" or redirect with ">". Note that extra arguments following the alias - will not be automatically passed to the expanded expression. To have a shell alias receive - arguments, you must explicitly accept them using "$1", "$2", etc., or "$@" to accept all of them. - - Platform note: on Windows, shell aliases are executed via "sh" as installed by Git For Windows. If - you have installed git on Windows in some other way, shell aliases may not work for you. - - Quotes must always be used when defining a command as in the examples.`), - Example: heredoc.Doc(` - $ gh alias set pv 'pr view' - $ gh pv -w 123 - #=> gh pr view -w 123 - - $ gh alias set bugs 'issue list --label="bugs"' - - $ gh alias set epicsBy 'issue list --author="$1" --label="epic"' - $ gh epicsBy vilmibm - #=> gh issue list --author="vilmibm" --label="epic" - - $ gh alias set --shell igrep 'gh issue list --label="$1" | grep $2' - $ gh igrep epic foo - #=> gh issue list --label="epic" | grep "foo"`), - Args: cobra.ExactArgs(2), - RunE: aliasSet, -} - -func aliasSet(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - cfg, err := ctx.Config() - if err != nil { - return err - } - - aliasCfg, err := cfg.Aliases() - if err != nil { - return err - } - - alias := args[0] - expansion := args[1] - - stderr := colorableErr(cmd) - if connectedToTerminal(cmd) { - fmt.Fprintf(stderr, "- Adding alias for %s: %s\n", utils.Bold(alias), utils.Bold(expansion)) - } - - shell, err := cmd.Flags().GetBool("shell") - if err != nil { - return err - } - if shell && !strings.HasPrefix(expansion, "!") { - expansion = "!" + expansion - } - isExternal := strings.HasPrefix(expansion, "!") - - if validCommand(alias) { - return fmt.Errorf("could not create alias: %q is already a gh command", alias) - } - - if !isExternal && !validCommand(expansion) { - return fmt.Errorf("could not create alias: %s does not correspond to a gh command", expansion) - } - - successMsg := fmt.Sprintf("%s Added alias.", utils.Green("✓")) - - oldExpansion, ok := aliasCfg.Get(alias) - if ok { - successMsg = fmt.Sprintf("%s Changed alias %s from %s to %s", - utils.Green("✓"), - utils.Bold(alias), - utils.Bold(oldExpansion), - utils.Bold(expansion), - ) - } - - err = aliasCfg.Add(alias, expansion) - if err != nil { - return fmt.Errorf("could not create alias: %s", err) - } - - if connectedToTerminal(cmd) { - fmt.Fprintln(stderr, successMsg) - } - - return nil -} - -func validCommand(expansion string) bool { - split, err := shlex.Split(expansion) - if err != nil { - return false - } - cmd, _, err := RootCmd.Traverse(split) - return err == nil && cmd != RootCmd -} - -var aliasListCmd = &cobra.Command{ - Use: "list", - Short: "List your aliases", - Long: `This command prints out all of the aliases gh is configured to use.`, - Args: cobra.ExactArgs(0), - RunE: aliasList, -} - -func aliasList(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - cfg, err := ctx.Config() - if err != nil { - return fmt.Errorf("couldn't read config: %w", err) - } - - aliasCfg, err := cfg.Aliases() - if err != nil { - return fmt.Errorf("couldn't read aliases config: %w", err) - } - - stderr := colorableErr(cmd) - - if aliasCfg.Empty() { - if connectedToTerminal(cmd) { - fmt.Fprintf(stderr, "no aliases configured\n") - } - return nil - } - - tp := utils.NewTablePrinter(&iostreams.IOStreams{ - Out: cmd.OutOrStdout(), - }) - - aliasMap := aliasCfg.All() - keys := []string{} - for alias := range aliasMap { - keys = append(keys, alias) - } - sort.Strings(keys) - - for _, alias := range keys { - if tp.IsTTY() { - // ensure that screen readers pause - tp.AddField(alias+":", nil, nil) - } else { - tp.AddField(alias, nil, nil) - } - tp.AddField(aliasMap[alias], nil, nil) - tp.EndRow() - } - - return tp.Render() -} - -var aliasDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Delete an alias.", - Args: cobra.ExactArgs(1), - RunE: aliasDelete, -} - -func aliasDelete(cmd *cobra.Command, args []string) error { - alias := args[0] - - ctx := contextForCommand(cmd) - cfg, err := ctx.Config() - if err != nil { - return fmt.Errorf("couldn't read config: %w", err) - } - - aliasCfg, err := cfg.Aliases() - if err != nil { - return fmt.Errorf("couldn't read aliases config: %w", err) - } - - expansion, ok := aliasCfg.Get(alias) - if !ok { - return fmt.Errorf("no such alias %s", alias) - - } - - err = aliasCfg.Delete(alias) - if err != nil { - return fmt.Errorf("failed to delete alias %s: %w", alias, err) - } - - if connectedToTerminal(cmd) { - stderr := colorableErr(cmd) - redCheck := utils.Red("✓") - fmt.Fprintf(stderr, "%s Deleted alias %s; was %s\n", redCheck, alias, expansion) - } - - return nil -} diff --git a/command/alias_test.go b/command/alias_test.go index 63646f7a6..7bb76f553 100644 --- a/command/alias_test.go +++ b/command/alias_test.go @@ -1,12 +1,9 @@ package command import ( - "bytes" "strings" "testing" - "github.com/cli/cli/internal/config" - "github.com/cli/cli/test" "github.com/stretchr/testify/assert" ) @@ -20,180 +17,6 @@ func stubSh(value string) func() { } } -func TestAliasSet_gh_command(t *testing.T) { - initBlankContext("", "OWNER/REPO", "trunk") - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - _, err := RunCommand("alias set pr 'pr status'") - if err == nil { - t.Fatal("expected error") - } - - eq(t, err.Error(), `could not create alias: "pr" is already a gh command`) -} - -func TestAliasSet_empty_aliases(t *testing.T) { - cfg := `--- -aliases: -editor: vim -` - initBlankContext(cfg, "OWNER/REPO", "trunk") - - defer stubTerminal(true)() - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand("alias set co 'pr checkout'") - - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - test.ExpectLines(t, output.Stderr(), "Added alias") - test.ExpectLines(t, output.String(), "") - - expected := `aliases: - co: pr checkout -editor: vim -` - eq(t, mainBuf.String(), expected) -} - -func TestAliasSet_existing_alias(t *testing.T) { - cfg := `--- -hosts: - github.com: - user: OWNER - oauth_token: token123 -aliases: - co: pr checkout -` - initBlankContext(cfg, "OWNER/REPO", "trunk") - defer stubTerminal(true)() - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand("alias set co 'pr checkout -Rcool/repo'") - - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - test.ExpectLines(t, output.Stderr(), "Changed alias.*co.*from.*pr checkout.*to.*pr checkout -Rcool/repo") -} - -func TestAliasSet_space_args(t *testing.T) { - initBlankContext("", "OWNER/REPO", "trunk") - defer stubTerminal(true)() - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand(`alias set il 'issue list -l "cool story"'`) - - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - test.ExpectLines(t, output.Stderr(), `Adding alias for.*il.*issue list -l "cool story"`) - - test.ExpectLines(t, mainBuf.String(), `il: issue list -l "cool story"`) -} - -func TestAliasSet_arg_processing(t *testing.T) { - initBlankContext("", "OWNER/REPO", "trunk") - defer stubTerminal(true)() - cases := []struct { - Cmd string - ExpectedOutputLine string - ExpectedConfigLine string - }{ - {`alias set il "issue list"`, "- Adding alias for.*il.*issue list", "il: issue list"}, - - {`alias set iz 'issue list'`, "- Adding alias for.*iz.*issue list", "iz: issue list"}, - - {`alias set ii 'issue list --author="$1" --label="$2"'`, - `- Adding alias for.*ii.*issue list --author="\$1" --label="\$2"`, - `ii: issue list --author="\$1" --label="\$2"`}, - - {`alias set ix "issue list --author='\$1' --label='\$2'"`, - `- Adding alias for.*ix.*issue list --author='\$1' --label='\$2'`, - `ix: issue list --author='\$1' --label='\$2'`}, - } - - for _, c := range cases { - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand(c.Cmd) - if err != nil { - t.Fatalf("got unexpected error running %s: %s", c.Cmd, err) - } - - test.ExpectLines(t, output.Stderr(), c.ExpectedOutputLine) - test.ExpectLines(t, mainBuf.String(), c.ExpectedConfigLine) - } -} - -func TestAliasSet_init_alias_cfg(t *testing.T) { - cfg := `--- -editor: vim -` - initBlankContext(cfg, "OWNER/REPO", "trunk") - defer stubTerminal(true)() - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand("alias set diff 'pr diff'") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - expected := `editor: vim -aliases: - diff: pr diff -` - - test.ExpectLines(t, output.Stderr(), "Adding alias for.*diff.*pr diff", "Added alias.") - eq(t, mainBuf.String(), expected) -} - -func TestAliasSet_existing_aliases(t *testing.T) { - cfg := `--- -aliases: - foo: bar -` - initBlankContext(cfg, "OWNER/REPO", "trunk") - defer stubTerminal(true)() - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand("alias set view 'pr view'") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - expected := `aliases: - foo: bar - view: pr view -` - - test.ExpectLines(t, output.Stderr(), "Adding alias for.*view.*pr view", "Added alias.") - eq(t, mainBuf.String(), expected) - -} - func TestExpandAlias_shell(t *testing.T) { defer stubSh("sh")() cfg := `--- @@ -284,140 +107,3 @@ aliases: assert.Equal(t, c.ExpectedArgs, expanded) } } - -func TestAliasSet_invalid_command(t *testing.T) { - initBlankContext("", "OWNER/REPO", "trunk") - _, err := RunCommand("alias set co 'pe checkout'") - if err == nil { - t.Fatal("expected error") - } - - eq(t, err.Error(), "could not create alias: pe checkout does not correspond to a gh command") -} - -func TestAliasList_empty(t *testing.T) { - initBlankContext("", "OWNER/REPO", "trunk") - - output, err := RunCommand("alias list") - - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - eq(t, output.String(), "") -} - -func TestAliasList(t *testing.T) { - cfg := `--- -aliases: - co: pr checkout - il: issue list --author=$1 --label=$2 - clone: repo clone - prs: pr status - cs: config set editor 'quoted path' -` - initBlankContext(cfg, "OWNER/REPO", "trunk") - - output, err := RunCommand("alias list") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - expected := `clone repo clone -co pr checkout -cs config set editor 'quoted path' -il issue list --author=$1 --label=$2 -prs pr status -` - - eq(t, output.String(), expected) -} - -func TestAliasDelete_nonexistent_command(t *testing.T) { - cfg := `--- -aliases: - co: pr checkout - il: issue list --author="$1" --label="$2" - ia: issue list --author="$1" --assignee="$1" -` - initBlankContext(cfg, "OWNER/REPO", "trunk") - - _, err := RunCommand("alias delete cool") - if err == nil { - t.Fatalf("expected error") - } - - eq(t, err.Error(), "no such alias cool") -} - -func TestAliasDelete(t *testing.T) { - cfg := `--- -aliases: - co: pr checkout - il: issue list --author="$1" --label="$2" - ia: issue list --author="$1" --assignee="$1" -` - initBlankContext(cfg, "OWNER/REPO", "trunk") - defer stubTerminal(true)() - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand("alias delete co") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - test.ExpectLines(t, output.Stderr(), "Deleted alias co; was pr checkout") - - expected := `aliases: - il: issue list --author="$1" --label="$2" - ia: issue list --author="$1" --assignee="$1" -` - - eq(t, mainBuf.String(), expected) -} - -func TestShellAlias_flag(t *testing.T) { - initBlankContext("", "OWNER/REPO", "trunk") - - defer stubTerminal(true)() - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand("alias set --shell igrep 'gh issue list | grep'") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - test.ExpectLines(t, output.Stderr(), "Adding alias for.*igrep") - expected := `aliases: - igrep: '!gh issue list | grep' -` - - eq(t, mainBuf.String(), expected) -} - -func TestShellAlias_bang(t *testing.T) { - initBlankContext("", "OWNER/REPO", "trunk") - - defer stubTerminal(true)() - - mainBuf := bytes.Buffer{} - hostsBuf := bytes.Buffer{} - defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - - output, err := RunCommand("alias set igrep '!gh issue list | grep'") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - test.ExpectLines(t, output.Stderr(), "Adding alias for.*igrep") - expected := `aliases: - igrep: '!gh issue list | grep' -` - - eq(t, mainBuf.String(), expected) -} diff --git a/command/root.go b/command/root.go index 8c3b17e51..1ff53e783 100644 --- a/command/root.go +++ b/command/root.go @@ -3,7 +3,6 @@ package command import ( "errors" "fmt" - "io" "os" "os/exec" "path/filepath" @@ -17,12 +16,10 @@ import ( "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/run" - configCmd "github.com/cli/cli/pkg/cmd/config" "github.com/cli/cli/pkg/cmd/factory" "github.com/cli/cli/pkg/cmd/root" "github.com/cli/cli/utils" "github.com/google/shlex" - "github.com/spf13/cobra" ) @@ -43,9 +40,6 @@ func init() { cmdFactory := factory.New(Version) RootCmd = root.NewCmdRoot(cmdFactory, Version, BuildDate) - RootCmd.AddCommand(aliasCmd) - RootCmd.AddCommand(root.NewCmdCompletion(cmdFactory.IOStreams)) - RootCmd.AddCommand(configCmd.NewCmdConfig(cmdFactory)) } // overridden in tests @@ -74,24 +68,12 @@ func BasicClient() (*api.Client, error) { return api.NewClient(opts...), nil } -func contextForCommand(cmd *cobra.Command) context.Context { - return initContext() -} - func apiVerboseLog() api.ClientOption { logTraffic := strings.Contains(os.Getenv("DEBUG"), "api") colorize := utils.IsTerminal(os.Stderr) return api.VerboseLog(utils.NewColorable(os.Stderr), logTraffic, colorize) } -func colorableErr(cmd *cobra.Command) io.Writer { - err := cmd.ErrOrStderr() - if outFile, isFile := err.(*os.File); isFile { - return utils.NewColorable(outFile) - } - return err -} - func ExecuteShellAlias(args []string) error { externalCmd := exec.Command(args[0], args[1:]...) externalCmd.Stderr = os.Stderr @@ -198,7 +180,3 @@ func ExpandAlias(args []string) (expanded []string, isShell bool, err error) { expanded = args[1:] return } - -func connectedToTerminal(cmd *cobra.Command) bool { - return utils.IsTerminal(cmd.InOrStdin()) && utils.IsTerminal(cmd.OutOrStdout()) -} diff --git a/internal/config/config_file.go b/internal/config/config_file.go index 2f97d2956..d4c132360 100644 --- a/internal/config/config_file.go +++ b/internal/config/config_file.go @@ -75,22 +75,30 @@ func parseConfigFile(filename string) ([]byte, *yaml.Node, error) { return nil, nil, err } - var root yaml.Node - err = yaml.Unmarshal(data, &root) + root, err := parseConfigData(data) if err != nil { - return data, nil, err + return nil, nil, err } + return data, root, err +} + +func parseConfigData(data []byte) (*yaml.Node, error) { + var root yaml.Node + err := yaml.Unmarshal(data, &root) + if err != nil { + return nil, err + } + if len(root.Content) == 0 { - return data, &yaml.Node{ + return &yaml.Node{ Kind: yaml.DocumentNode, Content: []*yaml.Node{{Kind: yaml.MappingNode}}, }, nil } if root.Content[0].Kind != yaml.MappingNode { - return data, &root, fmt.Errorf("expected a top level map") + return &root, fmt.Errorf("expected a top level map") } - - return data, &root, nil + return &root, nil } func isLegacy(root *yaml.Node) bool { diff --git a/internal/config/config_type.go b/internal/config/config_type.go index 85e5c0d4a..721c4340e 100644 --- a/internal/config/config_type.go +++ b/internal/config/config_type.go @@ -122,6 +122,16 @@ func NewConfig(root *yaml.Node) Config { } } +// NewFromString initializes a Config from a yaml string +func NewFromString(str string) Config { + root, err := parseConfigData([]byte(str)) + if err != nil { + panic(err) + } + return NewConfig(root) +} + +// NewBlankConfig initializes a config file pre-populated with comments and default values func NewBlankConfig() Config { return NewConfig(NewBlankRoot()) } diff --git a/pkg/cmd/alias/alias.go b/pkg/cmd/alias/alias.go new file mode 100644 index 000000000..20bc26e83 --- /dev/null +++ b/pkg/cmd/alias/alias.go @@ -0,0 +1,28 @@ +package alias + +import ( + "github.com/MakeNowJust/heredoc" + deleteCmd "github.com/cli/cli/pkg/cmd/alias/delete" + listCmd "github.com/cli/cli/pkg/cmd/alias/list" + setCmd "github.com/cli/cli/pkg/cmd/alias/set" + "github.com/cli/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdAlias(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "alias", + Short: "Create command shortcuts", + Long: heredoc.Doc(` + Aliases can be used to make shortcuts for gh commands or to compose multiple commands. + + Run "gh help alias set" to learn more. + `), + } + + cmd.AddCommand(deleteCmd.NewCmdDelete(f, nil)) + cmd.AddCommand(listCmd.NewCmdList(f, nil)) + cmd.AddCommand(setCmd.NewCmdSet(f, nil)) + + return cmd +} diff --git a/pkg/cmd/alias/delete/delete.go b/pkg/cmd/alias/delete/delete.go new file mode 100644 index 000000000..ccf98ca68 --- /dev/null +++ b/pkg/cmd/alias/delete/delete.go @@ -0,0 +1,71 @@ +package delete + +import ( + "fmt" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type DeleteOptions struct { + Config func() (config.Config, error) + IO *iostreams.IOStreams + + Name string +} + +func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := &DeleteOptions{ + IO: f.IOStreams, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an alias", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Name = args[0] + + if runF != nil { + return runF(opts) + } + return deleteRun(opts) + }, + } + + return cmd +} + +func deleteRun(opts *DeleteOptions) error { + cfg, err := opts.Config() + if err != nil { + return err + } + + aliasCfg, err := cfg.Aliases() + if err != nil { + return fmt.Errorf("couldn't read aliases config: %w", err) + } + + expansion, ok := aliasCfg.Get(opts.Name) + if !ok { + return fmt.Errorf("no such alias %s", opts.Name) + + } + + err = aliasCfg.Delete(opts.Name) + if err != nil { + return fmt.Errorf("failed to delete alias %s: %w", opts.Name, err) + } + + if opts.IO.IsStdoutTTY() { + redCheck := utils.Red("✓") + fmt.Fprintf(opts.IO.ErrOut, "%s Deleted alias %s; was %s\n", redCheck, opts.Name, expansion) + } + + return nil +} diff --git a/pkg/cmd/alias/delete/delete_test.go b/pkg/cmd/alias/delete/delete_test.go new file mode 100644 index 000000000..3c6acea28 --- /dev/null +++ b/pkg/cmd/alias/delete/delete_test.go @@ -0,0 +1,90 @@ +package delete + +import ( + "bytes" + "io/ioutil" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAliasDelete(t *testing.T) { + tests := []struct { + name string + config string + cli string + isTTY bool + wantStdout string + wantStderr string + wantErr string + }{ + { + name: "no aliases", + config: "", + cli: "co", + isTTY: true, + wantStdout: "", + wantStderr: "", + wantErr: "no such alias co", + }, + { + name: "delete one", + config: heredoc.Doc(` + aliases: + il: issue list + co: pr checkout + `), + cli: "co", + isTTY: true, + wantStdout: "", + wantStderr: "✓ Deleted alias co; was pr checkout\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer config.StubWriteConfig(ioutil.Discard, ioutil.Discard)() + + cfg := config.NewFromString(tt.config) + + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(tt.isTTY) + io.SetStdinTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + Config: func() (config.Config, error) { + return cfg, nil + }, + } + + cmd := NewCmdDelete(factory, nil) + + argv, err := shlex.Split(tt.cli) + require.NoError(t, err) + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + if assert.Error(t, err) { + assert.Equal(t, tt.wantErr, err.Error()) + } + return + } + require.NoError(t, err) + + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} diff --git a/pkg/cmd/alias/list/list.go b/pkg/cmd/alias/list/list.go new file mode 100644 index 000000000..b9d67c2ae --- /dev/null +++ b/pkg/cmd/alias/list/list.go @@ -0,0 +1,83 @@ +package list + +import ( + "fmt" + "sort" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ListOptions struct { + Config func() (config.Config, error) + IO *iostreams.IOStreams +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List your aliases", + Long: heredoc.Doc(` + This command prints out all of the aliases gh is configured to use. + `), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + + return cmd +} + +func listRun(opts *ListOptions) error { + cfg, err := opts.Config() + if err != nil { + return err + } + + aliasCfg, err := cfg.Aliases() + if err != nil { + return fmt.Errorf("couldn't read aliases config: %w", err) + } + + if aliasCfg.Empty() { + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "no aliases configured\n") + } + return nil + } + + tp := utils.NewTablePrinter(opts.IO) + + aliasMap := aliasCfg.All() + keys := []string{} + for alias := range aliasMap { + keys = append(keys, alias) + } + sort.Strings(keys) + + for _, alias := range keys { + if tp.IsTTY() { + // ensure that screen readers pause + tp.AddField(alias+":", nil, nil) + } else { + tp.AddField(alias, nil, nil) + } + tp.AddField(aliasMap[alias], nil, nil) + tp.EndRow() + } + + return tp.Render() +} diff --git a/pkg/cmd/alias/list/list_test.go b/pkg/cmd/alias/list/list_test.go new file mode 100644 index 000000000..b058a04ce --- /dev/null +++ b/pkg/cmd/alias/list/list_test.go @@ -0,0 +1,77 @@ +package list + +import ( + "bytes" + "io/ioutil" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAliasList(t *testing.T) { + tests := []struct { + name string + config string + isTTY bool + wantStdout string + wantStderr string + }{ + { + name: "empty", + config: "", + isTTY: true, + wantStdout: "", + wantStderr: "no aliases configured\n", + }, + { + name: "some", + config: heredoc.Doc(` + aliases: + co: pr checkout + gc: "!gh gist create \"$@\" | pbcopy" + `), + isTTY: true, + wantStdout: "co: pr checkout\ngc: !gh gist create \"$@\" | pbcopy\n", + wantStderr: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // TODO: change underlying config implementation so Write is not + // automatically called when editing aliases in-memory + defer config.StubWriteConfig(ioutil.Discard, ioutil.Discard)() + + cfg := config.NewFromString(tt.config) + + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(tt.isTTY) + io.SetStdinTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + Config: func() (config.Config, error) { + return cfg, nil + }, + } + + cmd := NewCmdList(factory, nil) + cmd.SetArgs([]string{}) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err := cmd.ExecuteC() + require.NoError(t, err) + + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} diff --git a/pkg/cmd/alias/set/set.go b/pkg/cmd/alias/set/set.go new file mode 100644 index 000000000..a9f628471 --- /dev/null +++ b/pkg/cmd/alias/set/set.go @@ -0,0 +1,147 @@ +package set + +import ( + "fmt" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/google/shlex" + "github.com/spf13/cobra" +) + +type SetOptions struct { + Config func() (config.Config, error) + IO *iostreams.IOStreams + + Name string + Expansion string + IsShell bool + RootCmd *cobra.Command +} + +func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command { + opts := &SetOptions{ + IO: f.IOStreams, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "set ", + Short: "Create a shortcut for a gh command", + Long: heredoc.Doc(` + Declare a word as a command alias that will expand to the specified command(s). + + The expansion may specify additional arguments and flags. If the expansion + includes positional placeholders such as '$1', '$2', etc., any extra arguments + that follow the invocation of an alias will be inserted appropriately. + + If '--shell' is specified, the alias will be run through a shell interpreter (sh). This allows you + to compose commands with "|" or redirect with ">". Note that extra arguments following the alias + will not be automatically passed to the expanded expression. To have a shell alias receive + arguments, you must explicitly accept them using "$1", "$2", etc., or "$@" to accept all of them. + + Platform note: on Windows, shell aliases are executed via "sh" as installed by Git For Windows. If + you have installed git on Windows in some other way, shell aliases may not work for you. + + Quotes must always be used when defining a command as in the examples. + `), + Example: heredoc.Doc(` + $ gh alias set pv 'pr view' + $ gh pv -w 123 + #=> gh pr view -w 123 + + $ gh alias set bugs 'issue list --label="bugs"' + + $ gh alias set epicsBy 'issue list --author="$1" --label="epic"' + $ gh epicsBy vilmibm + #=> gh issue list --author="vilmibm" --label="epic" + + $ gh alias set --shell igrep 'gh issue list --label="$1" | grep $2' + $ gh igrep epic foo + #=> gh issue list --label="epic" | grep "foo" + `), + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + opts.RootCmd = cmd.Root() + + opts.Name = args[0] + opts.Expansion = args[1] + + if runF != nil { + return runF(opts) + } + return setRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.IsShell, "shell", "s", false, "Declare an alias to be passed through a shell interpreter") + + return cmd +} + +func setRun(opts *SetOptions) error { + cfg, err := opts.Config() + if err != nil { + return err + } + + aliasCfg, err := cfg.Aliases() + if err != nil { + return err + } + + isTerminal := opts.IO.IsStdoutTTY() + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "- Adding alias for %s: %s\n", utils.Bold(opts.Name), utils.Bold(opts.Expansion)) + } + + expansion := opts.Expansion + isShell := opts.IsShell + if isShell && !strings.HasPrefix(expansion, "!") { + expansion = "!" + expansion + } + isShell = strings.HasPrefix(expansion, "!") + + if validCommand(opts.RootCmd, opts.Name) { + return fmt.Errorf("could not create alias: %q is already a gh command", opts.Name) + } + + if !isShell && !validCommand(opts.RootCmd, expansion) { + return fmt.Errorf("could not create alias: %s does not correspond to a gh command", expansion) + } + + successMsg := fmt.Sprintf("%s Added alias.", utils.Green("✓")) + if oldExpansion, ok := aliasCfg.Get(opts.Name); ok { + successMsg = fmt.Sprintf("%s Changed alias %s from %s to %s", + utils.Green("✓"), + utils.Bold(opts.Name), + utils.Bold(oldExpansion), + utils.Bold(expansion), + ) + } + + err = aliasCfg.Add(opts.Name, expansion) + if err != nil { + return fmt.Errorf("could not create alias: %s", err) + } + + if isTerminal { + fmt.Fprintln(opts.IO.ErrOut, successMsg) + } + + return nil +} + +func validCommand(rootCmd *cobra.Command, expansion string) bool { + split, err := shlex.Split(expansion) + if err != nil { + return false + } + + cmd, _, err := rootCmd.Traverse(split) + return err == nil && cmd != rootCmd +} diff --git a/pkg/cmd/alias/set/set_test.go b/pkg/cmd/alias/set/set_test.go new file mode 100644 index 000000000..95f3a5031 --- /dev/null +++ b/pkg/cmd/alias/set/set_test.go @@ -0,0 +1,252 @@ +package set + +import ( + "bytes" + "io/ioutil" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCommand(cfg config.Config, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + Config: func() (config.Config, error) { + return cfg, nil + }, + } + + cmd := NewCmdSet(factory, nil) + + // fake command nesting structure needed for validCommand + rootCmd := &cobra.Command{} + rootCmd.AddCommand(cmd) + 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) + + argv, err := shlex.Split("set " + cli) + if err != nil { + return nil, err + } + rootCmd.SetArgs(argv) + + rootCmd.SetIn(&bytes.Buffer{}) + rootCmd.SetOut(ioutil.Discard) + rootCmd.SetErr(ioutil.Discard) + + _, err = rootCmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestAliasSet_gh_command(t *testing.T) { + defer config.StubWriteConfig(ioutil.Discard, ioutil.Discard)() + + cfg := config.NewFromString(``) + + _, err := runCommand(cfg, true, "pr 'pr status'") + + if assert.Error(t, err) { + assert.Equal(t, `could not create alias: "pr" is already a gh command`, err.Error()) + } +} + +func TestAliasSet_empty_aliases(t *testing.T) { + mainBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, ioutil.Discard)() + + cfg := config.NewFromString(heredoc.Doc(` + aliases: + editor: vim + `)) + + output, err := runCommand(cfg, true, "co 'pr checkout'") + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + test.ExpectLines(t, output.Stderr(), "Added alias") + test.ExpectLines(t, output.String(), "") + + expected := `aliases: + co: pr checkout +editor: vim +` + assert.Equal(t, expected, mainBuf.String()) +} + +func TestAliasSet_existing_alias(t *testing.T) { + mainBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, ioutil.Discard)() + + cfg := config.NewFromString(heredoc.Doc(` + aliases: + co: pr checkout + `)) + + output, err := runCommand(cfg, true, "co 'pr checkout -Rcool/repo'") + require.NoError(t, err) + + test.ExpectLines(t, output.Stderr(), "Changed alias.*co.*from.*pr checkout.*to.*pr checkout -Rcool/repo") +} + +func TestAliasSet_space_args(t *testing.T) { + mainBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, ioutil.Discard)() + + cfg := config.NewFromString(``) + + output, err := runCommand(cfg, true, `il 'issue list -l "cool story"'`) + require.NoError(t, err) + + test.ExpectLines(t, output.Stderr(), `Adding alias for.*il.*issue list -l "cool story"`) + + test.ExpectLines(t, mainBuf.String(), `il: issue list -l "cool story"`) +} + +func TestAliasSet_arg_processing(t *testing.T) { + cases := []struct { + Cmd string + ExpectedOutputLine string + ExpectedConfigLine string + }{ + {`il "issue list"`, "- Adding alias for.*il.*issue list", "il: issue list"}, + + {`iz 'issue list'`, "- Adding alias for.*iz.*issue list", "iz: issue list"}, + + {`ii 'issue list --author="$1" --label="$2"'`, + `- Adding alias for.*ii.*issue list --author="\$1" --label="\$2"`, + `ii: issue list --author="\$1" --label="\$2"`}, + + {`ix "issue list --author='\$1' --label='\$2'"`, + `- Adding alias for.*ix.*issue list --author='\$1' --label='\$2'`, + `ix: issue list --author='\$1' --label='\$2'`}, + } + + for _, c := range cases { + t.Run(c.Cmd, func(t *testing.T) { + mainBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, ioutil.Discard)() + + cfg := config.NewFromString(``) + + output, err := runCommand(cfg, true, c.Cmd) + if err != nil { + t.Fatalf("got unexpected error running %s: %s", c.Cmd, err) + } + + test.ExpectLines(t, output.Stderr(), c.ExpectedOutputLine) + test.ExpectLines(t, mainBuf.String(), c.ExpectedConfigLine) + }) + } +} + +func TestAliasSet_init_alias_cfg(t *testing.T) { + mainBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, ioutil.Discard)() + + cfg := config.NewFromString(heredoc.Doc(` + editor: vim + `)) + + output, err := runCommand(cfg, true, "diff 'pr diff'") + require.NoError(t, err) + + expected := `editor: vim +aliases: + diff: pr diff +` + + test.ExpectLines(t, output.Stderr(), "Adding alias for.*diff.*pr diff", "Added alias.") + assert.Equal(t, expected, mainBuf.String()) +} + +func TestAliasSet_existing_aliases(t *testing.T) { + mainBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, ioutil.Discard)() + + cfg := config.NewFromString(heredoc.Doc(` + aliases: + foo: bar + `)) + + output, err := runCommand(cfg, true, "view 'pr view'") + require.NoError(t, err) + + expected := `aliases: + foo: bar + view: pr view +` + + test.ExpectLines(t, output.Stderr(), "Adding alias for.*view.*pr view", "Added alias.") + assert.Equal(t, expected, mainBuf.String()) + +} + +func TestAliasSet_invalid_command(t *testing.T) { + defer config.StubWriteConfig(ioutil.Discard, ioutil.Discard)() + + cfg := config.NewFromString(``) + + _, err := runCommand(cfg, true, "co 'pe checkout'") + if assert.Error(t, err) { + assert.Equal(t, "could not create alias: pe checkout does not correspond to a gh command", err.Error()) + } +} + +func TestShellAlias_flag(t *testing.T) { + mainBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, ioutil.Discard)() + + cfg := config.NewFromString(``) + + output, err := runCommand(cfg, true, "--shell igrep 'gh issue list | grep'") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + test.ExpectLines(t, output.Stderr(), "Adding alias for.*igrep") + + expected := `aliases: + igrep: '!gh issue list | grep' +` + assert.Equal(t, expected, mainBuf.String()) +} + +func TestShellAlias_bang(t *testing.T) { + mainBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, ioutil.Discard)() + + cfg := config.NewFromString(``) + + output, err := runCommand(cfg, true, "igrep '!gh issue list | grep'") + require.NoError(t, err) + + test.ExpectLines(t, output.Stderr(), "Adding alias for.*igrep") + + expected := `aliases: + igrep: '!gh issue list | grep' +` + assert.Equal(t, expected, mainBuf.String()) +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 15140800b..cd0321a5e 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -9,7 +9,9 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/context" "github.com/cli/cli/internal/ghrepo" + aliasCmd "github.com/cli/cli/pkg/cmd/alias" apiCmd "github.com/cli/cli/pkg/cmd/api" + configCmd "github.com/cli/cli/pkg/cmd/config" gistCmd "github.com/cli/cli/pkg/cmd/gist" issueCmd "github.com/cli/cli/pkg/cmd/issue" prCmd "github.com/cli/cli/pkg/cmd/pr" @@ -94,9 +96,12 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { // CHILD COMMANDS + cmd.AddCommand(aliasCmd.NewCmdAlias(f)) cmd.AddCommand(apiCmd.NewCmdApi(f, nil)) - cmd.AddCommand(gistCmd.NewCmdGist(f)) + cmd.AddCommand(configCmd.NewCmdConfig(f)) cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil)) + cmd.AddCommand(gistCmd.NewCmdGist(f)) + cmd.AddCommand(NewCmdCompletion(f.IOStreams)) // below here at the commands that require the "intelligent" BaseRepo resolver repoResolvingCmdFactory := *f diff --git a/test/helpers.go b/test/helpers.go index b10e87a85..edfa25bda 100644 --- a/test/helpers.go +++ b/test/helpers.go @@ -6,7 +6,6 @@ import ( "fmt" "os/exec" "regexp" - "testing" "github.com/cli/cli/internal/run" ) @@ -86,7 +85,13 @@ func createStubbedPrepareCmd(cs *CmdStubber) func(*exec.Cmd) run.Runnable { } } -func ExpectLines(t *testing.T, output string, lines ...string) { +type T interface { + Helper() + Errorf(string, ...interface{}) +} + +func ExpectLines(t T, output string, lines ...string) { + t.Helper() var r *regexp.Regexp for _, l := range lines { r = regexp.MustCompile(l)