Isolate all alias commands

This commit is contained in:
Mislav Marohnić 2020-08-11 13:57:48 +02:00
parent 3c55b29446
commit 172ea2b078
14 changed files with 786 additions and 579 deletions

View file

@ -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 <alias> <expansion>",
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 <alias>",
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
}

View file

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

View file

@ -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())
}

View file

@ -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 {

View file

@ -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())
}

28
pkg/cmd/alias/alias.go Normal file
View file

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

View file

@ -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 <alias>",
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
}

View file

@ -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())
})
}
}

View file

@ -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()
}

View file

@ -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())
})
}
}

147
pkg/cmd/alias/set/set.go Normal file
View file

@ -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 <alias> <expansion>",
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
}

View file

@ -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())
}

View file

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

View file

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