Allow creating of nested aliases (#7457)

This commit is contained in:
Sam Coe 2023-05-25 09:46:45 +09:00 committed by GitHub
parent 79128a23c8
commit 74ee8cacae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 556 additions and 502 deletions

View file

@ -2,13 +2,17 @@ package main
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/docs"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/root"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/pflag"
)
@ -41,9 +45,13 @@ func run(args []string) error {
}
ios, _, _, _ := iostreams.Test()
rootCmd := root.NewCmdRoot(&cmdutil.Factory{
rootCmd, _ := root.NewCmdRoot(&cmdutil.Factory{
IOStreams: ios,
Browser: &browser{},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
ExtensionManager: &em{},
}, "", "")
rootCmd.InitDefaultHelpCmd()
@ -79,8 +87,42 @@ func linkHandler(name string) string {
return fmt.Sprintf("./%s", strings.TrimSuffix(name, ".md"))
}
// Implements browser.Browser interface.
type browser struct{}
func (b *browser) Browse(url string) error {
func (b *browser) Browse(_ string) error {
return nil
}
// Implements extensions.ExtensionManager interface.
type em struct{}
func (e *em) List() []extensions.Extension {
return nil
}
func (e *em) Install(_ ghrepo.Interface, _ string) error {
return nil
}
func (e *em) InstallLocal(_ string) error {
return nil
}
func (e *em) Upgrade(_ string, _ bool) error {
return nil
}
func (e *em) Remove(_ string) error {
return nil
}
func (e *em) Dispatch(_ []string, _ io.Reader, _, _ io.Writer) (bool, error) {
return false, nil
}
func (e *em) Create(_ string, _ extensions.ExtTemplateType) error {
return nil
}
func (e *em) EnableDryRunMode() {}

View file

@ -14,16 +14,10 @@ import (
surveyCore "github.com/AlecAivazis/survey/v2/core"
"github.com/AlecAivazis/survey/v2/terminal"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/build"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/run"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/internal/update"
"github.com/cli/cli/v2/pkg/cmd/alias/expand"
"github.com/cli/cli/v2/pkg/cmd/factory"
"github.com/cli/cli/v2/pkg/cmd/root"
"github.com/cli/cli/v2/pkg/cmdutil"
@ -93,11 +87,9 @@ func mainRun() exitCode {
cobra.MousetrapHelpText = ""
}
rootCmd := root.NewCmdRoot(cmdFactory, buildVersion, buildDate)
cfg, err := cmdFactory.Config()
rootCmd, err := root.NewCmdRoot(cmdFactory, buildVersion, buildDate)
if err != nil {
fmt.Fprintf(stderr, "failed to read configuration: %s\n", err)
fmt.Fprintf(stderr, "failed to create root command: %s\n", err)
return exitError
}
@ -106,110 +98,10 @@ func mainRun() exitCode {
expandedArgs = os.Args[1:]
}
// translate `gh help <command>` to `gh <command> --help` for extensions
if len(expandedArgs) == 2 && expandedArgs[0] == "help" && !hasCommand(rootCmd, expandedArgs[1:]) {
expandedArgs = []string{expandedArgs[1], "--help"}
}
if !hasCommand(rootCmd, expandedArgs) {
originalArgs := expandedArgs
isShell := false
argsForExpansion := append([]string{"gh"}, expandedArgs...)
expandedArgs, isShell, err = expand.ExpandAlias(cfg, argsForExpansion, nil)
if err != nil {
fmt.Fprintf(stderr, "failed to process aliases: %s\n", err)
return exitError
}
if hasDebug {
fmt.Fprintf(stderr, "%v -> %v\n", originalArgs, expandedArgs)
}
if isShell {
exe, err := safeexec.LookPath(expandedArgs[0])
if err != nil {
fmt.Fprintf(stderr, "failed to run external command: %s", err)
return exitError
}
externalCmd := exec.Command(exe, expandedArgs[1:]...)
externalCmd.Stderr = os.Stderr
externalCmd.Stdout = os.Stdout
externalCmd.Stdin = os.Stdin
preparedCmd := run.PrepareCmd(externalCmd)
err = preparedCmd.Run()
if err != nil {
var execError *exec.ExitError
if errors.As(err, &execError) {
return exitCode(execError.ExitCode())
}
fmt.Fprintf(stderr, "failed to run external command: %s\n", err)
return exitError
}
return exitOK
} else if len(expandedArgs) > 0 && !hasCommand(rootCmd, expandedArgs) {
extensionManager := cmdFactory.ExtensionManager
if found, err := extensionManager.Dispatch(expandedArgs, os.Stdin, os.Stdout, os.Stderr); err != nil {
var execError *exec.ExitError
if errors.As(err, &execError) {
return exitCode(execError.ExitCode())
}
fmt.Fprintf(stderr, "failed to run extension: %s\n", err)
return exitError
} else if found {
return exitOK
}
}
}
// provide completions for aliases and extensions
rootCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
var results []string
aliases := cfg.Aliases()
for aliasName, aliasValue := range aliases.All() {
if strings.HasPrefix(aliasName, toComplete) {
var s string
if strings.HasPrefix(aliasValue, "!") {
s = fmt.Sprintf("%s\tShell alias", aliasName)
} else {
aliasValue = text.Truncate(80, aliasValue)
s = fmt.Sprintf("%s\tAlias for %s", aliasName, aliasValue)
}
results = append(results, s)
}
}
for _, ext := range cmdFactory.ExtensionManager.List() {
if strings.HasPrefix(ext.Name(), toComplete) {
var s string
if ext.IsLocal() {
s = fmt.Sprintf("%s\tLocal extension gh-%s", ext.Name(), ext.Name())
} else {
path := ext.URL()
if u, err := git.ParseURL(ext.URL()); err == nil {
if r, err := ghrepo.FromURL(u); err == nil {
path = ghrepo.FullName(r)
}
}
s = fmt.Sprintf("%s\tExtension %s", ext.Name(), path)
}
results = append(results, s)
}
}
return results, cobra.ShellCompDirectiveNoFileComp
}
authError := errors.New("authError")
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
// require that the user is authenticated before running most commands
if cmdutil.IsAuthCheckEnabled(cmd) && !cmdutil.CheckAuth(cfg) {
fmt.Fprint(stderr, authHelp())
return authError
}
return nil
// translate `gh help <command>` to `gh <command> --help` for extensions.
if len(expandedArgs) >= 2 && expandedArgs[0] == "help" && isExtensionCommand(rootCmd, expandedArgs[1:]) {
expandedArgs = expandedArgs[1:]
expandedArgs = append(expandedArgs, "--help")
}
rootCmd.SetArgs(expandedArgs)
@ -217,6 +109,8 @@ func mainRun() exitCode {
if cmd, err := rootCmd.ExecuteContextC(ctx); err != nil {
var pagerPipeError *iostreams.ErrClosedPagerPipe
var noResultsError cmdutil.NoResultsError
var execError *exec.ExitError
var authError *root.AuthError
if err == cmdutil.SilentError {
return exitError
} else if cmdutil.IsUserCancellation(err) {
@ -225,7 +119,7 @@ func mainRun() exitCode {
fmt.Fprint(stderr, "\n")
}
return exitCancel
} else if errors.Is(err, authError) {
} else if errors.As(err, &authError) {
return exitAuth
} else if errors.As(err, &pagerPipeError) {
// ignore the error raised when piping to a closed pager
@ -236,6 +130,8 @@ func mainRun() exitCode {
}
// no results is not a command failure
return exitOK
} else if errors.As(err, &execError) {
return exitCode(execError.ExitCode())
}
printError(stderr, err, cmd, hasDebug)
@ -284,10 +180,10 @@ func mainRun() exitCode {
return exitOK
}
// hasCommand returns true if args resolve to a built-in command
func hasCommand(rootCmd *cobra.Command, args []string) bool {
c, _, err := rootCmd.Traverse(args)
return err == nil && c != rootCmd
// isExtensionCommand returns true if args resolve to an extension command.
func isExtensionCommand(rootCmd *cobra.Command, args []string) bool {
c, _, err := rootCmd.Find(args)
return err == nil && c != nil && c.GroupID == "extension"
}
func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
@ -312,27 +208,6 @@ func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
}
}
func authHelp() string {
if os.Getenv("GITHUB_ACTIONS") == "true" {
return heredoc.Doc(`
gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable. Example:
env:
GH_TOKEN: ${{ github.token }}
`)
}
if os.Getenv("CI") != "" {
return heredoc.Doc(`
gh: To use GitHub CLI in automation, set the GH_TOKEN environment variable.
`)
}
return heredoc.Doc(`
To get started with GitHub CLI, please run: gh auth login
Alternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token.
`)
}
func shouldCheckForUpdate() bool {
if os.Getenv("GH_NO_UPDATE_NOTIFIER") != "" {
return false

View file

@ -1,90 +0,0 @@
package expand
import (
"errors"
"fmt"
"os/exec"
"regexp"
"runtime"
"strings"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/pkg/findsh"
"github.com/google/shlex"
)
// ExpandAlias processes argv to see if it should be rewritten according to a user's aliases. The
// second return value indicates whether the alias should be executed in a new shell process instead
// of running gh itself.
func ExpandAlias(cfg config.Config, args []string, findShFunc func() (string, error)) (expanded []string, isShell bool, err error) {
if len(args) < 2 {
// the command is lacking a subcommand
return
}
expanded = args[1:]
aliases := cfg.Aliases()
expansion, getErr := aliases.Get(args[1])
if getErr != nil {
return
}
if strings.HasPrefix(expansion, "!") {
isShell = true
if findShFunc == nil {
findShFunc = findSh
}
shPath, shErr := findShFunc()
if shErr != nil {
err = shErr
return
}
expanded = []string{shPath, "-c", expansion[1:]}
if len(args[2:]) > 0 {
expanded = append(expanded, "--")
expanded = append(expanded, args[2:]...)
}
return
}
extraArgs := []string{}
for i, a := range args[2:] {
if !strings.Contains(expansion, "$") {
extraArgs = append(extraArgs, a)
} else {
expansion = strings.ReplaceAll(expansion, fmt.Sprintf("$%d", i+1), a)
}
}
lingeringRE := regexp.MustCompile(`\$\d`)
if lingeringRE.MatchString(expansion) {
err = fmt.Errorf("not enough arguments for alias: %s", expansion)
return
}
var newArgs []string
newArgs, err = shlex.Split(expansion)
if err != nil {
return
}
expanded = append(newArgs, extraArgs...)
return
}
func findSh() (string, error) {
shPath, err := findsh.Find()
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
if runtime.GOOS == "windows" {
return "", errors.New("unable to locate sh to execute the shell alias with. The sh.exe interpreter is typically distributed with Git for Windows.")
}
return "", errors.New("unable to locate sh to execute shell alias with")
}
return "", err
}
return shPath, nil
}

View file

@ -1,185 +0,0 @@
package expand
import (
"errors"
"reflect"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/config"
)
func TestExpandAlias(t *testing.T) {
findShFunc := func() (string, error) {
return "/usr/bin/sh", nil
}
cfg := config.NewFromString(heredoc.Doc(`
aliases:
co: pr checkout
il: issue list --author="$1" --label="$2"
ia: issue list --author="$1" --assignee="$1"
`))
type args struct {
config config.Config
argv []string
}
tests := []struct {
name string
args args
wantExpanded []string
wantIsShell bool
wantErr error
}{
{
name: "no arguments",
args: args{
config: cfg,
argv: []string{},
},
wantExpanded: []string(nil),
wantIsShell: false,
wantErr: nil,
},
{
name: "too few arguments",
args: args{
config: cfg,
argv: []string{"gh"},
},
wantExpanded: []string(nil),
wantIsShell: false,
wantErr: nil,
},
{
name: "no expansion",
args: args{
config: cfg,
argv: []string{"gh", "pr", "status"},
},
wantExpanded: []string{"pr", "status"},
wantIsShell: false,
wantErr: nil,
},
{
name: "simple expansion",
args: args{
config: cfg,
argv: []string{"gh", "co"},
},
wantExpanded: []string{"pr", "checkout"},
wantIsShell: false,
wantErr: nil,
},
{
name: "adding arguments after expansion",
args: args{
config: cfg,
argv: []string{"gh", "co", "123"},
},
wantExpanded: []string{"pr", "checkout", "123"},
wantIsShell: false,
wantErr: nil,
},
{
name: "not enough arguments for expansion",
args: args{
config: cfg,
argv: []string{"gh", "il"},
},
wantExpanded: []string{},
wantIsShell: false,
wantErr: errors.New(`not enough arguments for alias: issue list --author="$1" --label="$2"`),
},
{
name: "not enough arguments for expansion 2",
args: args{
config: cfg,
argv: []string{"gh", "il", "vilmibm"},
},
wantExpanded: []string{},
wantIsShell: false,
wantErr: errors.New(`not enough arguments for alias: issue list --author="vilmibm" --label="$2"`),
},
{
name: "satisfy expansion arguments",
args: args{
config: cfg,
argv: []string{"gh", "il", "vilmibm", "help wanted"},
},
wantExpanded: []string{"issue", "list", "--author=vilmibm", "--label=help wanted"},
wantIsShell: false,
wantErr: nil,
},
{
name: "mixed positional and non-positional arguments",
args: args{
config: cfg,
argv: []string{"gh", "il", "vilmibm", "epic", "-R", "monalisa/testing"},
},
wantExpanded: []string{"issue", "list", "--author=vilmibm", "--label=epic", "-R", "monalisa/testing"},
wantIsShell: false,
wantErr: nil,
},
{
name: "dollar in expansion",
args: args{
config: cfg,
argv: []string{"gh", "ia", "$coolmoney$"},
},
wantExpanded: []string{"issue", "list", "--author=$coolmoney$", "--assignee=$coolmoney$"},
wantIsShell: false,
wantErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotExpanded, gotIsShell, err := ExpandAlias(tt.args.config, tt.args.argv, findShFunc)
if tt.wantErr != nil {
if err == nil {
t.Fatal("expected error")
}
if tt.wantErr.Error() != err.Error() {
t.Fatalf("expected error %q, got %q", tt.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("got error: %v", err)
}
if !reflect.DeepEqual(gotExpanded, tt.wantExpanded) {
t.Errorf("ExpandAlias() gotExpanded = %v, want %v", gotExpanded, tt.wantExpanded)
}
if gotIsShell != tt.wantIsShell {
t.Errorf("ExpandAlias() gotIsShell = %v, want %v", gotIsShell, tt.wantIsShell)
}
})
}
}
// cfg := `---
// aliases:
// co: pr checkout
// il: issue list --author="$1" --label="$2"
// ia: issue list --author="$1" --assignee="$1"
// `
// initBlankContext(cfg, "OWNER/REPO", "trunk")
// for _, c := range []struct {
// Args string
// ExpectedArgs []string
// Err string
// }{
// {"gh co", []string{"pr", "checkout"}, ""},
// {"gh il", nil, `not enough arguments for alias: issue list --author="$1" --label="$2"`},
// {"gh il vilmibm", nil, `not enough arguments for alias: issue list --author="vilmibm" --label="$2"`},
// {"gh co 123", []string{"pr", "checkout", "123"}, ""},
// {"gh il vilmibm epic", []string{"issue", "list", `--author=vilmibm`, `--label=epic`}, ""},
// {"gh ia vilmibm", []string{"issue", "list", `--author=vilmibm`, `--assignee=vilmibm`}, ""},
// {"gh ia $coolmoney$", []string{"issue", "list", `--author=$coolmoney$`, `--assignee=$coolmoney$`}, ""},
// {"gh pr status", []string{"pr", "status"}, ""},
// {"gh il vilmibm epic -R vilmibm/testing", []string{"issue", "list", "--author=vilmibm", "--label=epic", "-R", "vilmibm/testing"}, ""},
// {"gh dne", []string{"dne"}, ""},
// {"gh", []string{}, ""},
// {"", []string{}, ""},
// } {

View file

@ -21,7 +21,8 @@ type ImportOptions struct {
Filename string
OverwriteExisting bool
existingCommand func(string) bool
validAliasName func(string) bool
validAliasExpansion func(string) bool
}
func NewCmdImport(f *cmdutil.Factory, runF func(*ImportOptions) error) *cobra.Command {
@ -74,7 +75,8 @@ func NewCmdImport(f *cmdutil.Factory, runF func(*ImportOptions) error) *cobra.Co
opts.Filename = args[0]
}
opts.existingCommand = shared.ExistingCommandFunc(f, cmd)
opts.validAliasName = shared.ValidAliasNameFunc(cmd)
opts.validAliasExpansion = shared.ValidAliasExpansionFunc(cmd)
if runF != nil {
return runF(opts)
@ -120,9 +122,9 @@ func importRun(opts *ImportOptions) error {
var msg strings.Builder
for _, alias := range getSortedKeys(aliasMap) {
if opts.existingCommand(alias) {
if !opts.validAliasName(alias) {
msg.WriteString(
fmt.Sprintf("%s Could not import alias %s: already a gh command\n",
fmt.Sprintf("%s Could not import alias %s: already a gh command, extension, or alias\n",
cs.FailureIcon(),
cs.Bold(alias),
),
@ -133,9 +135,9 @@ func importRun(opts *ImportOptions) error {
expansion := aliasMap[alias]
if !(strings.HasPrefix(expansion, "!") || opts.existingCommand(expansion)) {
if !opts.validAliasExpansion(expansion) {
msg.WriteString(
fmt.Sprintf("%s Could not import alias %s: expansion does not correspond to a gh command\n",
fmt.Sprintf("%s Could not import alias %s: expansion does not correspond to a gh command, extension, or alias\n",
cs.FailureIcon(),
cs.Bold(alias),
),

View file

@ -13,7 +13,6 @@ import (
"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"
@ -257,9 +256,9 @@ func TestImportRun(t *testing.T) {
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",
"X Could not import alias api: already a gh command, extension, or alias",
"X Could not import alias issue: already a gh command, extension, or alias",
"X Could not import alias pr: already a gh command, extension, or alias\n\n",
},
"\n",
),
@ -277,8 +276,8 @@ func TestImportRun(t *testing.T) {
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",
"X Could not import alias alias1: expansion does not correspond to a gh command, extension, or alias",
"X Could not import alias alias2: expansion does not correspond to a gh command, extension, or alias\n\n",
},
"\n",
),
@ -304,16 +303,6 @@ func TestImportRun(t *testing.T) {
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"}
@ -327,7 +316,8 @@ func TestImportRun(t *testing.T) {
apiCmd.AddCommand(&cobra.Command{Use: "graphql"})
rootCmd.AddCommand(apiCmd)
tt.opts.existingCommand = shared.ExistingCommandFunc(f, rootCmd)
tt.opts.validAliasName = shared.ValidAliasNameFunc(rootCmd)
tt.opts.validAliasExpansion = shared.ValidAliasExpansionFunc(rootCmd)
if tt.stdin != "" {
stdin.WriteString(tt.stdin)

View file

@ -21,7 +21,8 @@ type SetOptions struct {
Expansion string
IsShell bool
existingCommand func(string) bool
validAliasName func(string) bool
validAliasExpansion func(string) bool
}
func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command {
@ -59,6 +60,9 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
$ gh alias set homework 'issue list --assignee @me'
$ gh homework
$ gh alias set 'issue mine' 'issue list --mention @me'
$ gh issue mine
$ gh alias set epicsBy 'issue list --author="$1" --label="epic"'
$ gh epicsBy vilmibm #=> gh issue list --author="vilmibm" --label="epic"
@ -70,7 +74,8 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
opts.Name = args[0]
opts.Expansion = args[1]
opts.existingCommand = shared.ExistingCommandFunc(f, cmd)
opts.validAliasName = shared.ValidAliasNameFunc(cmd)
opts.validAliasExpansion = shared.ValidAliasExpansionFunc(cmd)
if runF != nil {
return runF(opts)
@ -104,18 +109,16 @@ func setRun(opts *SetOptions) error {
fmt.Fprintf(opts.IO.ErrOut, "- Adding alias for %s: %s\n", cs.Bold(opts.Name), cs.Bold(expansion))
}
isShell := opts.IsShell
if isShell && !strings.HasPrefix(expansion, "!") {
if opts.IsShell && !strings.HasPrefix(expansion, "!") {
expansion = "!" + expansion
}
isShell = strings.HasPrefix(expansion, "!")
if opts.existingCommand(opts.Name) {
return fmt.Errorf("could not create alias: %q is already a gh command", opts.Name)
if !opts.validAliasName(opts.Name) {
return fmt.Errorf("could not create alias: %q is already a gh command, extension, or alias", opts.Name)
}
if !isShell && !opts.existingCommand(expansion) {
return fmt.Errorf("could not create alias: %s does not correspond to a gh command", expansion)
if !opts.validAliasExpansion(expansion) {
return fmt.Errorf("could not create alias: %s does not correspond to a gh command, extension, or alias", expansion)
}
successMsg := fmt.Sprintf("%s Added alias.", cs.SuccessIcon())

View file

@ -73,7 +73,7 @@ func TestAliasSet_gh_command(t *testing.T) {
cfg := config.NewFromString(``)
_, err := runCommand(cfg, true, "pr 'pr status'", "")
assert.EqualError(t, err, `could not create alias: "pr" is already a gh command`)
assert.EqualError(t, err, `could not create alias: "pr" is already a gh command, extension, or alias`)
}
func TestAliasSet_empty_aliases(t *testing.T) {
@ -231,7 +231,7 @@ func TestAliasSet_invalid_command(t *testing.T) {
cfg := config.NewFromString(``)
_, err := runCommand(cfg, true, "co 'pe checkout'", "")
assert.EqualError(t, err, "could not create alias: pe checkout does not correspond to a gh command")
assert.EqualError(t, err, "could not create alias: pe checkout does not correspond to a gh command, extension, or alias")
}
func TestShellAlias_flag(t *testing.T) {

View file

@ -1,14 +1,18 @@
package shared
import (
"github.com/cli/cli/v2/pkg/cmdutil"
"strings"
"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 {
// ValidAliasNameFunc returns a function that will check if the given string
// is a valid alias name. A name is valid if:
// - it does not shadow an existing command,
// - it is not nested under a command that is runnable,
// - it is not nested under a command that does not exist.
func ValidAliasNameFunc(cmd *cobra.Command) func(string) bool {
return func(args string) bool {
split, err := shlex.Split(args)
if err != nil || len(split) == 0 {
@ -16,17 +20,32 @@ func ExistingCommandFunc(f *cmdutil.Factory, cmd *cobra.Command) func(string) bo
}
rootCmd := cmd.Root()
cmd, _, err = rootCmd.Traverse(split)
if err == nil && cmd != rootCmd {
foundCmd, foundArgs, _ := rootCmd.Find(split)
if foundCmd != nil && !foundCmd.Runnable() && len(foundArgs) == 1 {
return true
}
for _, ext := range f.ExtensionManager.List() {
if ext.Name() == split[0] {
return true
}
}
return false
}
}
// ValidAliasExpansionFunc returns a function that will check if the given string
// is a valid alias expansion. An expansion is valid if:
// - it is a shell expansion,
// - it is a non-shell expansion that corresponds to an existing command, extension, or alias.
func ValidAliasExpansionFunc(cmd *cobra.Command) func(string) bool {
return func(expansion string) bool {
if strings.HasPrefix(expansion, "!") {
return true
}
split, err := shlex.Split(expansion)
if err != nil || len(split) == 0 {
return false
}
rootCmd := cmd.Root()
cmd, _, _ = rootCmd.Find(split)
return cmd != rootCmd
}
}

View file

@ -3,22 +3,11 @@ 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{}
},
},
}
func TestValidAliasNameFunc(t *testing.T) {
// Create fake command structure for testing.
issueCmd := &cobra.Command{Use: "issue"}
prCmd := &cobra.Command{Use: "pr"}
@ -28,13 +17,38 @@ func TestExistingCommandFunc(t *testing.T) {
cmd.AddCommand(prCmd)
cmd.AddCommand(issueCmd)
f := ExistingCommandFunc(factory, cmd)
f := ValidAliasNameFunc(cmd)
assert.True(t, f("pr"))
assert.True(t, f("pr checkout"))
assert.True(t, f("issue"))
assert.False(t, f("pr"))
assert.False(t, f("pr checkout"))
assert.False(t, f("issue"))
assert.False(t, f("repo list"))
assert.True(t, f("ps"))
assert.True(t, f("checkout"))
assert.True(t, f("issue erase"))
assert.True(t, f("pr erase"))
assert.True(t, f("pr checkout branch"))
}
func TestValidAliasExpansionFunc(t *testing.T) {
// 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 := ValidAliasExpansionFunc(cmd)
assert.False(t, f("ps"))
assert.False(t, f("checkout"))
assert.False(t, f("repo list"))
assert.True(t, f("!git branch --show-current"))
assert.True(t, f("pr"))
assert.True(t, f("pr checkout"))
assert.True(t, f("issue"))
}

View file

@ -643,10 +643,8 @@ func checkValidExtension(rootCmd *cobra.Command, m extensions.ExtensionManager,
}
commandName := strings.TrimPrefix(extName, "gh-")
if c, _, err := rootCmd.Traverse([]string{commandName}); err != nil {
return nil, err
} else if c != rootCmd {
return nil, fmt.Errorf("%q matches the name of a built-in command", commandName)
if c, _, _ := rootCmd.Find([]string{commandName}); c != rootCmd && c.GroupID != "extension" {
return nil, fmt.Errorf("%q matches the name of a built-in command or alias", commandName)
}
for _, ext := range m.List() {

View file

@ -974,7 +974,7 @@ func Test_checkValidExtension(t *testing.T) {
manager: m,
extName: "gh-auth",
},
wantError: "\"auth\" matches the name of a built-in command",
wantError: "\"auth\" matches the name of a built-in command or alias",
},
{
name: "clashes with an installed extension",

126
pkg/cmd/root/alias.go Normal file
View file

@ -0,0 +1,126 @@
package root
import (
"errors"
"fmt"
"os/exec"
"regexp"
"runtime"
"strings"
"github.com/cli/cli/v2/internal/run"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/findsh"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/shlex"
"github.com/spf13/cobra"
)
func NewCmdShellAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *cobra.Command {
return &cobra.Command{
Use: aliasName,
Short: fmt.Sprintf("Shell alias for %q", text.Truncate(80, aliasValue)),
RunE: func(c *cobra.Command, args []string) error {
expandedArgs, err := expandShellAlias(aliasValue, args, nil)
if err != nil {
return err
}
externalCmd := exec.Command(expandedArgs[0], expandedArgs[1:]...)
externalCmd.Stderr = io.ErrOut
externalCmd.Stdout = io.Out
externalCmd.Stdin = io.In
preparedCmd := run.PrepareCmd(externalCmd)
if err = preparedCmd.Run(); err != nil {
return fmt.Errorf("failed to run external command: %w\n", err)
}
return nil
},
GroupID: "alias",
Annotations: map[string]string{
"skipAuthCheck": "true",
},
DisableFlagParsing: true,
}
}
func NewCmdAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *cobra.Command {
return &cobra.Command{
Use: aliasName,
Short: fmt.Sprintf("Alias for %q", text.Truncate(80, aliasValue)),
RunE: func(c *cobra.Command, args []string) error {
expandedArgs, err := expandAlias(aliasValue, args)
if err != nil {
return err
}
root := c.Root()
root.SetArgs(expandedArgs)
return root.Execute()
},
GroupID: "alias",
Annotations: map[string]string{
"skipAuthCheck": "true",
},
DisableFlagParsing: true,
}
}
// ExpandAlias processes argv to see if it should be rewritten according to a user's aliases.
func expandAlias(expansion string, args []string) ([]string, error) {
extraArgs := []string{}
for i, a := range args {
if !strings.Contains(expansion, "$") {
extraArgs = append(extraArgs, a)
} else {
expansion = strings.ReplaceAll(expansion, fmt.Sprintf("$%d", i+1), a)
}
}
lingeringRE := regexp.MustCompile(`\$\d`)
if lingeringRE.MatchString(expansion) {
return nil, fmt.Errorf("not enough arguments for alias: %s", expansion)
}
newArgs, err := shlex.Split(expansion)
if err != nil {
return nil, err
}
expanded := append(newArgs, extraArgs...)
return expanded, nil
}
// ExpandShellAlias processes argv to see if it should be rewritten according to a user's aliases.
func expandShellAlias(expansion string, args []string, findShFunc func() (string, error)) ([]string, error) {
if findShFunc == nil {
findShFunc = findSh
}
shPath, shErr := findShFunc()
if shErr != nil {
return nil, shErr
}
expanded := []string{shPath, "-c", expansion[1:]}
if len(args) > 0 {
expanded = append(expanded, "--")
expanded = append(expanded, args...)
}
return expanded, nil
}
func findSh() (string, error) {
shPath, err := findsh.Find()
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
if runtime.GOOS == "windows" {
return "", errors.New("unable to locate sh to execute the shell alias with. The sh.exe interpreter is typically distributed with Git for Windows.")
}
return "", errors.New("unable to locate sh to execute shell alias with")
}
return "", err
}
return shPath, nil
}

123
pkg/cmd/root/alias_test.go Normal file
View file

@ -0,0 +1,123 @@
package root
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func TestExpandAlias(t *testing.T) {
tests := []struct {
name string
expansion string
args []string
wantExpanded []string
wantErr string
}{
{
name: "no expansion",
expansion: "pr status",
args: []string{},
wantExpanded: []string{"pr", "status"},
},
{
name: "adding arguments after expansion",
expansion: "pr checkout",
args: []string{"123"},
wantExpanded: []string{"pr", "checkout", "123"},
},
{
name: "not enough arguments for expansion",
expansion: `issue list --author="$1" --label="$2"`,
args: []string{},
wantErr: `not enough arguments for alias: issue list --author="$1" --label="$2"`,
},
{
name: "not enough arguments for expansion 2",
expansion: `issue list --author="$1" --label="$2"`,
args: []string{"vilmibm"},
wantErr: `not enough arguments for alias: issue list --author="vilmibm" --label="$2"`,
},
{
name: "satisfy expansion arguments",
expansion: `issue list --author="$1" --label="$2"`,
args: []string{"vilmibm", "help wanted"},
wantExpanded: []string{"issue", "list", "--author=vilmibm", "--label=help wanted"},
},
{
name: "mixed positional and non-positional arguments",
expansion: `issue list --author="$1" --label="$2"`,
args: []string{"vilmibm", "epic", "-R", "monalisa/testing"},
wantExpanded: []string{"issue", "list", "--author=vilmibm", "--label=epic", "-R", "monalisa/testing"},
},
{
name: "dollar in expansion",
expansion: `issue list --author="$1" --assignee="$1"`,
args: []string{"$coolmoney$"},
wantExpanded: []string{"issue", "list", "--author=$coolmoney$", "--assignee=$coolmoney$"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotExpanded, err := expandAlias(tt.expansion, tt.args)
if tt.wantErr != "" {
assert.Nil(t, gotExpanded)
assert.EqualError(t, err, tt.wantErr)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantExpanded, gotExpanded)
})
}
}
func TestExpandShellAlias(t *testing.T) {
findShFunc := func() (string, error) {
return "/usr/bin/sh", nil
}
tests := []struct {
name string
expansion string
args []string
findSh func() (string, error)
wantExpanded []string
wantErr string
}{
{
name: "simple expansion",
expansion: "!git branch --show-current",
args: []string{},
findSh: findShFunc,
wantExpanded: []string{"/usr/bin/sh", "-c", "git branch --show-current"},
},
{
name: "adding arguments after expansion",
expansion: "!git branch checkout",
args: []string{"123"},
findSh: findShFunc,
wantExpanded: []string{"/usr/bin/sh", "-c", "git branch checkout", "--", "123"},
},
{
name: "unable to find sh",
expansion: "!git branch --show-current",
args: []string{},
findSh: func() (string, error) {
return "", errors.New("unable to locate sh")
},
wantErr: "unable to locate sh",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotExpanded, err := expandShellAlias(tt.expansion, tt.args, tt.findSh)
if tt.wantErr != "" {
assert.Nil(t, gotExpanded)
assert.EqualError(t, err, tt.wantErr)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantExpanded, gotExpanded)
})
}
}

42
pkg/cmd/root/extension.go Normal file
View file

@ -0,0 +1,42 @@
package root
import (
"fmt"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)
func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ext extensions.Extension) *cobra.Command {
var short string
if ext.IsLocal() {
short = fmt.Sprintf("Local extension gh-%s", ext.Name())
} else {
path := ext.URL()
if u, err := git.ParseURL(ext.URL()); err == nil {
if r, err := ghrepo.FromURL(u); err == nil {
path = ghrepo.FullName(r)
}
}
short = fmt.Sprintf("Extension %s", path)
}
return &cobra.Command{
Use: ext.Name(),
Short: short,
RunE: func(c *cobra.Command, args []string) error {
args = append([]string{ext.Name()}, args...)
if _, err := em.Dispatch(args, io.In, io.Out, io.ErrOut); err != nil {
return fmt.Errorf("failed to run extension: %w\n", err)
}
return nil
},
GroupID: "extension",
Annotations: map[string]string{
"skipAuthCheck": "true",
},
DisableFlagParsing: true,
}
}

View file

@ -4,9 +4,11 @@ import (
"bytes"
"fmt"
"io"
"os"
"sort"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
@ -16,13 +18,17 @@ import (
func rootUsageFunc(w io.Writer, command *cobra.Command) error {
fmt.Fprintf(w, "Usage: %s", command.UseLine())
subcommands := command.Commands()
var subcommands []*cobra.Command
for _, c := range command.Commands() {
if !c.IsAvailableCommand() {
continue
}
subcommands = append(subcommands, c)
}
if len(subcommands) > 0 {
fmt.Fprint(w, "\n\nAvailable commands:\n")
for _, c := range subcommands {
if c.Hidden {
continue
}
fmt.Fprintf(w, " %s\n", c.Name())
}
return nil
@ -82,8 +88,10 @@ func isRootCmd(command *cobra.Command) bool {
}
func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, args []string) {
flags := command.Flags()
if isRootCmd(command) {
if versionVal, err := command.Flags().GetBool("version"); err == nil && versionVal {
if versionVal, err := flags.GetBool("version"); err == nil && versionVal {
fmt.Fprint(f.IOStreams.Out, command.Annotations["versionInfo"])
return
} else if err != nil {
@ -95,8 +103,8 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, args []string) {
cs := f.IOStreams.ColorScheme()
if isRootCmd(command.Parent()) && len(args) >= 2 && args[1] != "--help" && args[1] != "-h" {
nestedSuggestFunc(f.IOStreams.ErrOut, command, args[1])
if help, _ := flags.GetBool("help"); !help && !command.Runnable() && len(flags.Args()) > 0 {
nestedSuggestFunc(f.IOStreams.ErrOut, command, strings.Join(flags.Args(), " "))
hasFailed = true
return
}
@ -144,14 +152,6 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, args []string) {
}
sort.Strings(helpTopics)
helpEntries = append(helpEntries, helpEntry{"HELP TOPICS", strings.Join(helpTopics, "\n")})
if exts := f.ExtensionManager.List(); len(exts) > 0 {
var names []string
for _, ext := range exts {
names = append(names, ext.Name())
}
helpEntries = append(helpEntries, helpEntry{"EXTENSION COMMANDS", strings.Join(names, "\n")})
}
}
flagUsages := command.LocalFlags().FlagUsages()
@ -189,6 +189,27 @@ Read the manual at https://cli.github.com/manual`})
}
}
func authHelp() string {
if os.Getenv("GITHUB_ACTIONS") == "true" {
return heredoc.Doc(`
gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable. Example:
env:
GH_TOKEN: ${{ github.token }}
`)
}
if os.Getenv("CI") != "" {
return heredoc.Doc(`
gh: To use GitHub CLI in automation, set the GH_TOKEN environment variable.
`)
}
return heredoc.Doc(`
To get started with GitHub CLI, please run: gh auth login
Alternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token.
`)
}
func findCommand(cmd *cobra.Command, name string) *cobra.Command {
for _, c := range cmd.Commands() {
if c.Name() == name {

View file

@ -1,8 +1,10 @@
package root
import (
"fmt"
"net/http"
"os"
"strings"
"sync"
"github.com/MakeNowJust/heredoc"
@ -10,6 +12,7 @@ import (
codespacesAPI "github.com/cli/cli/v2/internal/codespaces/api"
actionsCmd "github.com/cli/cli/v2/pkg/cmd/actions"
aliasCmd "github.com/cli/cli/v2/pkg/cmd/alias"
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
apiCmd "github.com/cli/cli/v2/pkg/cmd/api"
authCmd "github.com/cli/cli/v2/pkg/cmd/auth"
browseCmd "github.com/cli/cli/v2/pkg/cmd/browse"
@ -36,15 +39,29 @@ import (
versionCmd "github.com/cli/cli/v2/pkg/cmd/version"
workflowCmd "github.com/cli/cli/v2/pkg/cmd/workflow"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/google/shlex"
"github.com/spf13/cobra"
)
func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
type AuthError struct {
err error
}
func (ae *AuthError) Error() string {
return ae.err.Error()
}
func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, error) {
io := f.IOStreams
cfg, err := f.Config()
if err != nil {
return nil, fmt.Errorf("failed to read configuration: %s\n", err)
}
cmd := &cobra.Command{
Use: "gh <command> <subcommand> [flags]",
Short: "GitHub CLI",
Long: `Work seamlessly with GitHub from the command line.`,
Example: heredoc.Doc(`
$ gh issue create
$ gh repo clone cli/cli
@ -53,6 +70,14 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
Annotations: map[string]string{
"versionInfo": versionCmd.Format(version, buildDate),
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// require that the user is authenticated before running most commands
if cmdutil.IsAuthCheckEnabled(cmd) && !cmdutil.CheckAuth(cfg) {
fmt.Fprint(io.ErrOut, authHelp())
return &AuthError{}
}
return nil
},
}
// cmd.SetOut(f.IOStreams.Out) // can't use due to https://github.com/spf13/cobra/issues/1708
@ -85,6 +110,10 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
ID: "actions",
Title: "GitHub Actions commands",
})
cmd.AddGroup(&cobra.Group{
ID: "extension",
Title: "Extension commands",
})
// Child commands
cmd.AddCommand(versionCmd.NewCmdVersion(f, version, buildDate))
@ -136,6 +165,51 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
}
}
// Aliases
aliases := cfg.Aliases()
validAliasName := shared.ValidAliasNameFunc(cmd)
validAliasExpansion := shared.ValidAliasExpansionFunc(cmd)
for k, v := range aliases.All() {
aliasName := k
aliasValue := v
if validAliasName(aliasName) && validAliasExpansion(aliasValue) {
split, _ := shlex.Split(aliasName)
parentCmd, parentArgs, _ := cmd.Find(split)
if !parentCmd.ContainsGroup("alias") {
parentCmd.AddGroup(&cobra.Group{
ID: "alias",
Title: "Alias commands",
})
}
if strings.HasPrefix(aliasValue, "!") {
shellAliasCmd := NewCmdShellAlias(io, parentArgs[0], aliasValue)
parentCmd.AddCommand(shellAliasCmd)
parentCmd.ValidArgs = append(parentCmd.ValidArgs, fmt.Sprintf("%s\tShell alias", aliasName))
} else {
aliasCmd := NewCmdAlias(io, parentArgs[0], aliasValue)
split, _ := shlex.Split(aliasValue)
child, _, _ := cmd.Find(split)
aliasCmd.SetUsageFunc(func(_ *cobra.Command) error {
return rootUsageFunc(f.IOStreams.ErrOut, child)
})
aliasCmd.SetHelpFunc(func(_ *cobra.Command, args []string) {
rootHelpFunc(f, child, args)
})
parentCmd.AddCommand(aliasCmd)
parentCmd.ValidArgs = append(parentCmd.ValidArgs, fmt.Sprintf("%s\tAlias for %s", aliasName, aliasValue))
}
}
}
// Extensions
em := f.ExtensionManager
for _, e := range em.List() {
extension := e
extensionCmd := NewCmdExtension(io, em, e)
cmd.AddCommand(extensionCmd)
cmd.ValidArgs = append(cmd.ValidArgs, fmt.Sprintf("%s\t%s", extension.Name(), extensionCmd.Short))
}
cmdutil.DisableAuthCheck(cmd)
// The reference command produces paged output that displays information on every other command.
@ -145,7 +219,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
// is special.
referenceCmd.Long = stringifyReference(cmd)
referenceCmd.SetHelpFunc(longPager(f.IOStreams))
return cmd
return cmd, nil
}
func bareHTTPClient(f *cmdutil.Factory, version string) func() (*http.Client, error) {