From 5309a2089a2600fa4ac475f4570b9622718ee8a7 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Tue, 24 Nov 2020 12:07:54 -0800 Subject: [PATCH] implement gh secret create and gh secret list --- pkg/cmd/gist/create/create.go | 4 +- pkg/cmd/root/root.go | 2 + pkg/cmd/secret/create/create.go | 183 +++++++++++++++ pkg/cmd/secret/create/create_test.go | 334 +++++++++++++++++++++++++++ pkg/cmd/secret/create/http.go | 138 +++++++++++ pkg/cmd/secret/list/list.go | 156 +++++++++++++ pkg/cmd/secret/list/list_test.go | 227 ++++++++++++++++++ pkg/cmd/secret/secret.go | 28 +++ pkg/cmd/secret/shared/shared.go | 7 + 9 files changed, 1077 insertions(+), 2 deletions(-) create mode 100644 pkg/cmd/secret/create/create.go create mode 100644 pkg/cmd/secret/create/create_test.go create mode 100644 pkg/cmd/secret/create/http.go create mode 100644 pkg/cmd/secret/list/list.go create mode 100644 pkg/cmd/secret/list/list_test.go create mode 100644 pkg/cmd/secret/secret.go create mode 100644 pkg/cmd/secret/shared/shared.go diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go index ee2976b80..dd46b6edd 100644 --- a/pkg/cmd/gist/create/create.go +++ b/pkg/cmd/gist/create/create.go @@ -227,8 +227,8 @@ func createGist(client *http.Client, hostname, description string, public bool, } requestBody := bytes.NewReader(requestByte) - apliClient := api.NewClientFromHTTP(client) - err = apliClient.REST(hostname, "POST", path, requestBody, &result) + apiClient := api.NewClientFromHTTP(client) + err = apiClient.REST(hostname, "POST", path, requestBody, &result) if err != nil { return nil, err } diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 42c1b627f..d5e509665 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -19,6 +19,7 @@ import ( releaseCmd "github.com/cli/cli/pkg/cmd/release" repoCmd "github.com/cli/cli/pkg/cmd/repo" creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits" + secretCmd "github.com/cli/cli/pkg/cmd/secret" versionCmd "github.com/cli/cli/pkg/cmd/version" "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" @@ -74,6 +75,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil)) cmd.AddCommand(gistCmd.NewCmdGist(f)) cmd.AddCommand(completionCmd.NewCmdCompletion(f.IOStreams)) + cmd.AddCommand(secretCmd.NewCmdSecret(f)) // the `api` command should not inherit any extra HTTP headers bareHTTPCmdFactory := *f diff --git a/pkg/cmd/secret/create/create.go b/pkg/cmd/secret/create/create.go new file mode 100644 index 000000000..bdd8d46be --- /dev/null +++ b/pkg/cmd/secret/create/create.go @@ -0,0 +1,183 @@ +package create + +import ( + "encoding/base64" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/secret/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" + "golang.org/x/crypto/nacl/box" +) + +type CreateOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + RandomOverride io.Reader + + SecretName string + OrgName string + Body string + Visibility string + RepositoryNames []string +} + +func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { + opts := &CreateOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create secrets", + Long: "Locally encrypt a new secret and send it to GitHub for storage.", + Example: heredoc.Doc(` + $ cat SECRET.txt | gh secret create NEW_SECRET + $ gh secret create NEW_SECRET -b"some literal value" + $ gh secret create NEW_SECRET -b"@file.json" + $ gh secret create ORG_SECRET --org + $ gh secret create ORG_SECRET --org=anotherOrg --visibility=selected -r="repo1,repo2,repo3" + $ gh secret create ORG_SECRET --org=anotherOrg --visibility="all" +`), + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return &cmdutil.FlagError{Err: errors.New("must pass single secret name")} + } + if !cmd.Flags().Changed("body") && opts.IO.IsStdinTTY() { + return &cmdutil.FlagError{Err: errors.New("no --body specified but nothing on STIDN")} + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + opts.SecretName = args[0] + + if cmd.Flags().Changed("visibility") { + if opts.OrgName == "" { + return &cmdutil.FlagError{Err: errors.New( + "--visibility not supported for repository secrets; did you mean to pass --org?")} + } + + if opts.Visibility != shared.VisAll && opts.Visibility != shared.VisPrivate && opts.Visibility != shared.VisSelected { + return &cmdutil.FlagError{Err: errors.New( + "--visibility must be one of `all`, `private`, or `selected`")} + } + } + + if cmd.Flags().Changed("repos") && opts.Visibility != shared.VisSelected { + return &cmdutil.FlagError{Err: errors.New( + "--repos only supported when --visibility='selected'")} + } + + if opts.Visibility == shared.VisSelected && len(opts.RepositoryNames) == 0 { + return &cmdutil.FlagError{Err: errors.New( + "--repos flag required when --visibility='selected'")} + } + + if runF != nil { + return runF(opts) + } + + return createRun(opts) + }, + } + cmd.Flags().StringVar(&opts.OrgName, "org", "", "List secrets for an organization") + cmd.Flags().Lookup("org").NoOptDefVal = "@owner" + cmd.Flags().StringVarP(&opts.Visibility, "visibility", "v", "private", "Set visibility for an organization secret: `all`, `private`, or `selected`") + cmd.Flags().StringSliceVarP(&opts.RepositoryNames, "repos", "r", []string{}, "List of repository names for `selected` visibility") + cmd.Flags().StringVarP(&opts.Body, "body", "b", "-", "Provide either a literal string or a file path; prepend file paths with an @. Reads from STDIN if not provided.") + + return cmd +} + +func createRun(opts *CreateOptions) error { + body, err := getBody(opts) + if err != nil { + return fmt.Errorf("did not understand secret body: %w", err) + } + + c, err := opts.HttpClient() + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } + client := api.NewClientFromHTTP(c) + + var baseRepo ghrepo.Interface + if opts.OrgName == "" || opts.OrgName == "@owner" { + baseRepo, err = opts.BaseRepo() + if err != nil { + return fmt.Errorf("could not determine base repo: %w", err) + } + } + + host := ghinstance.OverridableDefault() + if opts.OrgName == "@owner" { + opts.OrgName = baseRepo.RepoOwner() + host = baseRepo.RepoHost() + } + + var pk *PubKey + if opts.OrgName != "" { + pk, err = getOrgPublicKey(client, host, opts.OrgName) + } else { + pk, err = getRepoPubKey(client, baseRepo) + } + if err != nil { + return fmt.Errorf("failed to fetch public key: %w", err) + } + + eBody, err := box.SealAnonymous(nil, body, &pk.Raw, opts.RandomOverride) + if err != nil { + return fmt.Errorf("failed to encrypt body: %w", err) + } + + encoded := base64.StdEncoding.EncodeToString(eBody) + + if opts.OrgName != "" { + err = putOrgSecret(client, pk, host, *opts, encoded) + } else { + err = putRepoSecret(client, pk, baseRepo, opts.SecretName, encoded) + } + if err != nil { + return fmt.Errorf("failed to create secret: %w", err) + } + + return nil +} + +func getBody(opts *CreateOptions) (body []byte, err error) { + if opts.Body == "-" { + body, err = ioutil.ReadAll(opts.IO.In) + if err != nil { + return nil, fmt.Errorf("failed to read from STDIN: %w", err) + } + + return + } + + if strings.HasPrefix(opts.Body, "@") { + body, err = opts.IO.ReadUserFile(opts.Body[1:]) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", opts.Body[1:], err) + } + + return + } + + return []byte(opts.Body), nil +} diff --git a/pkg/cmd/secret/create/create_test.go b/pkg/cmd/secret/create/create_test.go new file mode 100644 index 000000000..365fd84a5 --- /dev/null +++ b/pkg/cmd/secret/create/create_test.go @@ -0,0 +1,334 @@ +package create + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "testing" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/secret/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdCreate(t *testing.T) { + tests := []struct { + name string + cli string + wants CreateOptions + stdinTTY bool + wantsErr bool + }{ + { + name: "invalid visibility", + cli: "cool_secret --org -v'mistyVeil'", + wantsErr: true, + }, + { + name: "invalid visibility", + cli: "cool_secret --org -v'selected'", + wantsErr: true, + }, + { + name: "no name", + cli: "", + wantsErr: true, + }, + { + name: "multiple names", + cli: "cool_secret good_secret", + wantsErr: true, + }, + { + name: "no body, stdin is terminal", + cli: "cool_secret", + stdinTTY: true, + wantsErr: true, + }, + { + name: "visibility without org", + cli: "cool_secret -vall", + wantsErr: true, + }, + { + name: "explicit org with selected repo", + cli: "--org=coolOrg -vselected -rcoolRepo cool_secret", + wants: CreateOptions{ + SecretName: "cool_secret", + Visibility: shared.VisSelected, + RepositoryNames: []string{"coolRepo"}, + Body: "-", + OrgName: "coolOrg", + }, + }, + { + name: "explicit org with selected repos", + cli: `--org=coolOrg -vselected -r="coolRepo,radRepo,goodRepo" cool_secret`, + wants: CreateOptions{ + SecretName: "cool_secret", + Visibility: shared.VisSelected, + RepositoryNames: []string{"coolRepo", "goodRepo", "radRepo"}, + Body: "-", + OrgName: "coolOrg", + }, + }, + { + name: "repo", + cli: `cool_secret -b"a secret"`, + wants: CreateOptions{ + SecretName: "cool_secret", + Visibility: shared.VisPrivate, + Body: "a secret", + OrgName: "", + }, + }, + { + name: "implicit org", + cli: `cool_secret --org -b"@cool.json"`, + wants: CreateOptions{ + SecretName: "cool_secret", + Visibility: shared.VisPrivate, + Body: "@cool.json", + OrgName: "@owner", + }, + }, + { + name: "vis all", + cli: `cool_secret --org -b"@cool.json" -vall`, + wants: CreateOptions{ + SecretName: "cool_secret", + Visibility: shared.VisAll, + Body: "@cool.json", + OrgName: "@owner", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + } + + io.SetStdinTTY(tt.stdinTTY) + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *CreateOptions + cmd := NewCmdCreate(f, func(opts *CreateOptions) 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.wantsErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.SecretName, gotOpts.SecretName) + assert.Equal(t, tt.wants.Body, gotOpts.Body) + assert.Equal(t, tt.wants.Visibility, gotOpts.Visibility) + assert.Equal(t, tt.wants.OrgName, gotOpts.OrgName) + assert.ElementsMatch(t, tt.wants.RepositoryNames, gotOpts.RepositoryNames) + }) + } +} + +func Test_createRun_repo(t *testing.T) { + reg := &httpmock.Registry{} + + reg.Register(httpmock.REST("GET", "repos/owner/repo/actions/secrets/public-key"), + httpmock.JSONResponse(PubKey{ID: "123", Key: "CDjXqf7AJBXWhMczcy+Fs7JlACEptgceysutztHaFQI="})) + + reg.Register(httpmock.REST("PUT", "repos/owner/repo/actions/secrets/cool_secret"), httpmock.StatusStringResponse(201, `{}`)) + + mockClient := func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + io, _, _, _ := iostreams.Test() + + opts := &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + }, + HttpClient: mockClient, + IO: io, + SecretName: "cool_secret", + Body: "a secret", + // Cribbed from https://github.com/golang/crypto/commit/becbf705a91575484002d598f87d74f0002801e7 + RandomOverride: bytes.NewReader([]byte{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5}), + } + + err := createRun(opts) + assert.NoError(t, err) + + reg.Verify(t) + + data, err := ioutil.ReadAll(reg.Requests[1].Body) + assert.NoError(t, err) + var payload SecretPayload + err = json.Unmarshal(data, &payload) + assert.NoError(t, err) + assert.Equal(t, payload.KeyID, "123") + assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=") +} + +func Test_createRun_org(t *testing.T) { + tests := []struct { + name string + opts *CreateOptions + wantVisibility string + wantRepositories []int + }{ + { + name: "explicit org name", + opts: &CreateOptions{ + OrgName: "UmbrellaCorporation", + Visibility: shared.VisAll, + }, + }, + { + name: "implicit org name", + opts: &CreateOptions{ + OrgName: "@owner", + Visibility: shared.VisPrivate, + }, + }, + { + name: "selected visibility", + opts: &CreateOptions{ + OrgName: "UmbrellaCorporation", + Visibility: shared.VisSelected, + RepositoryNames: []string{"birkin", "wesker"}, + }, + wantRepositories: []int{1, 2}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + + orgName := tt.opts.OrgName + if orgName == "@owner" { + orgName = "NeoUmbrella" + } + + reg.Register(httpmock.REST("GET", + fmt.Sprintf("orgs/%s/actions/secrets/public-key", orgName)), + httpmock.JSONResponse(PubKey{ID: "123", Key: "CDjXqf7AJBXWhMczcy+Fs7JlACEptgceysutztHaFQI="})) + + reg.Register(httpmock.REST("PUT", + fmt.Sprintf("orgs/%s/actions/secrets/cool_secret", orgName)), + httpmock.StatusStringResponse(201, `{}`)) + + if len(tt.opts.RepositoryNames) > 0 { + reg.Register(httpmock.GraphQL(`query MapRepositoryNames\b`), + httpmock.StringResponse(`{"data":{"birkin":{"databaseId":1},"wesker":{"databaseId":2}}}`)) + } + + io, _, _, _ := iostreams.Test() + + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("NeoUmbrella/repo") + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + tt.opts.IO = io + tt.opts.SecretName = "cool_secret" + tt.opts.Body = "a secret" + // Cribbed from https://github.com/golang/crypto/commit/becbf705a91575484002d598f87d74f0002801e7 + tt.opts.RandomOverride = bytes.NewReader([]byte{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5}) + + err := createRun(tt.opts) + assert.NoError(t, err) + + reg.Verify(t) + + data, err := ioutil.ReadAll(reg.Requests[len(reg.Requests)-1].Body) + assert.NoError(t, err) + var payload SecretPayload + err = json.Unmarshal(data, &payload) + assert.NoError(t, err) + assert.Equal(t, payload.KeyID, "123") + assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=") + assert.Equal(t, payload.Visibility, tt.opts.Visibility) + assert.ElementsMatch(t, payload.Repositories, tt.wantRepositories) + }) + } +} + +func Test_getBody(t *testing.T) { + tests := []struct { + name string + bodyArg string + want string + stdin string + fromFile bool + }{ + { + name: "literal value", + bodyArg: "a secret", + want: "a secret", + }, + { + name: "from stdin", + bodyArg: "-", + want: "a secret", + stdin: "a secret", + }, + { + name: "from file", + fromFile: true, + want: "a secret from a file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, stdin, _, _ := iostreams.Test() + + io.SetStdinTTY(false) + + _, err := stdin.WriteString(tt.stdin) + assert.NoError(t, err) + + if tt.fromFile { + dir := os.TempDir() + tmpfile, err := ioutil.TempFile(dir, "testfile*") + assert.NoError(t, err) + _, err = tmpfile.WriteString(tt.want) + assert.NoError(t, err) + tt.bodyArg = fmt.Sprintf("@%s", tmpfile.Name()) + } + + body, err := getBody(&CreateOptions{ + Body: tt.bodyArg, + IO: io, + }) + assert.NoError(t, err) + + assert.Equal(t, string(body), tt.want) + + }) + + } + +} diff --git a/pkg/cmd/secret/create/http.go b/pkg/cmd/secret/create/http.go new file mode 100644 index 000000000..51d270b83 --- /dev/null +++ b/pkg/cmd/secret/create/http.go @@ -0,0 +1,138 @@ +package create + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/secret/shared" +) + +type SecretPayload struct { + EncryptedValue string `json:"encrypted_value"` + Visibility string `json:"visibility,omitempty"` + Repositories []int `json:"selected_repository_ids,omitempty"` + KeyID string `json:"key_id"` +} + +type PubKey struct { + Raw [32]byte + ID string `json:"key_id"` + Key string +} + +func getPubKey(client *api.Client, host, path string) (*PubKey, error) { + pk := PubKey{} + err := client.REST(host, "GET", path, nil, &pk) + if err != nil { + return nil, err + } + + if pk.Key == "" { + return nil, fmt.Errorf("failed to find public key at %s/%s", host, path) + } + + decoded, err := base64.StdEncoding.DecodeString(pk.Key) + if err != nil { + return nil, fmt.Errorf("failed to decode public key: %w", err) + } + + copy(pk.Raw[:], decoded[0:32]) + return &pk, nil +} + +func getOrgPublicKey(client *api.Client, host, orgName string) (*PubKey, error) { + return getPubKey(client, host, fmt.Sprintf("orgs/%s/actions/secrets/public-key", orgName)) +} + +func getRepoPubKey(client *api.Client, repo ghrepo.Interface) (*PubKey, error) { + return getPubKey(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/secrets/public-key", + ghrepo.FullName(repo))) +} + +func putSecret(client *api.Client, host, path string, payload SecretPayload) error { + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to serialize: %w", err) + } + requestBody := bytes.NewReader(payloadBytes) + + return client.REST(host, "PUT", path, requestBody, nil) +} + +func putOrgSecret(client *api.Client, pk *PubKey, host string, opts CreateOptions, eValue string) error { + secretName := opts.SecretName + orgName := opts.OrgName + visibility := opts.Visibility + + var repositoryIDs []int + var err error + if orgName != "" && visibility == shared.VisSelected { + repositoryIDs, err = mapRepoNameToID(client, host, orgName, opts.RepositoryNames) + if err != nil { + return fmt.Errorf("failed to look up IDs for repositories %v: %w", opts.RepositoryNames, err) + } + } + + payload := SecretPayload{ + EncryptedValue: eValue, + KeyID: pk.ID, + Repositories: repositoryIDs, + Visibility: visibility, + } + path := fmt.Sprintf("orgs/%s/actions/secrets/%s", orgName, secretName) + + return putSecret(client, host, path, payload) +} + +func putRepoSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, secretName, eValue string) error { + payload := SecretPayload{ + EncryptedValue: eValue, + KeyID: pk.ID, + } + path := fmt.Sprintf("repos/%s/actions/secrets/%s", ghrepo.FullName(repo), secretName) + return putSecret(client, repo.RepoHost(), path, payload) +} + +func mapRepoNameToID(client *api.Client, host, orgName string, repositoryNames []string) ([]int, error) { + queries := make([]string, 0, len(repositoryNames)) + for _, repoName := range repositoryNames { + queries = append(queries, fmt.Sprintf(` + %s: repository(owner: %q, name :%q) { + databaseId + } + `, repoName, orgName, repoName)) + } + + query := fmt.Sprintf(`query MapRepositoryNames { %s }`, strings.Join(queries, "")) + + graphqlResult := make(map[string]*struct { + DatabaseID int `json:"databaseId"` + }) + + err := client.GraphQL(host, query, nil, &graphqlResult) + + gqlErr, isGqlErr := err.(*api.GraphQLErrorResponse) + if isGqlErr { + for _, ge := range gqlErr.Errors { + if ge.Type == "NOT_FOUND" { + return nil, fmt.Errorf("could not find %s/%s", orgName, ge.Path[0]) + } + } + } + if err != nil { + return nil, fmt.Errorf("failed to look up repositories: %w", err) + } + + result := make([]int, 0, len(repositoryNames)) + + for _, repoName := range repositoryNames { + result = append(result, graphqlResult[repoName].DatabaseID) + } + + return result, nil +} diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go new file mode 100644 index 000000000..b5eb85dc5 --- /dev/null +++ b/pkg/cmd/secret/list/list.go @@ -0,0 +1,156 @@ +package list + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/secret/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ListOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + OrgName string +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List secrets", + Long: "List secrets for a repository or organization", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if runF != nil { + return runF(opts) + } + + return listRun(opts) + }, + } + + cmd.Flags().StringVar(&opts.OrgName, "org", "", "List secrets for an organization") + cmd.Flags().Lookup("org").NoOptDefVal = "@owner" + + return cmd +} + +func listRun(opts *ListOptions) error { + c, err := opts.HttpClient() + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } + client := api.NewClientFromHTTP(c) + + var baseRepo ghrepo.Interface + if opts.OrgName == "" || opts.OrgName == "@owner" { + baseRepo, err = opts.BaseRepo() + if err != nil { + return fmt.Errorf("could not determine base repo: %w", err) + } + } + + orgName := opts.OrgName + host := ghinstance.OverridableDefault() + if orgName == "@owner" { + orgName = baseRepo.RepoOwner() + host = baseRepo.RepoHost() + } + + var secrets []Secret + if orgName != "" { + secrets, err = getOrgSecrets(client, host, orgName) + } else { + secrets, err = getRepoSecrets(client, baseRepo) + } + + if err != nil { + return fmt.Errorf("failed to get secrets: %w", err) + } + + tp := utils.NewTablePrinter(opts.IO) + for _, secret := range secrets { + tp.AddField(secret.Name, nil, nil) + updatedAt := secret.UpdatedAt.Format("2006-01-02") + if opts.IO.IsStdoutTTY() { + updatedAt = fmt.Sprintf("Updated %s", updatedAt) + } + tp.AddField(updatedAt, nil, nil) + if secret.Visibility != "" { + if opts.IO.IsStdoutTTY() { + tp.AddField(fmtVisibility(secret), nil, nil) + } else { + tp.AddField(strings.ToUpper(secret.Visibility), nil, nil) + } + } + tp.EndRow() + } + + err = tp.Render() + if err != nil { + return err + } + + return nil +} + +type Secret struct { + Name string + UpdatedAt time.Time `json:"updated_at"` + Visibility string +} + +func fmtVisibility(s Secret) string { + switch s.Visibility { + case shared.VisAll: + return "Visible to all repositories" + case shared.VisPrivate: + return "Visible to private repositories" + case shared.VisSelected: + // TODO print how many? print which ones? + return "Visible to selected repositories" + } + return "" +} + +func getOrgSecrets(client *api.Client, host, orgName string) ([]Secret, error) { + return getSecrets(client, host, fmt.Sprintf("orgs/%s/actions/secrets", orgName)) +} + +func getRepoSecrets(client *api.Client, repo ghrepo.Interface) ([]Secret, error) { + return getSecrets(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/secrets", + ghrepo.FullName(repo))) +} + +type secretsPayload struct { + Secrets []Secret +} + +func getSecrets(client *api.Client, host, path string) ([]Secret, error) { + result := secretsPayload{} + + err := client.REST(host, "GET", path, nil, &result) + if err != nil { + return nil, err + } + + return result.Secrets, nil +} diff --git a/pkg/cmd/secret/list/list_test.go b/pkg/cmd/secret/list/list_test.go new file mode 100644 index 000000000..8a7077de1 --- /dev/null +++ b/pkg/cmd/secret/list/list_test.go @@ -0,0 +1,227 @@ +package list + +import ( + "bytes" + "fmt" + "net/http" + "testing" + "time" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/secret/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func Test_NewCmdList(t *testing.T) { + tests := []struct { + name string + cli string + wants ListOptions + }{ + { + name: "repo", + cli: "", + wants: ListOptions{ + OrgName: "", + }, + }, + { + name: "implicit org", + cli: "--org", + wants: ListOptions{ + OrgName: "@owner", + }, + }, + { + name: "explicit org", + cli: "--org=UmbrellaCorporation", + wants: ListOptions{ + OrgName: "UmbrellaCorporation", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *ListOptions + cmd := NewCmdList(f, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + assert.NoError(t, err) + + assert.Equal(t, tt.wants.OrgName, gotOpts.OrgName) + + }) + } +} + +// TODO run tests + +func Test_listRun(t *testing.T) { + tests := []struct { + name string + tty bool + opts *ListOptions + wantOut []string + }{ + { + name: "repo tty", + tty: true, + opts: &ListOptions{}, + wantOut: []string{ + "SECRET_ONE.*Updated 1988-10-11", + "SECRET_TWO.*Updated 2020-12-04", + "SECRET_THREE.*Updated 1975-11-30", + }, + }, + { + name: "repo not tty", + tty: false, + opts: &ListOptions{}, + wantOut: []string{ + "SECRET_ONE\t1988-10-11", + "SECRET_TWO\t2020-12-04", + "SECRET_THREE\t1975-11-30", + }, + }, + { + name: "explicit org tty", + tty: true, + opts: &ListOptions{ + OrgName: "UmbrellaCorporation", + }, + wantOut: []string{ + "SECRET_ONE.*Updated 1988-10-11.*Visible to all repositories", + "SECRET_TWO.*Updated 2020-12-04.*Visible to private repositories", + "SECRET_THREE.*Updated 1975-11-30.*Visible to selected repositories", + }, + }, + { + name: "explicit org not tty", + tty: false, + opts: &ListOptions{ + OrgName: "UmbrellaCorporation", + }, + wantOut: []string{ + "SECRET_ONE\t1988-10-11\tALL", + "SECRET_TWO\t2020-12-04\tPRIVATE", + "SECRET_THREE\t1975-11-30\tSELECTED", + }, + }, + { + name: "implicit org not tty", + tty: false, + opts: &ListOptions{ + OrgName: "@owner", + }, + wantOut: []string{ + "SECRET_ONE\t1988-10-11\tALL", + "SECRET_TWO\t2020-12-04\tPRIVATE", + "SECRET_THREE\t1975-11-30\tSELECTED", + }, + }, + { + name: "implicit org not tty", + tty: true, + opts: &ListOptions{ + OrgName: "@owner", + }, + wantOut: []string{ + "SECRET_ONE.*Updated 1988-10-11.*Visible to all repositories", + "SECRET_TWO.*Updated 2020-12-04.*Visible to private repositories", + "SECRET_THREE.*Updated 1975-11-30.*Visible to selected repositories", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + + t0, _ := time.Parse("2006-01-02", "1988-10-11") + t1, _ := time.Parse("2006-01-02", "2020-12-04") + t2, _ := time.Parse("2006-01-02", "1975-11-30") + path := "repos/owner/repo/actions/secrets" + payload := secretsPayload{} + payload.Secrets = []Secret{ + { + Name: "SECRET_ONE", + UpdatedAt: t0, + }, + { + Name: "SECRET_TWO", + UpdatedAt: t1, + }, + { + Name: "SECRET_THREE", + UpdatedAt: t2, + }, + } + if tt.opts.OrgName != "" { + payload.Secrets = []Secret{ + { + Name: "SECRET_ONE", + UpdatedAt: t0, + Visibility: shared.VisAll, + }, + { + Name: "SECRET_TWO", + UpdatedAt: t1, + Visibility: shared.VisPrivate, + }, + { + Name: "SECRET_THREE", + UpdatedAt: t2, + Visibility: shared.VisSelected, + }, + } + if tt.opts.OrgName == "@owner" { + path = "orgs/owner/actions/secrets" + } else { + path = fmt.Sprintf("orgs/%s/actions/secrets", tt.opts.OrgName) + } + } + + reg.Register(httpmock.REST("GET", path), httpmock.JSONResponse(payload)) + + io, _, stdout, _ := iostreams.Test() + + io.SetStdoutTTY(tt.tty) + + tt.opts.IO = io + 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 + } + + err := listRun(tt.opts) + assert.NoError(t, err) + + reg.Verify(t) + + test.ExpectLines(t, stdout.String(), tt.wantOut...) + }) + } +} diff --git a/pkg/cmd/secret/secret.go b/pkg/cmd/secret/secret.go new file mode 100644 index 000000000..4932b6407 --- /dev/null +++ b/pkg/cmd/secret/secret.go @@ -0,0 +1,28 @@ +package secret + +import ( + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/pkg/cmdutil" + "github.com/spf13/cobra" + + cmdCreate "github.com/cli/cli/pkg/cmd/secret/create" + cmdList "github.com/cli/cli/pkg/cmd/secret/list" +) + +func NewCmdSecret(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "secret ", + Short: "Manage GitHub secrets", + Long: heredoc.Doc(` + Secrets can be set at the repository or organization level for use in GitHub Actions. + Run "gh help secret add" to learn how to get started. +`), + } + + cmdutil.EnableRepoOverride(cmd, f) + + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil)) + + return cmd +} diff --git a/pkg/cmd/secret/shared/shared.go b/pkg/cmd/secret/shared/shared.go new file mode 100644 index 000000000..7e18b1298 --- /dev/null +++ b/pkg/cmd/secret/shared/shared.go @@ -0,0 +1,7 @@ +package shared + +const ( + VisAll = "all" + VisPrivate = "private" + VisSelected = "selected" +)