diff --git a/pkg/cmd/variable/get/get.go b/pkg/cmd/variable/get/get.go new file mode 100644 index 000000000..a94641134 --- /dev/null +++ b/pkg/cmd/variable/get/get.go @@ -0,0 +1,132 @@ +package get + +import ( + "errors" + "fmt" + "net/http" + + "github.com/MakeNowJust/heredoc" + "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/pkg/cmd/variable/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type GetOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + Config func() (gh.Config, error) + BaseRepo func() (ghrepo.Interface, error) + + VariableName string + OrgName string + EnvName string +} + +type getVariableResponse struct { + Value string `json:"value"` + // Other available but unused fields + // Name string `json:"name"` + // UpdatedAt time.Time `json:"updated_at"` + // Visibility shared.Visibility `json:"visibility"` + // SelectedReposURL string `json:"selected_repositories_url"` + // NumSelectedRepos int `json:"num_selected_repos"` +} + +func NewCmdGet(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command { + opts := &GetOptions{ + IO: f.IOStreams, + Config: f.Config, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "get ", + Short: "Get variables", + Long: heredoc.Doc(` + Get a variable on one of the following levels: + - repository (default): available to GitHub Actions runs or Dependabot in a repository + - environment: available to GitHub Actions runs for a deployment environment in a repository + - organization: available to GitHub Actions runs or Dependabot within an organization + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if err := cmdutil.MutuallyExclusive("specify only one of `--org` or `--env`", opts.OrgName != "", opts.EnvName != ""); err != nil { + return err + } + + opts.VariableName = args[0] + + if runF != nil { + return runF(opts) + } + + return getRun(opts) + }, + } + cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Get a variable for an organization") + cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "Get a variable for an environment") + + return cmd +} + +func getRun(opts *GetOptions) error { + c, err := opts.HttpClient() + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } + client := api.NewClientFromHTTP(c) + + orgName := opts.OrgName + envName := opts.EnvName + + variableEntity, err := shared.GetVariableEntity(orgName, envName) + if err != nil { + return err + } + + var baseRepo ghrepo.Interface + if variableEntity == shared.Repository || variableEntity == shared.Environment { + baseRepo, err = opts.BaseRepo() + if err != nil { + return err + } + } + + var path string + switch variableEntity { + case shared.Organization: + path = fmt.Sprintf("orgs/%s/actions/variables/%s", orgName, opts.VariableName) + case shared.Environment: + path = fmt.Sprintf("repos/%s/environments/%s/variables/%s", ghrepo.FullName(baseRepo), envName, opts.VariableName) + case shared.Repository: + path = fmt.Sprintf("repos/%s/actions/variables/%s", ghrepo.FullName(baseRepo), opts.VariableName) + } + + cfg, err := opts.Config() + if err != nil { + return err + } + + host, _ := cfg.Authentication().DefaultHost() + + var response getVariableResponse + if err = client.REST(host, "GET", path, nil, &response); err != nil { + var httpErr api.HTTPError + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + return fmt.Errorf("variable %s was not found", opts.VariableName) + } + + return fmt.Errorf("failed to get variable %s: %w", opts.VariableName, err) + } + + fmt.Fprintf(opts.IO.Out, "%s\n", response.Value) + + return nil +} diff --git a/pkg/cmd/variable/get/get_test.go b/pkg/cmd/variable/get/get_test.go new file mode 100644 index 000000000..60e2d8fa6 --- /dev/null +++ b/pkg/cmd/variable/get/get_test.go @@ -0,0 +1,202 @@ +package get + +import ( + "bytes" + "fmt" + "net/http" + "testing" + + "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/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdGet(t *testing.T) { + tests := []struct { + name string + cli string + wants GetOptions + wantErr error + }{ + { + name: "repo", + cli: "FOO", + wants: GetOptions{ + OrgName: "", + VariableName: "FOO", + }, + }, + { + name: "org", + cli: "-o TestOrg BAR", + wants: GetOptions{ + OrgName: "TestOrg", + VariableName: "BAR", + }, + }, + { + name: "env", + cli: "-e Development BAZ", + wants: GetOptions{ + EnvName: "Development", + VariableName: "BAZ", + }, + }, + { + name: "org and env", + cli: "-o TestOrg -e Development QUX", + wantErr: cmdutil.FlagErrorf("%s", "specify only one of `--org` or `--env`"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *GetOptions + cmd := NewCmdGet(f, func(opts *GetOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantErr != nil { + require.Equal(t, err, tt.wantErr) + return + } + require.NoError(t, err) + + require.Equal(t, tt.wants.OrgName, gotOpts.OrgName) + require.Equal(t, tt.wants.EnvName, gotOpts.EnvName) + require.Equal(t, tt.wants.VariableName, gotOpts.VariableName) + }) + } +} + +func Test_getRun(t *testing.T) { + tests := []struct { + name string + opts *GetOptions + httpStubs func(*httpmock.Registry) + wantOut string + wantErr error + }{ + { + name: "getting repo variable", + opts: &GetOptions{ + VariableName: "VARIABLE_ONE", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "repos/owner/repo/actions/variables/VARIABLE_ONE"), + httpmock.JSONResponse(getVariableResponse{ + Value: "repo_var", + })) + }, + wantOut: "repo_var\n", + }, + { + name: "getting org variable", + opts: &GetOptions{ + OrgName: "TestOrg", + VariableName: "VARIABLE_ONE", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "orgs/TestOrg/actions/variables/VARIABLE_ONE"), + httpmock.JSONResponse(getVariableResponse{ + Value: "org_var", + })) + }, + wantOut: "org_var\n", + }, + { + name: "getting env variable", + opts: &GetOptions{ + EnvName: "Development", + VariableName: "VARIABLE_ONE", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "repos/owner/repo/environments/Development/variables/VARIABLE_ONE"), + httpmock.JSONResponse(getVariableResponse{ + Value: "env_var", + })) + }, + wantOut: "env_var\n", + }, + { + name: "when the variable is not found, an error is returned", + opts: &GetOptions{ + VariableName: "VARIABLE_ONE", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "repos/owner/repo/actions/variables/VARIABLE_ONE"), + httpmock.StatusStringResponse(404, "not found"), + ) + }, + wantErr: fmt.Errorf("variable VARIABLE_ONE was not found"), + }, + { + name: "when getting any variable from API fails, the error is bubbled with context", + opts: &GetOptions{ + VariableName: "VARIABLE_ONE", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "repos/owner/repo/actions/variables/VARIABLE_ONE"), + httpmock.StatusStringResponse(400, "not found"), + ) + }, + wantErr: fmt.Errorf("failed to get variable VARIABLE_ONE: HTTP 400 (https://api.github.com/repos/owner/repo/actions/variables/VARIABLE_ONE)"), + }, + } + + for _, tt := range tests { + var runTest = func(tty bool) func(t *testing.T) { + return func(t *testing.T) { + reg := &httpmock.Registry{} + tt.httpStubs(reg) + defer reg.Verify(t) + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(tty) + + tt.opts.IO = ios + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + tt.opts.Config = func() (gh.Config, error) { + return config.NewBlankConfig(), nil + } + + err := getRun(tt.opts) + if err != nil { + require.EqualError(t, tt.wantErr, err.Error()) + return + } + + require.NoError(t, err) + require.Equal(t, tt.wantOut, stdout.String()) + } + } + + t.Run(tt.name+" tty", runTest(true)) + t.Run(tt.name+" no-tty", runTest(false)) + } +} diff --git a/pkg/cmd/variable/variable.go b/pkg/cmd/variable/variable.go index f064a81e3..15ff38744 100644 --- a/pkg/cmd/variable/variable.go +++ b/pkg/cmd/variable/variable.go @@ -3,6 +3,7 @@ package variable import ( "github.com/MakeNowJust/heredoc" cmdDelete "github.com/cli/cli/v2/pkg/cmd/variable/delete" + cmdGet "github.com/cli/cli/v2/pkg/cmd/variable/get" cmdList "github.com/cli/cli/v2/pkg/cmd/variable/list" cmdSet "github.com/cli/cli/v2/pkg/cmd/variable/set" "github.com/cli/cli/v2/pkg/cmdutil" @@ -21,6 +22,7 @@ func NewCmdVariable(f *cmdutil.Factory) *cobra.Command { cmdutil.EnableRepoOverride(cmd, f) + cmd.AddCommand(cmdGet.NewCmdGet(f, nil)) cmd.AddCommand(cmdSet.NewCmdSet(f, nil)) cmd.AddCommand(cmdList.NewCmdList(f, nil)) cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil))