Repository: cli/cli
Pull request #7384 (2023-04-30 12:49:58)

Add PR auto-merge status info to PR queries
_This PR is part of a series for issue #7309, adding auto-merge status support to PR commands_

This PR adds the `autoMergeRequest` field to the list of JSON fields available to the `gh pr list`, `gh pr status` and `gh pr view` commands.

Once this information is available, each of these commands can be extended to make use of this; e.g. the `gh pr view` command could include that a PR will auto-merge once conditions are met in the output it generates.



Patch:
diff --git a/api/queries_pr.go b/api/queries_pr.go
index 87ffd658d..ae8f7c0c6 100644
--- a/api/queries_pr.go
+++ b/api/queries_pr.go
@@ -39,6 +39,8 @@ type PullRequest struct {
 	ClosedAt            *time.Time
 	MergedAt            *time.Time
 
+	AutoMergeRequest *AutoMergeRequest
+
 	MergeCommit          *Commit
 	PotentialMergeCommit *Commit
 
@@ -136,6 +138,16 @@ type PRRepository struct {
 	Name string `json:"name"`
 }
 
+type AutoMergeRequest struct {
+	AuthorEmail    *string `json:"authorEmail"`
+	CommitBody     *string `json:"commitBody"`
+	CommitHeadline *string `json:"commitHeadline"`
+	// MERGE, REBASE, SQUASH
+	MergeMethod string    `json:"mergeMethod"`
+	EnabledAt   time.Time `json:"enabledAt"`
+	EnabledBy   Author    `json:"enabledBy"`
+}
+
 // Commit loads just the commit SHA and nothing else
 type Commit struct {
 	OID string `json:"oid"`
diff --git a/api/query_builder.go b/api/query_builder.go
index 877f79788..3e54f4c60 100644
--- a/api/query_builder.go
+++ b/api/query_builder.go
@@ -132,6 +132,17 @@ var prCommits = shortenQuery(`
 	}
 `)
 
+var autoMergeRequest = shortenQuery(`
+	autoMergeRequest {
+		authorEmail,
+		commitBody,
+		commitHeadline,
+		mergeMethod,
+		enabledAt,
+		enabledBy{login,...on User{id,name}}
+	}
+`)
+
 func StatusCheckRollupGraphQL(after string) string {
 	var afterClause string
 	if after != "" {
@@ -231,6 +242,7 @@ var IssueFields = []string{
 
 var PullRequestFields = append(IssueFields,
 	"additions",
+	"autoMergeRequest",
 	"baseRefName",
 	"changedFiles",
 	"commits",
@@ -285,6 +297,8 @@ func IssueGraphQL(fields []string) string {
 			q = append(q, `mergeCommit{oid}`)
 		case "potentialMergeCommit":
 			q = append(q, `potentialMergeCommit{oid}`)
+		case "autoMergeRequest":
+			q = append(q, autoMergeRequest)
 		case "comments":
 			q = append(q, issueComments)
 		case "lastComment": // pseudo-field
diff --git a/internal/codespaces/rpc/codespace/codespace_host_service.v1.pb.go b/internal/codespaces/rpc/codespace/codespace_host_service.v1.pb.go
index 6da7f9e39..aa3601f96 100644
--- a/internal/codespaces/rpc/codespace/codespace_host_service.v1.pb.go
+++ b/internal/codespaces/rpc/codespace/codespace_host_service.v1.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
 // 	protoc-gen-go v1.28.1
-// 	protoc        v3.21.12
+// 	protoc        v3.12.4
 // source: codespace/codespace_host_service.v1.proto
 
 package codespace
diff --git a/internal/codespaces/rpc/codespace/codespace_host_service.v1_grpc.pb.go b/internal/codespaces/rpc/codespace/codespace_host_service.v1_grpc.pb.go
index e876578ad..c8bf17a8f 100644
--- a/internal/codespaces/rpc/codespace/codespace_host_service.v1_grpc.pb.go
+++ b/internal/codespaces/rpc/codespace/codespace_host_service.v1_grpc.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
 // versions:
 // - protoc-gen-go-grpc v1.2.0
-// - protoc             v3.21.12
+// - protoc             v3.12.4
 // source: codespace/codespace_host_service.v1.proto
 
 package codespace
diff --git a/internal/codespaces/rpc/generate.sh b/internal/codespaces/rpc/generate.sh
index 4ba2f898a..2314b6b57 100755
--- a/internal/codespaces/rpc/generate.sh
+++ b/internal/codespaces/rpc/generate.sh
@@ -20,11 +20,11 @@ function generate {
 
   local contract="$dir/$proto"
 
-  protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative "$contract"
+  protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative "$contract" --experimental_allow_proto3_optional
   echo "Generated protocol buffers for $contract"
 
-  services=$(cat "$contract" | grep -Eo "service .+ {" | awk '{print $2 "Server"}')
-  moq -out $contract.mock.go $dir $services
+  services=$(grep -Eo "service .+ {" <$contract | awk '{print $2 "Server"}')
+  moq -out "$contract.mock.go" "$dir" "$services"
   echo "Generated mock protocols for $contract"
 }
 
diff --git a/internal/codespaces/rpc/jupyter/jupyter_server_host_service.v1.pb.go b/internal/codespaces/rpc/jupyter/jupyter_server_host_service.v1.pb.go
index b8f400d3c..ba1987b87 100644
--- a/internal/codespaces/rpc/jupyter/jupyter_server_host_service.v1.pb.go
+++ b/internal/codespaces/rpc/jupyter/jupyter_server_host_service.v1.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
 // 	protoc-gen-go v1.28.1
-// 	protoc        v3.21.12
+// 	protoc        v3.12.4
 // source: jupyter/jupyter_server_host_service.v1.proto
 
 package jupyter
diff --git a/internal/codespaces/rpc/jupyter/jupyter_server_host_service.v1_grpc.pb.go b/internal/codespaces/rpc/jupyter/jupyter_server_host_service.v1_grpc.pb.go
index 31802020d..0473d5a8f 100644
--- a/internal/codespaces/rpc/jupyter/jupyter_server_host_service.v1_grpc.pb.go
+++ b/internal/codespaces/rpc/jupyter/jupyter_server_host_service.v1_grpc.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
 // versions:
 // - protoc-gen-go-grpc v1.2.0
-// - protoc             v3.21.12
+// - protoc             v3.12.4
 // source: jupyter/jupyter_server_host_service.v1.proto
 
 package jupyter
diff --git a/internal/codespaces/rpc/ssh/ssh_server_host_service.v1.pb.go b/internal/codespaces/rpc/ssh/ssh_server_host_service.v1.pb.go
index 3dd22f583..d36dd7b56 100644
--- a/internal/codespaces/rpc/ssh/ssh_server_host_service.v1.pb.go
+++ b/internal/codespaces/rpc/ssh/ssh_server_host_service.v1.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
 // 	protoc-gen-go v1.28.1
-// 	protoc        v3.21.12
+// 	protoc        v3.12.4
 // source: ssh/ssh_server_host_service.v1.proto
 
 package ssh
diff --git a/internal/codespaces/rpc/ssh/ssh_server_host_service.v1_grpc.pb.go b/internal/codespaces/rpc/ssh/ssh_server_host_service.v1_grpc.pb.go
index a111656e8..995eb6ce5 100644
--- a/internal/codespaces/rpc/ssh/ssh_server_host_service.v1_grpc.pb.go
+++ b/internal/codespaces/rpc/ssh/ssh_server_host_service.v1_grpc.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
 // versions:
 // - protoc-gen-go-grpc v1.2.0
-// - protoc             v3.21.12
+// - protoc             v3.12.4
 // source: ssh/ssh_server_host_service.v1.proto
 
 package ssh
diff --git a/pkg/cmd/alias/alias.go b/pkg/cmd/alias/alias.go
index 46d7e2bc8..713e7d1db 100644
--- a/pkg/cmd/alias/alias.go
+++ b/pkg/cmd/alias/alias.go
@@ -3,6 +3,7 @@ package alias
 import (
 	"github.com/MakeNowJust/heredoc"
 	deleteCmd "github.com/cli/cli/v2/pkg/cmd/alias/delete"
+	importCmd "github.com/cli/cli/v2/pkg/cmd/alias/imports"
 	listCmd "github.com/cli/cli/v2/pkg/cmd/alias/list"
 	setCmd "github.com/cli/cli/v2/pkg/cmd/alias/set"
 	"github.com/cli/cli/v2/pkg/cmdutil"
@@ -23,6 +24,7 @@ func NewCmdAlias(f *cmdutil.Factory) *cobra.Command {
 	cmdutil.DisableAuthCheck(cmd)
 
 	cmd.AddCommand(deleteCmd.NewCmdDelete(f, nil))
+	cmd.AddCommand(importCmd.NewCmdImport(f, nil))
 	cmd.AddCommand(listCmd.NewCmdList(f, nil))
 	cmd.AddCommand(setCmd.NewCmdSet(f, nil))
 
diff --git a/pkg/cmd/alias/imports/import.go b/pkg/cmd/alias/imports/import.go
new file mode 100644
index 000000000..f724e2699
--- /dev/null
+++ b/pkg/cmd/alias/imports/import.go
@@ -0,0 +1,195 @@
+package imports
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/v2/internal/config"
+	"github.com/cli/cli/v2/pkg/cmd/alias/shared"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/spf13/cobra"
+	"gopkg.in/yaml.v3"
+)
+
+type ImportOptions struct {
+	Config func() (config.Config, error)
+	IO     *iostreams.IOStreams
+
+	Filename          string
+	OverwriteExisting bool
+
+	existingCommand func(string) bool
+}
+
+func NewCmdImport(f *cmdutil.Factory, runF func(*ImportOptions) error) *cobra.Command {
+	opts := &ImportOptions{
+		IO:     f.IOStreams,
+		Config: f.Config,
+	}
+
+	cmd := &cobra.Command{
+		Use:   "import [<filename> | -]",
+		Short: "Import aliases from a YAML file",
+		Long: heredoc.Doc(`
+			Import aliases from the contents of a YAML file.
+
+			Aliases should be defined as a map in YAML, where the keys represent aliases and
+			the values represent the corresponding expansions. An example file should look like
+			the following:
+
+			    bugs: issue list --label=bug
+			    igrep: '!gh issue list --label="$1" | grep "$2"'
+			    features: |-
+			        issue list
+			        --label=enhancement
+
+			Use "-" to read aliases (in YAML format) from standard input.
+
+			The output from the gh command "alias list" can be used to produce a YAML file
+			containing your aliases, which you can use to import them from one machine to
+			another. Run "gh help alias list" to learn more.
+		`),
+		Example: heredoc.Doc(`
+			# Import aliases from a file
+			$ gh alias import aliases.yml
+
+			# Import aliases from standard input
+			$ gh alias import -
+		`),
+		Args: func(cmd *cobra.Command, args []string) error {
+			if len(args) > 1 {
+				return cmdutil.FlagErrorf("too many arguments")
+			}
+			if len(args) == 0 && opts.IO.IsStdinTTY() {
+				return cmdutil.FlagErrorf("no filename passed and nothing on STDIN")
+			}
+			return nil
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			opts.Filename = "-"
+			if len(args) > 0 {
+				opts.Filename = args[0]
+			}
+
+			opts.existingCommand = shared.ExistingCommandFunc(f, cmd)
+
+			if runF != nil {
+				return runF(opts)
+			}
+
+			return importRun(opts)
+		},
+	}
+
+	cmd.Flags().BoolVar(&opts.OverwriteExisting, "clobber", false, "Overwrite existing aliases of the same name")
+
+	return cmd
+}
+
+func importRun(opts *ImportOptions) error {
+	cs := opts.IO.ColorScheme()
+	cfg, err := opts.Config()
+	if err != nil {
+		return err
+	}
+
+	aliasCfg := cfg.Aliases()
+
+	b, err := cmdutil.ReadFile(opts.Filename, opts.IO.In)
+	if err != nil {
+		return err
+	}
+
+	aliasMap := map[string]string{}
+	if err = yaml.Unmarshal(b, &aliasMap); err != nil {
+		return err
+	}
+
+	isTerminal := opts.IO.IsStdoutTTY()
+	if isTerminal {
+		if opts.Filename == "-" {
+			fmt.Fprintf(opts.IO.ErrOut, "- Importing aliases from standard input\n")
+		} else {
+			fmt.Fprintf(opts.IO.ErrOut, "- Importing aliases from file %q\n", opts.Filename)
+		}
+	}
+
+	var msg strings.Builder
+
+	for _, alias := range getSortedKeys(aliasMap) {
+		if opts.existingCommand(alias) {
+			msg.WriteString(
+				fmt.Sprintf("%s Could not import alias %s: already a gh command\n",
+					cs.FailureIcon(),
+					cs.Bold(alias),
+				),
+			)
+
+			continue
+		}
+
+		expansion := aliasMap[alias]
+
+		if !(strings.HasPrefix(expansion, "!") || opts.existingCommand(expansion)) {
+			msg.WriteString(
+				fmt.Sprintf("%s Could not import alias %s: expansion does not correspond to a gh command\n",
+					cs.FailureIcon(),
+					cs.Bold(alias),
+				),
+			)
+
+			continue
+		}
+
+		if _, err := aliasCfg.Get(alias); err == nil {
+			if opts.OverwriteExisting {
+				aliasCfg.Add(alias, expansion)
+
+				msg.WriteString(
+					fmt.Sprintf("%s Changed alias %s\n",
+						cs.WarningIcon(),
+						cs.Bold(alias),
+					),
+				)
+			} else {
+				msg.WriteString(
+					fmt.Sprintf("%s Could not import alias %s: name already taken\n",
+						cs.FailureIcon(),
+						cs.Bold(alias),
+					),
+				)
+			}
+		} else {
+			aliasCfg.Add(alias, expansion)
+
+			msg.WriteString(
+				fmt.Sprintf("%s Added alias %s\n",
+					cs.SuccessIcon(),
+					cs.Bold(alias),
+				),
+			)
+		}
+	}
+
+	if err := cfg.Write(); err != nil {
+		return err
+	}
+
+	if isTerminal {
+		fmt.Fprintln(opts.IO.ErrOut, msg.String())
+	}
+
+	return nil
+}
+
+func getSortedKeys(m map[string]string) []string {
+	keys := []string{}
+	for key := range m {
+		keys = append(keys, key)
+	}
+	sort.Strings(keys)
+	return keys
+}
diff --git a/pkg/cmd/alias/set/set.go b/pkg/cmd/alias/set/set.go
index 6d09787a4..871737881 100644
--- a/pkg/cmd/alias/set/set.go
+++ b/pkg/cmd/alias/set/set.go
@@ -7,9 +7,9 @@ import (
 
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/internal/config"
+	"github.com/cli/cli/v2/pkg/cmd/alias/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
-	"github.com/google/shlex"
 	"github.com/spf13/cobra"
 )
 
@@ -21,7 +21,7 @@ type SetOptions struct {
 	Expansion string
 	IsShell   bool
 
-	validCommand func(string) bool
+	existingCommand func(string) bool
 }
 
 func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command {
@@ -70,29 +70,12 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
 			opts.Name = args[0]
 			opts.Expansion = args[1]
 
-			opts.validCommand = func(args string) bool {
-				split, err := shlex.Split(args)
-				if err != nil {
-					return false
-				}
-
-				rootCmd := cmd.Root()
-				cmd, _, err := rootCmd.Traverse(split)
-				if err == nil && cmd != rootCmd {
-					return true
-				}
-
-				for _, ext := range f.ExtensionManager.List() {
-					if ext.Name() == split[0] {
-						return true
-					}
-				}
-				return false
-			}
+			opts.existingCommand = shared.ExistingCommandFunc(f, cmd)
 
 			if runF != nil {
 				return runF(opts)
 			}
+
 			return setRun(opts)
 		},
 	}
@@ -127,11 +110,11 @@ func setRun(opts *SetOptions) error {
 	}
 	isShell = strings.HasPrefix(expansion, "!")
 
-	if opts.validCommand(opts.Name) {
+	if opts.existingCommand(opts.Name) {
 		return fmt.Errorf("could not create alias: %q is already a gh command", opts.Name)
 	}
 
-	if !isShell && !opts.validCommand(expansion) {
+	if !isShell && !opts.existingCommand(expansion) {
 		return fmt.Errorf("could not create alias: %s does not correspond to a gh command", expansion)
 	}
 
diff --git a/pkg/cmd/alias/shared/validations.go b/pkg/cmd/alias/shared/validations.go
new file mode 100644
index 000000000..6557f66ca
--- /dev/null
+++ b/pkg/cmd/alias/shared/validations.go
@@ -0,0 +1,32 @@
+package shared
+
+import (
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/google/shlex"
+	"github.com/spf13/cobra"
+)
+
+// ExistingCommandFunc returns a function that will check if the given string
+// corresponds to an existing command.
+func ExistingCommandFunc(f *cmdutil.Factory, cmd *cobra.Command) func(string) bool {
+	return func(args string) bool {
+		split, err := shlex.Split(args)
+		if err != nil || len(split) == 0 {
+			return false
+		}
+
+		rootCmd := cmd.Root()
+		cmd, _, err = rootCmd.Traverse(split)
+		if err == nil && cmd != rootCmd {
+			return true
+		}
+
+		for _, ext := range f.ExtensionManager.List() {
+			if ext.Name() == split[0] {
+				return true
+			}
+		}
+
+		return false
+	}
+}
diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go
index 640fee632..fe2269e7e 100644
--- a/pkg/cmd/repo/create/create.go
+++ b/pkg/cmd/repo/create/create.go
@@ -730,7 +730,7 @@ func interactiveRepoInfo(client *http.Client, hostname string, prompter iprompte
 		name = fmt.Sprintf("%s/%s", owner, name)
 	}
 
-	description, err := prompter.Input("Description", defaultName)
+	description, err := prompter.Input("Description", "")
 	if err != nil {
 		return "", "", "", err
 	}


Test patch:
diff --git a/pkg/cmd/alias/imports/import_test.go b/pkg/cmd/alias/imports/import_test.go
new file mode 100644
index 000000000..8837a52db
--- /dev/null
+++ b/pkg/cmd/alias/imports/import_test.go
@@ -0,0 +1,346 @@
+package imports
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/v2/internal/config"
+	"github.com/cli/cli/v2/pkg/cmd/alias/shared"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/extensions"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/google/shlex"
+	"github.com/spf13/cobra"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestNewCmdImport(t *testing.T) {
+	tests := []struct {
+		name       string
+		cli        string
+		tty        bool
+		wants      ImportOptions
+		wantsError string
+	}{
+		{
+			name: "no filename and stdin tty",
+			cli:  "",
+			tty:  true,
+			wants: ImportOptions{
+				Filename:          "",
+				OverwriteExisting: false,
+			},
+			wantsError: "no filename passed and nothing on STDIN",
+		},
+		{
+			name: "no filename and stdin is not tty",
+			cli:  "",
+			tty:  false,
+			wants: ImportOptions{
+				Filename:          "-",
+				OverwriteExisting: false,
+			},
+		},
+		{
+			name: "stdin arg",
+			cli:  "-",
+			wants: ImportOptions{
+				Filename:          "-",
+				OverwriteExisting: false,
+			},
+		},
+		{
+			name: "multiple filenames",
+			cli:  "aliases1 aliases2",
+			wants: ImportOptions{
+				Filename:          "aliases1 aliases2",
+				OverwriteExisting: false,
+			},
+			wantsError: "too many arguments",
+		},
+		{
+			name: "clobber flag",
+			cli:  "aliases --clobber",
+			wants: ImportOptions{
+				Filename:          "aliases",
+				OverwriteExisting: true,
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ios, _, _, _ := iostreams.Test()
+			ios.SetStdinTTY(tt.tty)
+			f := &cmdutil.Factory{IOStreams: ios}
+
+			argv, err := shlex.Split(tt.cli)
+			assert.NoError(t, err)
+
+			var gotOpts *ImportOptions
+			cmd := NewCmdImport(f, func(opts *ImportOptions) error {
+				gotOpts = opts
+				return nil
+			})
+			cmd.SetArgs(argv)
+			cmd.SetIn(&bytes.Buffer{})
+			cmd.SetOut(io.Discard)
+			cmd.SetErr(io.Discard)
+
+			_, err = cmd.ExecuteC()
+			if tt.wantsError != "" {
+				assert.EqualError(t, err, tt.wantsError)
+				return
+			}
+			assert.NoError(t, err)
+
+			assert.Equal(t, tt.wants.Filename, gotOpts.Filename)
+			assert.Equal(t, tt.wants.OverwriteExisting, gotOpts.OverwriteExisting)
+		})
+	}
+}
+
+func TestImportRun(t *testing.T) {
+	tmpFile := filepath.Join(t.TempDir(), "aliases.yml")
+	importFileMsg := fmt.Sprintf(`- Importing aliases from file %q`, tmpFile)
+	importStdinMsg := "- Importing aliases from standard input"
+
+	tests := []struct {
+		name         string
+		opts         *ImportOptions
+		stdin        string
+		fileContents string
+		initConfig   string
+		wantConfig   string
+		wantStderr   string
+	}{
+		{
+			name: "with no existing aliases",
+			opts: &ImportOptions{
+				Filename:          tmpFile,
+				OverwriteExisting: false,
+			},
+			fileContents: heredoc.Doc(`
+                co: pr checkout
+                igrep: '!gh issue list --label="$1" | grep "$2"'
+            `),
+			wantConfig: heredoc.Doc(`
+                aliases:
+                    co: pr checkout
+                    igrep: '!gh issue list --label="$1" | grep "$2"'
+            `),
+			wantStderr: importFileMsg + "\n✓ Added alias co\n✓ Added alias igrep\n\n",
+		},
+		{
+			name: "with existing aliases",
+			opts: &ImportOptions{
+				Filename:          tmpFile,
+				OverwriteExisting: false,
+			},
+			fileContents: heredoc.Doc(`
+                users: |-
+                    api graphql -F name="$1" -f query='
+                        query ($name: String!) {
+                            user(login: $name) {
+                                name
+                            }
+                        }'
+                co: pr checkout
+            `),
+			initConfig: heredoc.Doc(`
+                aliases:
+                    igrep: '!gh issue list --label="$1" | grep "$2"'
+                editor: vim
+            `),
+			wantConfig: heredoc.Doc(`
+                aliases:
+                    igrep: '!gh issue list --label="$1" | grep "$2"'
+                    co: pr checkout
+                    users: |-
+                        api graphql -F name="$1" -f query='
+                            query ($name: String!) {
+                                user(login: $name) {
+                                    name
+                                }
+                            }'
+                editor: vim
+            `),
+			wantStderr: importFileMsg + "\n✓ Added alias co\n✓ Added alias users\n\n",
+		},
+		{
+			name: "from stdin",
+			opts: &ImportOptions{
+				Filename:          "-",
+				OverwriteExisting: false,
+			},
+			stdin: heredoc.Doc(`
+                co: pr checkout
+                features: |-
+                    issue list
+                    --label=enhancement
+                igrep: '!gh issue list --label="$1" | grep "$2"'
+            `),
+			wantConfig: heredoc.Doc(`
+                aliases:
+                    co: pr checkout
+                    features: |-
+                        issue list
+                        --label=enhancement
+                    igrep: '!gh issue list --label="$1" | grep "$2"'
+            `),
+			wantStderr: importStdinMsg + "\n✓ Added alias co\n✓ Added alias features\n✓ Added alias igrep\n\n",
+		},
+		{
+			name: "already taken aliases",
+			opts: &ImportOptions{
+				Filename:          tmpFile,
+				OverwriteExisting: false,
+			},
+			fileContents: heredoc.Doc(`
+                co: pr checkout -R cool/repo
+                igrep: '!gh issue list --label="$1" | grep "$2"'
+            `),
+			initConfig: heredoc.Doc(`
+                aliases:
+                    co: pr checkout
+                editor: vim
+            `),
+			wantConfig: heredoc.Doc(`
+                aliases:
+                    co: pr checkout
+                    igrep: '!gh issue list --label="$1" | grep "$2"'
+                editor: vim
+            `),
+			wantStderr: importFileMsg + "\nX Could not import alias co: name already taken\n✓ Added alias igrep\n\n",
+		},
+		{
+			name: "override aliases",
+			opts: &ImportOptions{
+				Filename:          tmpFile,
+				OverwriteExisting: true,
+			},
+			fileContents: heredoc.Doc(`
+                co: pr checkout -R cool/repo
+                igrep: '!gh issue list --label="$1" | grep "$2"'
+            `),
+			initConfig: heredoc.Doc(`
+                aliases:
+                    co: pr checkout
+                editor: vim
+            `),
+			wantConfig: heredoc.Doc(`
+                aliases:
+                    co: pr checkout -R cool/repo
+                    igrep: '!gh issue list --label="$1" | grep "$2"'
+                editor: vim
+            `),
+			wantStderr: importFileMsg + "\n! Changed alias co\n✓ Added alias igrep\n\n",
+		},
+		{
+			name: "alias is a gh command",
+			opts: &ImportOptions{
+				Filename:          tmpFile,
+				OverwriteExisting: false,
+			},
+			fileContents: heredoc.Doc(`
+                pr: pr checkout
+                issue: issue list
+                api: api graphql
+            `),
+			wantStderr: strings.Join(
+				[]string{
+					importFileMsg,
+					"X Could not import alias api: already a gh command",
+					"X Could not import alias issue: already a gh command",
+					"X Could not import alias pr: already a gh command\n\n",
+				},
+				"\n",
+			),
+		},
+		{
+			name: "invalid expansion",
+			opts: &ImportOptions{
+				Filename:          tmpFile,
+				OverwriteExisting: false,
+			},
+			fileContents: heredoc.Doc(`
+                alias1:
+                alias2: ps checkout
+            `),
+			wantStderr: strings.Join(
+				[]string{
+					importFileMsg,
+					"X Could not import alias alias1: expansion does not correspond to a gh command",
+					"X Could not import alias alias2: expansion does not correspond to a gh command\n\n",
+				},
+				"\n",
+			),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.fileContents != "" {
+				err := os.WriteFile(tmpFile, []byte(tt.fileContents), 0600)
+				require.NoError(t, err)
+			}
+
+			ios, stdin, _, stderr := iostreams.Test()
+			ios.SetStdinTTY(true)
+			ios.SetStdoutTTY(true)
+			ios.SetStderrTTY(true)
+			tt.opts.IO = ios
+
+			readConfigs := config.StubWriteConfig(t)
+			cfg := config.NewFromString(tt.initConfig)
+			tt.opts.Config = func() (config.Config, error) {
+				return cfg, nil
+			}
+
+			// Create fake command factory for testing.
+			f := &cmdutil.Factory{
+				IOStreams: ios,
+				ExtensionManager: &extensions.ExtensionManagerMock{
+					ListFunc: func() []extensions.Extension {
+						return []extensions.Extension{}
+					},
+				},
+			}
+
+			// Create fake command structure for testing.
+			rootCmd := &cobra.Command{}
+			prCmd := &cobra.Command{Use: "pr"}
+			prCmd.AddCommand(&cobra.Command{Use: "checkout"})
+			prCmd.AddCommand(&cobra.Command{Use: "status"})
+			rootCmd.AddCommand(prCmd)
+			issueCmd := &cobra.Command{Use: "issue"}
+			issueCmd.AddCommand(&cobra.Command{Use: "list"})
+			rootCmd.AddCommand(issueCmd)
+			apiCmd := &cobra.Command{Use: "api"}
+			apiCmd.AddCommand(&cobra.Command{Use: "graphql"})
+			rootCmd.AddCommand(apiCmd)
+
+			tt.opts.existingCommand = shared.ExistingCommandFunc(f, rootCmd)
+
+			if tt.stdin != "" {
+				stdin.WriteString(tt.stdin)
+			}
+
+			err := importRun(tt.opts)
+			require.NoError(t, err)
+
+			configOut := bytes.Buffer{}
+			readConfigs(&configOut, io.Discard)
+
+			assert.Equal(t, tt.wantStderr, stderr.String())
+			assert.Equal(t, tt.wantConfig, configOut.String())
+		})
+	}
+}
diff --git a/pkg/cmd/alias/set/set_test.go b/pkg/cmd/alias/set/set_test.go
index 72d6fb413..bec03cba4 100644
--- a/pkg/cmd/alias/set/set_test.go
+++ b/pkg/cmd/alias/set/set_test.go
@@ -38,7 +38,7 @@ func runCommand(cfg config.Config, isTTY bool, cli string, in string) (*test.Cmd
 
 	cmd := NewCmdSet(factory, nil)
 
-	// fake command nesting structure needed for validCommand
+	// Create fake command structure for testing.
 	rootCmd := &cobra.Command{}
 	rootCmd.AddCommand(cmd)
 	prCmd := &cobra.Command{Use: "pr"}
diff --git a/pkg/cmd/alias/shared/validations_test.go b/pkg/cmd/alias/shared/validations_test.go
new file mode 100644
index 000000000..faa0e497d
--- /dev/null
+++ b/pkg/cmd/alias/shared/validations_test.go
@@ -0,0 +1,40 @@
+package shared
+
+import (
+	"testing"
+
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/extensions"
+	"github.com/spf13/cobra"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestExistingCommandFunc(t *testing.T) {
+	// Create fake command factory for testing.
+	factory := &cmdutil.Factory{
+		ExtensionManager: &extensions.ExtensionManagerMock{
+			ListFunc: func() []extensions.Extension {
+				return []extensions.Extension{}
+			},
+		},
+	}
+
+	// Create fake command structure for testing.
+	issueCmd := &cobra.Command{Use: "issue"}
+	prCmd := &cobra.Command{Use: "pr"}
+	prCmd.AddCommand(&cobra.Command{Use: "checkout"})
+
+	cmd := &cobra.Command{}
+	cmd.AddCommand(prCmd)
+	cmd.AddCommand(issueCmd)
+
+	f := ExistingCommandFunc(factory, cmd)
+
+	assert.True(t, f("pr"))
+	assert.True(t, f("pr checkout"))
+	assert.True(t, f("issue"))
+
+	assert.False(t, f("ps"))
+	assert.False(t, f("checkout"))
+	assert.False(t, f("repo list"))
+}