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/api/api.go b/internal/codespaces/api/api.go index 667a9c3dc..c45eafb87 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -128,11 +128,18 @@ func (a *API) GetUser(ctx context.Context) (*User, error) { return &response, nil } +// RepositoryOwner represents owner of a repository +type RepositoryOwner struct { + Type string `json:"type"` + Login string `json:"login"` +} + // Repository represents a GitHub repository. type Repository struct { - ID int `json:"id"` - FullName string `json:"full_name"` - DefaultBranch string `json:"default_branch"` + ID int `json:"id"` + FullName string `json:"full_name"` + DefaultBranch string `json:"default_branch"` + Owner RepositoryOwner `json:"owner"` } // GetRepository returns the repository associated with the given owner and name. 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 [ | -]", + 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/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.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/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.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/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")) +} diff --git a/pkg/cmd/codespace/codespace_selector.go b/pkg/cmd/codespace/codespace_selector.go index 69b9ed06b..a51e42b6f 100644 --- a/pkg/cmd/codespace/codespace_selector.go +++ b/pkg/cmd/codespace/codespace_selector.go @@ -15,6 +15,7 @@ type CodespaceSelector struct { repoName string codespaceName string + repoOwner string } var errNoFilteredCodespaces = errors.New("you have no codespaces meeting the filter criteria") @@ -25,8 +26,10 @@ func AddCodespaceSelector(cmd *cobra.Command, api apiClient) *CodespaceSelector cmd.PersistentFlags().StringVarP(&cs.codespaceName, "codespace", "c", "", "Name of the codespace") cmd.PersistentFlags().StringVarP(&cs.repoName, "repo", "R", "", "Filter codespace selection by repository name (user/repo)") + cmd.PersistentFlags().StringVar(&cs.repoOwner, "repo-owner", "", "Filter codespace selection by repository owner (username or org)") cmd.MarkFlagsMutuallyExclusive("codespace", "repo") + cmd.MarkFlagsMutuallyExclusive("codespace", "repo-owner") return cs } @@ -102,6 +105,10 @@ func (cs *CodespaceSelector) fetchCodespaces(ctx context.Context) (codespaces [] codespaces = filteredCodespaces } + if cs.repoOwner != "" { + codespaces = filterCodespacesByRepoOwner(codespaces, cs.repoOwner) + } + if len(codespaces) == 0 { return nil, errNoFilteredCodespaces } diff --git a/pkg/cmd/codespace/codespace_selector_test.go b/pkg/cmd/codespace/codespace_selector_test.go index 30ba3c588..7b34ebd7b 100644 --- a/pkg/cmd/codespace/codespace_selector_test.go +++ b/pkg/cmd/codespace/codespace_selector_test.go @@ -48,68 +48,101 @@ func TestSelectNameWithCodespaceName(t *testing.T) { func TestFetchCodespaces(t *testing.T) { var ( - repoA1 = &api.Codespace{Name: "1", Repository: api.Repository{FullName: "mock/A"}} - repoA2 = &api.Codespace{Name: "2", Repository: api.Repository{FullName: "mock/A"}} + octocatOwner = api.RepositoryOwner{Login: "octocat"} + cliOwner = api.RepositoryOwner{Login: "cli"} + octocatA = &api.Codespace{ + Name: "1", + Repository: api.Repository{FullName: "octocat/A", Owner: octocatOwner}, + } - repoB1 = &api.Codespace{Name: "1", Repository: api.Repository{FullName: "mock/B"}} + octocatA2 = &api.Codespace{ + Name: "2", + Repository: api.Repository{FullName: "octocat/A", Owner: octocatOwner}, + } + + cliA = &api.Codespace{ + Name: "3", + Repository: api.Repository{FullName: "cli/A", Owner: cliOwner}, + } + + octocatB = &api.Codespace{ + Name: "4", + Repository: api.Repository{FullName: "octocat/B", Owner: octocatOwner}, + } ) tests := []struct { tName string apiCodespaces []*api.Codespace + codespaceName string repoName string + repoOwner string wantCodespaces []*api.Codespace wantErr error }{ // Empty case { - "empty", nil, "", nil, errNoCodespaces, + tName: "empty", + apiCodespaces: nil, + wantCodespaces: nil, + wantErr: errNoCodespaces, }, // Tests with no filtering { - "no filtering, single codespace", - []*api.Codespace{repoA1}, - "", - []*api.Codespace{repoA1}, - nil, + tName: "no filtering, single codespaces", + apiCodespaces: []*api.Codespace{octocatA}, + wantCodespaces: []*api.Codespace{octocatA}, + wantErr: nil, }, { - "no filtering, multiple codespaces", - []*api.Codespace{repoA1, repoA2, repoB1}, - "", - []*api.Codespace{repoA1, repoA2, repoB1}, - nil, + tName: "no filtering, multiple codespace", + apiCodespaces: []*api.Codespace{octocatA, cliA, octocatB}, + wantCodespaces: []*api.Codespace{octocatA, cliA, octocatB}, }, // Test repo filtering { - "repo filtering, single codespace", - []*api.Codespace{repoA1}, - "mock/A", - []*api.Codespace{repoA1}, - nil, + tName: "repo name filtering, single codespace", + apiCodespaces: []*api.Codespace{octocatA}, + repoName: "octocat/A", + wantCodespaces: []*api.Codespace{octocatA}, + wantErr: nil, }, { - "repo filtering, multiple codespaces", - []*api.Codespace{repoA1, repoA2, repoB1}, - "mock/A", - []*api.Codespace{repoA1, repoA2}, - nil, + tName: "repo name filtering, multiple codespace", + apiCodespaces: []*api.Codespace{octocatA, octocatA2, cliA, octocatB}, + repoName: "octocat/A", + wantCodespaces: []*api.Codespace{octocatA, octocatA2}, + wantErr: nil, }, { - "repo filtering, multiple codespaces 2", - []*api.Codespace{repoA1, repoA2, repoB1}, - "mock/B", - []*api.Codespace{repoB1}, - nil, + tName: "repo name filtering, multiple codespace 2", + apiCodespaces: []*api.Codespace{octocatA, cliA, octocatB}, + repoName: "octocat/B", + wantCodespaces: []*api.Codespace{octocatB}, + wantErr: nil, }, { - "repo filtering, no matches", - []*api.Codespace{repoA1, repoA2, repoB1}, - "mock/C", - nil, - errNoFilteredCodespaces, + tName: "repo name filtering, no matches", + apiCodespaces: []*api.Codespace{octocatA, cliA, octocatB}, + repoName: "Unknown/unknown", + wantCodespaces: nil, + wantErr: errNoFilteredCodespaces, + }, + { + tName: "repo filtering, match with repo owner", + apiCodespaces: []*api.Codespace{octocatA, octocatA2, cliA, octocatB}, + repoOwner: "octocat", + wantCodespaces: []*api.Codespace{octocatA, octocatA2, octocatB}, + wantErr: nil, + }, + { + tName: "repo filtering, no match with repo owner", + apiCodespaces: []*api.Codespace{octocatA, cliA, octocatB}, + repoOwner: "unknown", + wantCodespaces: []*api.Codespace{}, + wantErr: errNoFilteredCodespaces, }, } @@ -121,7 +154,11 @@ func TestFetchCodespaces(t *testing.T) { }, } - cs := &CodespaceSelector{api: api, repoName: tt.repoName} + cs := &CodespaceSelector{ + api: api, + repoName: tt.repoName, + repoOwner: tt.repoOwner, + } codespaces, err := cs.fetchCodespaces(context.Background()) diff --git a/pkg/cmd/codespace/common.go b/pkg/cmd/codespace/common.go index 8ccc52a4f..f6f6fb937 100644 --- a/pkg/cmd/codespace/common.go +++ b/pkg/cmd/codespace/common.go @@ -10,6 +10,7 @@ import ( "log" "os" "sort" + "strings" "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" @@ -266,3 +267,14 @@ func addDeprecatedRepoShorthand(cmd *cobra.Command, target *string) error { return nil } + +// filterCodespacesByRepoOwner filters a list of codespaces by the owner of the repository. +func filterCodespacesByRepoOwner(codespaces []*api.Codespace, repoOwner string) []*api.Codespace { + filtered := make([]*api.Codespace, 0, len(codespaces)) + for _, c := range codespaces { + if strings.EqualFold(c.Repository.Owner.Login, repoOwner) { + filtered = append(filtered, c) + } + } + return filtered +} diff --git a/pkg/cmd/codespace/delete.go b/pkg/cmd/codespace/delete.go index d67c271ad..5a7028dc1 100644 --- a/pkg/cmd/codespace/delete.go +++ b/pkg/cmd/codespace/delete.go @@ -23,6 +23,7 @@ type deleteOptions struct { keepDays uint16 orgName string userName string + repoOwner string isInteractive bool now func() time.Time @@ -60,10 +61,12 @@ func newDeleteCmd(app *App) *cobra.Command { // After the admin subcommand is added (see https://github.com/cli/cli/pull/6944#issuecomment-1419553639) we can revisit this. opts.codespaceName = selector.codespaceName opts.repoFilter = selector.repoName + opts.repoOwner = selector.repoOwner if opts.deleteAll && opts.repoFilter != "" { return cmdutil.FlagErrorf("both `--all` and `--repo` is not supported") } + if opts.orgName != "" && opts.codespaceName != "" && opts.userName == "" { return cmdutil.FlagErrorf("using `--org` with `--codespace` requires `--user`") } @@ -99,6 +102,9 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) { userName = currentUser.Login } codespaces, fetchErr = a.apiClient.ListCodespaces(ctx, api.ListCodespacesOptions{OrgName: opts.orgName, UserName: userName}) + if opts.repoOwner != "" { + codespaces = filterCodespacesByRepoOwner(codespaces, opts.repoOwner) + } return }) if err != nil { @@ -139,6 +145,7 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) { if opts.repoFilter != "" && !strings.EqualFold(c.Repository.FullName, opts.repoFilter) { continue } + if opts.keepDays > 0 { t, err := time.Parse(time.RFC3339, c.LastUsedAt) if err != nil { diff --git a/pkg/cmd/codespace/delete_test.go b/pkg/cmd/codespace/delete_test.go index ca6fe989e..0a5a76956 100644 --- a/pkg/cmd/codespace/delete_test.go +++ b/pkg/cmd/codespace/delete_test.go @@ -217,6 +217,44 @@ func TestDelete(t *testing.T) { wantDeleted: []string{"monalisa-spoonknife-123"}, wantStdout: "", }, + { + name: "by repo owner", + opts: deleteOptions{ + deleteAll: true, + repoOwner: "octocat", + }, + codespaces: []*api.Codespace{ + { + Name: "octocat-spoonknife-123", + Repository: api.Repository{ + FullName: "octocat/Spoon-Knife", + Owner: api.RepositoryOwner{ + Login: "octocat", + }, + }, + }, + { + Name: "cli-robawt-abc", + Repository: api.Repository{ + FullName: "cli/ROBAWT", + Owner: api.RepositoryOwner{ + Login: "cli", + }, + }, + }, + { + Name: "octocat-spoonknife-c4f3", + Repository: api.Repository{ + FullName: "octocat/Spoon-Knife", + Owner: api.RepositoryOwner{ + Login: "octocat", + }, + }, + }, + }, + wantDeleted: []string{"octocat-spoonknife-123", "octocat-spoonknife-c4f3"}, + wantStdout: "", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go index b957ba509..2c7f4a18a 100644 --- a/pkg/cmd/release/download/download.go +++ b/pkg/cmd/release/download/download.go @@ -140,7 +140,7 @@ func downloadRun(opts *DownloadOptions) error { return err } - opts.IO.StartProgressIndicator() + opts.IO.StartProgressIndicatorWithLabel("Finding assets to download") defer opts.IO.StopProgressIndicator() ctx := context.Background() @@ -196,7 +196,7 @@ func downloadRun(opts *DownloadOptions) error { stdout: opts.IO.Out, } - return downloadAssets(&dest, httpClient, toDownload, opts.Concurrency, isArchive) + return downloadAssets(&dest, httpClient, toDownload, opts.Concurrency, isArchive, opts.IO) } func matchAny(patterns []string, name string) bool { @@ -208,7 +208,7 @@ func matchAny(patterns []string, name string) bool { return false } -func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload []shared.ReleaseAsset, numWorkers int, isArchive bool) error { +func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload []shared.ReleaseAsset, numWorkers int, isArchive bool, io *iostreams.IOStreams) error { if numWorkers == 0 { return errors.New("the number of concurrent workers needs to be greater than 0") } @@ -223,6 +223,7 @@ func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload for w := 1; w <= numWorkers; w++ { go func() { for a := range jobs { + io.StartProgressIndicatorWithLabel(fmt.Sprintf("Downloading %s", a.Name)) results <- downloadAsset(dest, httpClient, a.APIURL, a.Name, isArchive) } }() @@ -240,6 +241,8 @@ func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload } } + io.StopProgressIndicator() + return downloadError } 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 }