diff --git a/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar b/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar new file mode 100644 index 000000000..f4d1bbb4a --- /dev/null +++ b/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar @@ -0,0 +1,36 @@ +# Set up env vars +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes ${ORG}/${REPO} + +# Create a fork +exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${REPO}-fork + +# Defer fork cleanup +defer gh repo delete --yes ${ORG}/${REPO}-fork + +# Sleep to allow the fork to be created before cloning +sleep 2 + +# Clone and move into the fork repo +exec gh repo clone ${ORG}/${REPO}-fork +cd ${REPO}-fork + +# Secret list requires disambiguation +! exec gh secret list +stderr 'multiple remotes detected. please specify which repo to use by providing the -R, --repo argument' + +# Secret set requires disambiguation +! exec gh secret set 'TEST_SECRET_NAME' --body 'TEST_SECRET_VALUE' +stderr 'multiple remotes detected. please specify which repo to use by providing the -R, --repo argument' + +# Secret delete requires disambiguation +! exec gh secret delete 'TEST_SECRET_NAME' +stderr 'multiple remotes detected. please specify which repo to use by providing the -R, --repo argument' diff --git a/pkg/cmd/factory/remote_resolver.go b/pkg/cmd/factory/remote_resolver.go index 7e27834e2..b008ade7e 100644 --- a/pkg/cmd/factory/remote_resolver.go +++ b/pkg/cmd/factory/remote_resolver.go @@ -69,6 +69,7 @@ func (rr *remoteResolver) Resolver() func() (context.Remotes, error) { sort.Sort(resolvedRemotes) // Filter remotes by hosts + // Note that this is not caching correctly: https://github.com/cli/cli/issues/10103 cachedRemotes := resolvedRemotes.FilterByHosts(hosts) // Filter again by default host if one is set diff --git a/pkg/cmd/repo/setdefault/setdefault.go b/pkg/cmd/repo/setdefault/setdefault.go index eb8fcfb5a..3e1554a69 100644 --- a/pkg/cmd/repo/setdefault/setdefault.go +++ b/pkg/cmd/repo/setdefault/setdefault.go @@ -30,7 +30,8 @@ func explainer() string { - viewing and creating issues - viewing and creating releases - working with GitHub Actions - - adding repository and environment secrets`) + + ### NOTE: gh does not use the default repository for managing repository and environment secrets.`) } type iprompter interface { diff --git a/pkg/cmd/repo/setdefault/setdefault_test.go b/pkg/cmd/repo/setdefault/setdefault_test.go index 55a0193d5..dfd72d83e 100644 --- a/pkg/cmd/repo/setdefault/setdefault_test.go +++ b/pkg/cmd/repo/setdefault/setdefault_test.go @@ -382,7 +382,7 @@ func TestDefaultRun(t *testing.T) { } } }, - wantStdout: "This command sets the default remote repository to use when querying the\nGitHub API for the locally cloned repository.\n\ngh uses the default repository for things like:\n\n - viewing and creating pull requests\n - viewing and creating issues\n - viewing and creating releases\n - working with GitHub Actions\n - adding repository and environment secrets\n\n✓ Set OWNER2/REPO2 as the default repository for the current directory\n", + wantStdout: "This command sets the default remote repository to use when querying the\nGitHub API for the locally cloned repository.\n\ngh uses the default repository for things like:\n\n - viewing and creating pull requests\n - viewing and creating issues\n - viewing and creating releases\n - working with GitHub Actions\n\n### NOTE: gh does not use the default repository for managing repository and environment secrets.\n\n✓ Set OWNER2/REPO2 as the default repository for the current directory\n", }, { name: "interactive mode only one known host", @@ -456,7 +456,7 @@ func TestDefaultRun(t *testing.T) { } } }, - wantStdout: "This command sets the default remote repository to use when querying the\nGitHub API for the locally cloned repository.\n\ngh uses the default repository for things like:\n\n - viewing and creating pull requests\n - viewing and creating issues\n - viewing and creating releases\n - working with GitHub Actions\n - adding repository and environment secrets\n\n✓ Set OWNER2/REPO2 as the default repository for the current directory\n", + wantStdout: "This command sets the default remote repository to use when querying the\nGitHub API for the locally cloned repository.\n\ngh uses the default repository for things like:\n\n - viewing and creating pull requests\n - viewing and creating issues\n - viewing and creating releases\n - working with GitHub Actions\n\n### NOTE: gh does not use the default repository for managing repository and environment secrets.\n\n✓ Set OWNER2/REPO2 as the default repository for the current directory\n", }, } diff --git a/pkg/cmd/secret/delete/delete.go b/pkg/cmd/secret/delete/delete.go index 564e8633f..84a5e7a83 100644 --- a/pkg/cmd/secret/delete/delete.go +++ b/pkg/cmd/secret/delete/delete.go @@ -46,8 +46,19 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co `), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override + // If the user specified a repo directly, then we're using the OverrideBaseRepoFunc set by EnableRepoOverride + // So there's no reason to use the specialised BaseRepoFunc that requires remote disambiguation. opts.BaseRepo = f.BaseRepo + if !cmd.Flags().Changed("repo") { + // If they haven't specified a repo directly, then we will wrap the BaseRepoFunc in one that errors if + // there might be multiple valid remotes. + opts.BaseRepo = shared.RequireNoAmbiguityBaseRepoFunc(opts.BaseRepo, f.Remotes) + // But if we are able to prompt, then we will wrap that up in a BaseRepoFunc that can prompt the user to + // resolve the ambiguity. + if opts.IO.CanPrompt() { + opts.BaseRepo = shared.PromptWhenAmbiguousBaseRepoFunc(opts.BaseRepo, f.IOStreams, f.Prompter) + } + } if err := cmdutil.MutuallyExclusive("specify only one of `--org`, `--env`, or `--user`", opts.OrgName != "", opts.EnvName != "", opts.UserSecrets); err != nil { return err @@ -88,6 +99,14 @@ func removeRun(opts *DeleteOptions) error { return err } + var baseRepo ghrepo.Interface + if secretEntity == shared.Repository || secretEntity == shared.Environment { + baseRepo, err = opts.BaseRepo() + if err != nil { + return err + } + } + secretApp, err := shared.GetSecretApp(opts.Application, secretEntity) if err != nil { return err @@ -97,14 +116,6 @@ func removeRun(opts *DeleteOptions) error { return fmt.Errorf("%s secrets are not supported for %s", secretEntity, secretApp) } - var baseRepo ghrepo.Interface - if secretEntity == shared.Repository || secretEntity == shared.Environment { - baseRepo, err = opts.BaseRepo() - if err != nil { - return err - } - } - cfg, err := opts.Config() if err != nil { return err diff --git a/pkg/cmd/secret/delete/delete_test.go b/pkg/cmd/secret/delete/delete_test.go index bd5053f4d..377879cf2 100644 --- a/pkg/cmd/secret/delete/delete_test.go +++ b/pkg/cmd/secret/delete/delete_test.go @@ -2,12 +2,17 @@ package delete import ( "bytes" + "io" "net/http" "testing" + ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmd/secret/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -120,6 +125,108 @@ func TestNewCmdDelete(t *testing.T) { } } +func TestNewCmdDeleteBaseRepoFuncs(t *testing.T) { + remotes := ghContext.Remotes{ + &ghContext.Remote{ + Remote: &git.Remote{ + Name: "origin", + }, + Repo: ghrepo.New("owner", "fork"), + }, + &ghContext.Remote{ + Remote: &git.Remote{ + Name: "upstream", + }, + Repo: ghrepo.New("owner", "repo"), + }, + } + + tests := []struct { + name string + args string + prompterStubs func(*prompter.MockPrompter) + wantRepo ghrepo.Interface + wantErr error + }{ + { + name: "when there is a repo flag provided, the factory base repo func is used", + args: "SECRET_NAME --repo owner/repo", + wantRepo: ghrepo.New("owner", "repo"), + }, + { + name: "when there is no repo flag provided, and no prompting, the base func requiring no ambiguity is used", + args: "SECRET_NAME", + wantErr: shared.AmbiguousBaseRepoError{ + Remotes: remotes, + }, + }, + { + name: "when there is no repo flag provided, and can prompt, the base func resolving ambiguity is used", + args: "SECRET_NAME", + prompterStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect( + "Select a repo", + []string{"owner/fork", "owner/repo"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "owner/fork") + }, + ) + }, + wantRepo: ghrepo.New("owner", "fork"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + var pm *prompter.MockPrompter + if tt.prompterStubs != nil { + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + pm = prompter.NewMockPrompter(t) + tt.prompterStubs(pm) + } + + f := &cmdutil.Factory{ + IOStreams: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + }, + Prompter: pm, + Remotes: func() (ghContext.Remotes, error) { + return remotes, nil + }, + } + + argv, err := shlex.Split(tt.args) + assert.NoError(t, err) + + var gotOpts *DeleteOptions + cmd := NewCmdDelete(f, func(opts *DeleteOptions) error { + gotOpts = opts + return nil + }) + // Require to support --repo flag + cmdutil.EnableRepoOverride(cmd, f) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, err = cmd.ExecuteC() + require.NoError(t, err) + + baseRepo, err := gotOpts.BaseRepo() + if tt.wantErr != nil { + require.Equal(t, tt.wantErr, err) + return + } + require.True(t, ghrepo.IsSame(tt.wantRepo, baseRepo)) + }) + } +} + func Test_removeRun_repo(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go index 9e1fd305d..af3526386 100644 --- a/pkg/cmd/secret/list/list.go +++ b/pkg/cmd/secret/list/list.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/secret/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -23,8 +24,10 @@ type ListOptions struct { IO *iostreams.IOStreams Config func() (gh.Config, error) BaseRepo func() (ghrepo.Interface, error) - Now func() time.Time - Exporter cmdutil.Exporter + Prompter prompter.Prompter + + Now func() time.Time + Exporter cmdutil.Exporter OrgName string EnvName string @@ -48,6 +51,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Config: f.Config, HttpClient: f.HttpClient, Now: time.Now, + Prompter: f.Prompter, } cmd := &cobra.Command{ @@ -63,8 +67,19 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Aliases: []string{"ls"}, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override + // If the user specified a repo directly, then we're using the OverrideBaseRepoFunc set by EnableRepoOverride + // So there's no reason to use the specialised BaseRepoFunc that requires remote disambiguation. opts.BaseRepo = f.BaseRepo + if !cmd.Flags().Changed("repo") { + // If they haven't specified a repo directly, then we will wrap the BaseRepoFunc in one that errors if + // there might be multiple valid remotes. + opts.BaseRepo = shared.RequireNoAmbiguityBaseRepoFunc(opts.BaseRepo, f.Remotes) + // But if we are able to prompt, then we will wrap that up in a BaseRepoFunc that can prompt the user to + // resolve the ambiguity. + if opts.IO.CanPrompt() { + opts.BaseRepo = shared.PromptWhenAmbiguousBaseRepoFunc(opts.BaseRepo, f.IOStreams, f.Prompter) + } + } if err := cmdutil.MutuallyExclusive("specify only one of `--org`, `--env`, or `--user`", opts.OrgName != "", opts.EnvName != "", opts.UserSecrets); err != nil { return err diff --git a/pkg/cmd/secret/list/list_test.go b/pkg/cmd/secret/list/list_test.go index 2e7b19065..601de2572 100644 --- a/pkg/cmd/secret/list/list_test.go +++ b/pkg/cmd/secret/list/list_test.go @@ -3,15 +3,19 @@ package list import ( "bytes" "fmt" + "io" "net/http" "net/url" "strings" "testing" "time" + ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/secret/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -101,6 +105,108 @@ func Test_NewCmdList(t *testing.T) { } } +func TestNewCmdListBaseRepoFuncs(t *testing.T) { + remotes := ghContext.Remotes{ + &ghContext.Remote{ + Remote: &git.Remote{ + Name: "origin", + }, + Repo: ghrepo.New("owner", "fork"), + }, + &ghContext.Remote{ + Remote: &git.Remote{ + Name: "upstream", + }, + Repo: ghrepo.New("owner", "repo"), + }, + } + + tests := []struct { + name string + args string + prompterStubs func(*prompter.MockPrompter) + wantRepo ghrepo.Interface + wantErr error + }{ + { + name: "when there is a repo flag provided, the factory base repo func is used", + args: "--repo owner/repo", + wantRepo: ghrepo.New("owner", "repo"), + }, + { + name: "when there is no repo flag provided, and no prompting, the base func requiring no ambiguity is used", + args: "", + wantErr: shared.AmbiguousBaseRepoError{ + Remotes: remotes, + }, + }, + { + name: "when there is no repo flag provided, and can prompt, the base func resolving ambiguity is used", + args: "", + prompterStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect( + "Select a repo", + []string{"owner/fork", "owner/repo"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "owner/fork") + }, + ) + }, + wantRepo: ghrepo.New("owner", "fork"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + var pm *prompter.MockPrompter + if tt.prompterStubs != nil { + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + pm = prompter.NewMockPrompter(t) + tt.prompterStubs(pm) + } + + f := &cmdutil.Factory{ + IOStreams: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + }, + Prompter: pm, + Remotes: func() (ghContext.Remotes, error) { + return remotes, nil + }, + } + + argv, err := shlex.Split(tt.args) + assert.NoError(t, err) + + var gotOpts *ListOptions + cmd := NewCmdList(f, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + // Require to support --repo flag + cmdutil.EnableRepoOverride(cmd, f) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, err = cmd.ExecuteC() + require.NoError(t, err) + + baseRepo, err := gotOpts.BaseRepo() + if tt.wantErr != nil { + require.Equal(t, tt.wantErr, err) + return + } + require.True(t, ghrepo.IsSame(tt.wantRepo, baseRepo)) + }) + } +} + func Test_listRun(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go index 755bdda1f..3c12b4192 100644 --- a/pkg/cmd/secret/set/set.go +++ b/pkg/cmd/secret/set/set.go @@ -9,6 +9,8 @@ import ( "os" "strings" + "github.com/cli/cli/v2/internal/prompter" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" @@ -27,7 +29,7 @@ type SetOptions struct { IO *iostreams.IOStreams Config func() (gh.Config, error) BaseRepo func() (ghrepo.Interface, error) - Prompter iprompter + Prompter prompter.Prompter RandomOverride func() io.Reader @@ -43,10 +45,6 @@ type SetOptions struct { Application string } -type iprompter interface { - Password(string) (string, error) -} - func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command { opts := &SetOptions{ IO: f.IOStreams, @@ -77,6 +75,9 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command # Read secret value from an environment variable $ gh secret set MYSECRET --body "$ENV_VALUE" + # Set secret for a specific remote repository + $ gh secret set MYSECRET --repo origin/repo --body "$ENV_VALUE" + # Read secret value from a file $ gh secret set MYSECRET < myfile.txt @@ -103,8 +104,19 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override + // If the user specified a repo directly, then we're using the OverrideBaseRepoFunc set by EnableRepoOverride + // So there's no reason to use the specialised BaseRepoFunc that requires remote disambiguation. opts.BaseRepo = f.BaseRepo + if !cmd.Flags().Changed("repo") { + // If they haven't specified a repo directly, then we will wrap the BaseRepoFunc in one that errors if + // there might be multiple valid remotes. + opts.BaseRepo = shared.RequireNoAmbiguityBaseRepoFunc(opts.BaseRepo, f.Remotes) + // But if we are able to prompt, then we will wrap that up in a BaseRepoFunc that can prompt the user to + // resolve the ambiguity. + if opts.IO.CanPrompt() { + opts.BaseRepo = shared.PromptWhenAmbiguousBaseRepoFunc(opts.BaseRepo, f.IOStreams, f.Prompter) + } + } if err := cmdutil.MutuallyExclusive("specify only one of `--org`, `--env`, or `--user`", opts.OrgName != "", opts.EnvName != "", opts.UserSecrets); err != nil { return err @@ -166,6 +178,27 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command } func setRun(opts *SetOptions) error { + orgName := opts.OrgName + envName := opts.EnvName + + var host string + var baseRepo ghrepo.Interface + if orgName == "" && !opts.UserSecrets { + var err error + baseRepo, err = opts.BaseRepo() + if err != nil { + return err + } + + host = baseRepo.RepoHost() + } else { + cfg, err := opts.Config() + if err != nil { + return err + } + host, _ = cfg.Authentication().DefaultHost() + } + secrets, err := getSecretsFromOptions(opts) if err != nil { return err @@ -177,25 +210,6 @@ func setRun(opts *SetOptions) error { } client := api.NewClientFromHTTP(c) - orgName := opts.OrgName - envName := opts.EnvName - - var host string - var baseRepo ghrepo.Interface - if orgName == "" && !opts.UserSecrets { - baseRepo, err = opts.BaseRepo() - if err != nil { - return err - } - host = baseRepo.RepoHost() - } else { - cfg, err := opts.Config() - if err != nil { - return err - } - host, _ = cfg.Authentication().DefaultHost() - } - secretEntity, err := shared.GetSecretEntity(orgName, envName, opts.UserSecrets) if err != nil { return err diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go index 5c8d6f693..99c8c6cbe 100644 --- a/pkg/cmd/secret/set/set_test.go +++ b/pkg/cmd/secret/set/set_test.go @@ -10,6 +10,8 @@ import ( "testing" "github.com/MakeNowJust/heredoc" + ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" @@ -20,6 +22,7 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewCmdSet(t *testing.T) { @@ -221,6 +224,108 @@ func TestNewCmdSet(t *testing.T) { } } +func TestNewCmdSetBaseRepoFuncs(t *testing.T) { + remotes := ghContext.Remotes{ + &ghContext.Remote{ + Remote: &git.Remote{ + Name: "origin", + }, + Repo: ghrepo.New("owner", "fork"), + }, + &ghContext.Remote{ + Remote: &git.Remote{ + Name: "upstream", + }, + Repo: ghrepo.New("owner", "repo"), + }, + } + + tests := []struct { + name string + args string + prompterStubs func(*prompter.MockPrompter) + wantRepo ghrepo.Interface + wantErr error + }{ + { + name: "when there is a repo flag provided, the factory base repo func is used", + args: "SECRET_NAME --repo owner/repo", + wantRepo: ghrepo.New("owner", "repo"), + }, + { + name: "when there is no repo flag provided, and no prompting, the base func requiring no ambiguity is used", + args: "SECRET_NAME", + wantErr: shared.AmbiguousBaseRepoError{ + Remotes: remotes, + }, + }, + { + name: "when there is no repo flag provided, and can prompt, the base func resolving ambiguity is used", + args: "SECRET_NAME", + prompterStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect( + "Select a repo", + []string{"owner/fork", "owner/repo"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "owner/fork") + }, + ) + }, + wantRepo: ghrepo.New("owner", "fork"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + var pm *prompter.MockPrompter + if tt.prompterStubs != nil { + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + pm = prompter.NewMockPrompter(t) + tt.prompterStubs(pm) + } + + f := &cmdutil.Factory{ + IOStreams: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + }, + Prompter: pm, + Remotes: func() (ghContext.Remotes, error) { + return remotes, nil + }, + } + + argv, err := shlex.Split(tt.args) + assert.NoError(t, err) + + var gotOpts *SetOptions + cmd := NewCmdSet(f, func(opts *SetOptions) error { + gotOpts = opts + return nil + }) + // Require to support --repo flag + cmdutil.EnableRepoOverride(cmd, f) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, err = cmd.ExecuteC() + require.NoError(t, err) + + baseRepo, err := gotOpts.BaseRepo() + if tt.wantErr != nil { + require.Equal(t, tt.wantErr, err) + return + } + require.True(t, ghrepo.IsSame(tt.wantRepo, baseRepo)) + }) + } +} + func Test_setRun_repo(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/secret/shared/base_repo.go b/pkg/cmd/secret/shared/base_repo.go new file mode 100644 index 000000000..308117ac6 --- /dev/null +++ b/pkg/cmd/secret/shared/base_repo.go @@ -0,0 +1,71 @@ +package shared + +import ( + "errors" + "fmt" + + ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/iostreams" +) + +type AmbiguousBaseRepoError struct { + Remotes ghContext.Remotes +} + +func (e AmbiguousBaseRepoError) Error() string { + return "multiple remotes detected. please specify which repo to use by providing the -R, --repo argument" +} + +type baseRepoFn func() (ghrepo.Interface, error) +type remotesFn func() (ghContext.Remotes, error) + +func PromptWhenAmbiguousBaseRepoFunc(baseRepoFn baseRepoFn, ios *iostreams.IOStreams, prompter prompter.Prompter) baseRepoFn { + return func() (ghrepo.Interface, error) { + baseRepo, err := baseRepoFn() + if err != nil { + var ambiguousBaseRepoErr AmbiguousBaseRepoError + if !errors.As(err, &ambiguousBaseRepoErr) { + return nil, err + } + + baseRepoOptions := make([]string, len(ambiguousBaseRepoErr.Remotes)) + for i, remote := range ambiguousBaseRepoErr.Remotes { + baseRepoOptions[i] = ghrepo.FullName(remote) + } + + fmt.Fprintf(ios.Out, "%s Multiple remotes detected. Due to the sensitive nature of secrets, requiring disambiguation.\n", ios.ColorScheme().WarningIcon()) + selectedBaseRepo, err := prompter.Select("Select a repo", baseRepoOptions[0], baseRepoOptions) + if err != nil { + return nil, err + } + + selectedRepo, err := ghrepo.FromFullName(baseRepoOptions[selectedBaseRepo]) + if err != nil { + return nil, err + } + + return selectedRepo, nil + } + + return baseRepo, nil + } +} + +// RequireNoAmbiguityBaseRepoFunc returns a function to resolve the base repo, ensuring that +// there was only one option, regardless of whether the base repo had been set. +func RequireNoAmbiguityBaseRepoFunc(baseRepo baseRepoFn, remotes remotesFn) baseRepoFn { + return func() (ghrepo.Interface, error) { + remotes, err := remotes() + if err != nil { + return nil, err + } + + if remotes.Len() > 1 { + return nil, AmbiguousBaseRepoError{Remotes: remotes} + } + + return baseRepo() + } +} diff --git a/pkg/cmd/secret/shared/base_repo_test.go b/pkg/cmd/secret/shared/base_repo_test.go new file mode 100644 index 000000000..d78fad7e5 --- /dev/null +++ b/pkg/cmd/secret/shared/base_repo_test.go @@ -0,0 +1,249 @@ +package shared_test + +import ( + "errors" + "testing" + + ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/pkg/cmd/secret/shared" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/require" + + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" +) + +func TestRequireNoAmbiguityBaseRepoFunc(t *testing.T) { + t.Parallel() + + t.Run("succeeds when there is only one remote", func(t *testing.T) { + t.Parallel() + + // Given there is only one remote + baseRepoFn := shared.RequireNoAmbiguityBaseRepoFunc(baseRepoStubFn, oneRemoteStubFn) + + // When fetching the base repo + baseRepo, err := baseRepoFn() + + // It succeeds and returns the inner base repo + require.NoError(t, err) + require.True(t, ghrepo.IsSame(ghrepo.New("owner", "repo"), baseRepo)) + }) + + t.Run("returns specific error when there are multiple remotes", func(t *testing.T) { + t.Parallel() + + // Given there are multiple remotes + baseRepoFn := shared.RequireNoAmbiguityBaseRepoFunc(baseRepoStubFn, twoRemotesStubFn) + + // When fetching the base repo + _, err := baseRepoFn() + + // It succeeds and returns the inner base repo + var multipleRemotesError shared.AmbiguousBaseRepoError + require.ErrorAs(t, err, &multipleRemotesError) + require.Equal(t, ghContext.Remotes{ + { + Remote: &git.Remote{ + Name: "origin", + }, + Repo: ghrepo.New("owner", "fork"), + }, + { + Remote: &git.Remote{ + Name: "upstream", + }, + Repo: ghrepo.New("owner", "repo"), + }, + }, multipleRemotesError.Remotes) + }) + + t.Run("when the remote fetching function fails, it returns the error", func(t *testing.T) { + t.Parallel() + + // Given the remote fetching function fails + baseRepoFn := shared.RequireNoAmbiguityBaseRepoFunc(baseRepoStubFn, errRemoteStubFn) + + // When fetching the base repo + _, err := baseRepoFn() + + // It returns the error + require.Equal(t, errors.New("test remote error"), err) + }) + + t.Run("when the wrapped base repo function fails, it returns the error", func(t *testing.T) { + t.Parallel() + + // Given the wrapped base repo function fails + baseRepoFn := shared.RequireNoAmbiguityBaseRepoFunc(errBaseRepoStubFn, oneRemoteStubFn) + + // When fetching the base repo + _, err := baseRepoFn() + + // It returns the error + require.Equal(t, errors.New("test base repo error"), err) + }) +} + +func TestPromptWhenMultipleRemotesBaseRepoFunc(t *testing.T) { + t.Parallel() + + t.Run("when there is no error from wrapped base repo func, then it succeeds without prompting", func(t *testing.T) { + t.Parallel() + + ios, _, _, _ := iostreams.Test() + + // Given the base repo function succeeds + baseRepoFn := shared.PromptWhenAmbiguousBaseRepoFunc(baseRepoStubFn, ios, nil) + + // When fetching the base repo + baseRepo, err := baseRepoFn() + + // It succeeds and returns the inner base repo + require.NoError(t, err) + require.True(t, ghrepo.IsSame(ghrepo.New("owner", "repo"), baseRepo)) + }) + + t.Run("when the wrapped base repo func returns a specific error, then the prompter is used for disambiguation, with the remote ordering remaining unchanged", func(t *testing.T) { + t.Parallel() + + ios, _, stdout, _ := iostreams.Test() + + pm := prompter.NewMockPrompter(t) + pm.RegisterSelect( + "Select a repo", + []string{"owner/fork", "owner/repo"}, + func(_, def string, opts []string) (int, error) { + require.Equal(t, "owner/fork", def) + return prompter.IndexFor(opts, "owner/repo") + }, + ) + + // Given the wrapped base repo func returns a specific error + baseRepoFn := shared.PromptWhenAmbiguousBaseRepoFunc(errMultipleRemotesStubFn, ios, pm) + + // When fetching the base repo + baseRepo, err := baseRepoFn() + + // It prints an informative message + require.Equal(t, "! Multiple remotes detected. Due to the sensitive nature of secrets, requiring disambiguation.\n", stdout.String()) + + // And it uses the prompter for disambiguation + require.NoError(t, err) + require.True(t, ghrepo.IsSame(ghrepo.New("owner", "repo"), baseRepo)) + }) + + t.Run("when the prompter returns an error, then it is returned", func(t *testing.T) { + t.Parallel() + + ios, _, _, _ := iostreams.Test() + + // Given the prompter returns an error + pm := prompter.NewMockPrompter(t) + pm.RegisterSelect( + "Select a repo", + []string{"owner/fork", "owner/repo"}, + func(_, _ string, opts []string) (int, error) { + return 0, errors.New("test prompt error") + }, + ) + + // Given the wrapped base repo func returns a specific error + baseRepoFn := shared.PromptWhenAmbiguousBaseRepoFunc(errMultipleRemotesStubFn, ios, pm) + + // When fetching the base repo + _, err := baseRepoFn() + + // It returns the error + require.Equal(t, errors.New("test prompt error"), err) + }) + + t.Run("when the wrapped base repo func returns a non-specific error, then it is returned", func(t *testing.T) { + t.Parallel() + + ios, _, _, _ := iostreams.Test() + + // Given the wrapped base repo func returns a non-specific error + baseRepoFn := shared.PromptWhenAmbiguousBaseRepoFunc(errBaseRepoStubFn, ios, nil) + + // When fetching the base repo + _, err := baseRepoFn() + + // It returns the error + require.Equal(t, errors.New("test base repo error"), err) + }) +} + +func TestMultipleRemotesErrorMessage(t *testing.T) { + err := shared.AmbiguousBaseRepoError{} + require.EqualError(t, err, "multiple remotes detected. please specify which repo to use by providing the -R, --repo argument") +} + +func errMultipleRemotesStubFn() (ghrepo.Interface, error) { + remote1 := &ghContext.Remote{ + Remote: &git.Remote{ + Name: "origin", + }, + Repo: ghrepo.New("owner", "fork"), + } + + remote2 := &ghContext.Remote{ + Remote: &git.Remote{ + Name: "upstream", + }, + Repo: ghrepo.New("owner", "repo"), + } + + return nil, shared.AmbiguousBaseRepoError{ + Remotes: ghContext.Remotes{ + remote1, + remote2, + }, + } +} + +func baseRepoStubFn() (ghrepo.Interface, error) { + return ghrepo.New("owner", "repo"), nil +} + +func oneRemoteStubFn() (ghContext.Remotes, error) { + remote := &ghContext.Remote{ + Remote: &git.Remote{ + Name: "origin", + }, + Repo: ghrepo.New("owner", "repo"), + } + + return ghContext.Remotes{ + remote, + }, nil +} + +func twoRemotesStubFn() (ghContext.Remotes, error) { + remote1 := &ghContext.Remote{ + Remote: &git.Remote{ + Name: "origin", + }, + Repo: ghrepo.New("owner", "fork"), + } + + remote2 := &ghContext.Remote{ + Remote: &git.Remote{ + Name: "upstream", + }, + Repo: ghrepo.New("owner", "repo"), + } + return ghContext.Remotes{ + remote1, + remote2, + }, nil +} + +func errRemoteStubFn() (ghContext.Remotes, error) { + return nil, errors.New("test remote error") +} + +func errBaseRepoStubFn() (ghrepo.Interface, error) { + return nil, errors.New("test base repo error") +}