From 577f29ae0d3ae557332a7869867b32609330f8ed Mon Sep 17 00:00:00 2001 From: Josh Gross Date: Thu, 18 Nov 2021 06:53:35 -0500 Subject: [PATCH] Support listing and removing user Codespaces secrets (#4714) --- pkg/cmd/secret/list/list.go | 80 +++++++++++++++++++++------- pkg/cmd/secret/list/list_test.go | 80 ++++++++++++++++++++++++++-- pkg/cmd/secret/remove/remove.go | 29 +++++++--- pkg/cmd/secret/remove/remove_test.go | 35 ++++++++++++ 4 files changed, 191 insertions(+), 33 deletions(-) diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go index f138628df..6d1866652 100644 --- a/pkg/cmd/secret/list/list.go +++ b/pkg/cmd/secret/list/list.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghinstance" @@ -25,8 +26,9 @@ type ListOptions struct { Config func() (config.Config, error) BaseRepo func() (ghrepo.Interface, error) - OrgName string - EnvName string + OrgName string + EnvName string + UserSecrets bool } func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { @@ -39,13 +41,19 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd := &cobra.Command{ Use: "list", Short: "List secrets", - Long: "List secrets for a repository, environment, or organization", - Args: cobra.NoArgs, + Long: heredoc.Doc(` + List secrets on one of the following levels: + - repository (default): available to Actions runs in a repository + - environment: available to Actions runs for a deployment environment in a repository + - organization: available to Actions runs within an organization + - user: available to Codespaces for your user + `), + Args: cobra.NoArgs, 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 { + if err := cmdutil.MutuallyExclusive("specify only one of `--org`, `--env`, or `--user`", opts.OrgName != "", opts.EnvName != "", opts.UserSecrets); err != nil { return err } @@ -59,6 +67,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization") cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "List secrets for an environment") + cmd.Flags().BoolVarP(&opts.UserSecrets, "user", "u", false, "List a secret for your user") return cmd } @@ -73,7 +82,7 @@ func listRun(opts *ListOptions) error { envName := opts.EnvName var baseRepo ghrepo.Interface - if orgName == "" { + if orgName == "" && !opts.UserSecrets { baseRepo, err = opts.BaseRepo() if err != nil { return fmt.Errorf("could not determine base repo: %w", err) @@ -81,7 +90,8 @@ func listRun(opts *ListOptions) error { } var secrets []*Secret - if orgName == "" { + showSelectedRepoInfo := opts.IO.IsStdoutTTY() + if orgName == "" && !opts.UserSecrets { if envName == "" { secrets, err = getRepoSecrets(client, baseRepo) } else { @@ -101,7 +111,11 @@ func listRun(opts *ListOptions) error { return err } - secrets, err = getOrgSecrets(client, host, orgName) + if opts.UserSecrets { + secrets, err = getUserSecrets(client, host, showSelectedRepoInfo) + } else { + secrets, err = getOrgSecrets(client, host, orgName, showSelectedRepoInfo) + } } if err != nil { @@ -117,7 +131,7 @@ func listRun(opts *ListOptions) error { } tp.AddField(updatedAt, nil, nil) if secret.Visibility != "" { - if opts.IO.IsStdoutTTY() { + if showSelectedRepoInfo { tp.AddField(fmtVisibility(*secret), nil, nil) } else { tp.AddField(strings.ToUpper(string(secret.Visibility)), nil, nil) @@ -158,25 +172,32 @@ func fmtVisibility(s Secret) string { return "" } -func getOrgSecrets(client httpClient, host, orgName string) ([]*Secret, error) { +func getOrgSecrets(client httpClient, host, orgName string, showSelectedRepoInfo bool) ([]*Secret, error) { secrets, err := getSecrets(client, host, fmt.Sprintf("orgs/%s/actions/secrets", orgName)) if err != nil { return nil, err } - type responseData struct { - TotalCount int `json:"total_count"` + if showSelectedRepoInfo { + err = getSelectedRepositoryInformation(client, secrets) + if err != nil { + return nil, err + } + } + return secrets, nil +} + +func getUserSecrets(client httpClient, host string, showSelectedRepoInfo bool) ([]*Secret, error) { + secrets, err := getSecrets(client, host, "user/codespaces/secrets") + if err != nil { + return nil, err } - for _, secret := range secrets { - if secret.SelectedReposURL == "" { - continue + if showSelectedRepoInfo { + err = getSelectedRepositoryInformation(client, secrets) + if err != nil { + return nil, err } - var result responseData - if _, err := apiGet(client, secret.SelectedReposURL, &result); err != nil { - return nil, fmt.Errorf("failed determining selected repositories for %s: %w", secret.Name, err) - } - secret.NumSelectedRepos = result.TotalCount } return secrets, nil @@ -256,3 +277,22 @@ func findNextPage(link string) string { } return "" } + +func getSelectedRepositoryInformation(client httpClient, secrets []*Secret) error { + type responseData struct { + TotalCount int `json:"total_count"` + } + + for _, secret := range secrets { + if secret.SelectedReposURL == "" { + continue + } + var result responseData + if _, err := apiGet(client, secret.SelectedReposURL, &result); err != nil { + return fmt.Errorf("failed determining selected repositories for %s: %w", secret.Name, err) + } + secret.NumSelectedRepos = result.TotalCount + } + + return nil +} diff --git a/pkg/cmd/secret/list/list_test.go b/pkg/cmd/secret/list/list_test.go index 277e4328c..bd14fafe9 100644 --- a/pkg/cmd/secret/list/list_test.go +++ b/pkg/cmd/secret/list/list_test.go @@ -47,6 +47,13 @@ func Test_NewCmdList(t *testing.T) { EnvName: "Development", }, }, + { + name: "user", + cli: "-u", + wants: ListOptions{ + UserSecrets: true, + }, + }, } for _, tt := range tests { @@ -153,6 +160,30 @@ func Test_listRun(t *testing.T) { "SECRET_THREE\t1975-11-30", }, }, + { + name: "user tty", + tty: true, + opts: &ListOptions{ + UserSecrets: true, + }, + wantOut: []string{ + "SECRET_ONE.*Updated 1988-10-11.*Visible to 1 selected repository", + "SECRET_TWO.*Updated 2020-12-04.*Visible to 2 selected repositories", + "SECRET_THREE.*Updated 1975-11-30.*Visible to 3 selected repositories", + }, + }, + { + name: "user not tty", + tty: false, + opts: &ListOptions{ + UserSecrets: true, + }, + wantOut: []string{ + "SECRET_ONE\t1988-10-11\t", + "SECRET_TWO\t2020-12-04\t", + "SECRET_THREE\t1975-11-30\t", + }, + }, } for _, tt := range tests { @@ -203,11 +234,50 @@ func Test_listRun(t *testing.T) { } path = fmt.Sprintf("orgs/%s/actions/secrets", tt.opts.OrgName) - reg.Register( - httpmock.REST("GET", fmt.Sprintf("orgs/%s/actions/secrets/SECRET_THREE/repositories", tt.opts.OrgName)), - httpmock.JSONResponse(struct { - TotalCount int `json:"total_count"` - }{2})) + if tt.tty { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("orgs/%s/actions/secrets/SECRET_THREE/repositories", tt.opts.OrgName)), + httpmock.JSONResponse(struct { + TotalCount int `json:"total_count"` + }{2})) + } + } + + if tt.opts.UserSecrets { + payload.Secrets = []*Secret{ + { + Name: "SECRET_ONE", + UpdatedAt: t0, + Visibility: shared.Selected, + SelectedReposURL: "https://api.github.com/user/codespaces/secrets/SECRET_ONE/repositories", + }, + { + Name: "SECRET_TWO", + UpdatedAt: t1, + Visibility: shared.Selected, + SelectedReposURL: "https://api.github.com/user/codespaces/secrets/SECRET_TWO/repositories", + }, + { + Name: "SECRET_THREE", + UpdatedAt: t2, + Visibility: shared.Selected, + SelectedReposURL: "https://api.github.com/user/codespaces/secrets/SECRET_THREE/repositories", + }, + } + + path = "user/codespaces/secrets" + if tt.tty { + for i, secret := range payload.Secrets { + hostLen := len("https://api.github.com/") + path := secret.SelectedReposURL[hostLen:len(secret.SelectedReposURL)] + repositoryCount := i + 1 + reg.Register( + httpmock.REST("GET", path), + httpmock.JSONResponse(struct { + TotalCount int `json:"total_count"` + }{repositoryCount})) + } + } } reg.Register(httpmock.REST("GET", path), httpmock.JSONResponse(payload)) diff --git a/pkg/cmd/secret/remove/remove.go b/pkg/cmd/secret/remove/remove.go index 8e1c4570b..4ecf0b6cc 100644 --- a/pkg/cmd/secret/remove/remove.go +++ b/pkg/cmd/secret/remove/remove.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" @@ -18,9 +19,10 @@ type RemoveOptions struct { Config func() (config.Config, error) BaseRepo func() (ghrepo.Interface, error) - SecretName string - OrgName string - EnvName string + SecretName string + OrgName string + EnvName string + UserSecrets bool } func NewCmdRemove(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Command { @@ -33,13 +35,19 @@ func NewCmdRemove(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Co cmd := &cobra.Command{ Use: "remove ", Short: "Remove secrets", - Long: "Remove a secret for a repository, environment, or organization", - Args: cobra.ExactArgs(1), + Long: heredoc.Doc(` + Remove a secret on one of the following levels: + - repository (default): available to Actions runs in a repository + - environment: available to Actions runs for a deployment environment in a repository + - organization: available to Actions runs within an organization + - user: available to Codespaces for your user + `), + 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 { + if err := cmdutil.MutuallyExclusive("specify only one of `--org`, `--env`, or `--user`", opts.OrgName != "", opts.EnvName != "", opts.UserSecrets); err != nil { return err } @@ -54,6 +62,7 @@ func NewCmdRemove(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Co } cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Remove a secret for an organization") cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "Remove a secret for an environment") + cmd.Flags().BoolVarP(&opts.UserSecrets, "user", "u", false, "Remove a secret for your user") return cmd } @@ -69,7 +78,7 @@ func removeRun(opts *RemoveOptions) error { envName := opts.EnvName var baseRepo ghrepo.Interface - if orgName == "" { + if orgName == "" && !opts.UserSecrets { baseRepo, err = opts.BaseRepo() if err != nil { return fmt.Errorf("could not determine base repo: %w", err) @@ -81,6 +90,8 @@ func removeRun(opts *RemoveOptions) error { path = fmt.Sprintf("orgs/%s/actions/secrets/%s", orgName, opts.SecretName) } else if envName != "" { path = fmt.Sprintf("repos/%s/environments/%s/secrets/%s", ghrepo.FullName(baseRepo), envName, opts.SecretName) + } else if opts.UserSecrets { + path = fmt.Sprintf("user/codespaces/secrets/%s", opts.SecretName) } else { path = fmt.Sprintf("repos/%s/actions/secrets/%s", ghrepo.FullName(baseRepo), opts.SecretName) } @@ -102,7 +113,9 @@ func removeRun(opts *RemoveOptions) error { if opts.IO.IsStdoutTTY() { target := orgName - if orgName == "" { + if opts.UserSecrets { + target = "your user" + } else if orgName == "" { target = ghrepo.FullName(baseRepo) } cs := opts.IO.ColorScheme() diff --git a/pkg/cmd/secret/remove/remove_test.go b/pkg/cmd/secret/remove/remove_test.go index 565a8f742..2abb38c64 100644 --- a/pkg/cmd/secret/remove/remove_test.go +++ b/pkg/cmd/secret/remove/remove_test.go @@ -49,6 +49,14 @@ func TestNewCmdRemove(t *testing.T) { EnvName: "anEnv", }, }, + { + name: "user", + cli: "cool -u", + wants: RemoveOptions{ + SecretName: "cool", + UserSecrets: true, + }, + }, } for _, tt := range tests { @@ -201,3 +209,30 @@ func Test_removeRun_org(t *testing.T) { } } + +func Test_removeRun_user(t *testing.T) { + reg := &httpmock.Registry{} + + reg.Register( + httpmock.REST("DELETE", "user/codespaces/secrets/cool_secret"), + httpmock.StatusStringResponse(204, "No Content")) + + io, _, _, _ := iostreams.Test() + + opts := &RemoveOptions{ + IO: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + SecretName: "cool_secret", + UserSecrets: true, + } + + err := removeRun(opts) + assert.NoError(t, err) + + reg.Verify(t) +}