github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/secret/list/list.go (about)

     1  package list
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"net/http"
     7  	"regexp"
     8  	"strings"
     9  	"time"
    10  
    11  	"github.com/MakeNowJust/heredoc"
    12  	"github.com/ungtb10d/cli/v2/api"
    13  	"github.com/ungtb10d/cli/v2/internal/config"
    14  	"github.com/ungtb10d/cli/v2/internal/ghinstance"
    15  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    16  	"github.com/ungtb10d/cli/v2/pkg/cmd/secret/shared"
    17  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    18  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    19  	"github.com/ungtb10d/cli/v2/utils"
    20  	"github.com/spf13/cobra"
    21  )
    22  
    23  type ListOptions struct {
    24  	HttpClient func() (*http.Client, error)
    25  	IO         *iostreams.IOStreams
    26  	Config     func() (config.Config, error)
    27  	BaseRepo   func() (ghrepo.Interface, error)
    28  
    29  	OrgName     string
    30  	EnvName     string
    31  	UserSecrets bool
    32  	Application string
    33  }
    34  
    35  func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
    36  	opts := &ListOptions{
    37  		IO:         f.IOStreams,
    38  		Config:     f.Config,
    39  		HttpClient: f.HttpClient,
    40  	}
    41  
    42  	cmd := &cobra.Command{
    43  		Use:   "list",
    44  		Short: "List secrets",
    45  		Long: heredoc.Doc(`
    46  			List secrets on one of the following levels:
    47  			- repository (default): available to Actions runs or Dependabot in a repository
    48  			- environment: available to Actions runs for a deployment environment in a repository
    49  			- organization: available to Actions runs, Dependabot, or Codespaces within an organization
    50  			- user: available to Codespaces for your user
    51  		`),
    52  		Aliases: []string{"ls"},
    53  		Args:    cobra.NoArgs,
    54  		RunE: func(cmd *cobra.Command, args []string) error {
    55  			// support `-R, --repo` override
    56  			opts.BaseRepo = f.BaseRepo
    57  
    58  			if err := cmdutil.MutuallyExclusive("specify only one of `--org`, `--env`, or `--user`", opts.OrgName != "", opts.EnvName != "", opts.UserSecrets); err != nil {
    59  				return err
    60  			}
    61  
    62  			if runF != nil {
    63  				return runF(opts)
    64  			}
    65  
    66  			return listRun(opts)
    67  		},
    68  	}
    69  
    70  	cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization")
    71  	cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "List secrets for an environment")
    72  	cmd.Flags().BoolVarP(&opts.UserSecrets, "user", "u", false, "List a secret for your user")
    73  	cmdutil.StringEnumFlag(cmd, &opts.Application, "app", "a", "", []string{shared.Actions, shared.Codespaces, shared.Dependabot}, "List secrets for a specific application")
    74  
    75  	return cmd
    76  }
    77  
    78  func listRun(opts *ListOptions) error {
    79  	client, err := opts.HttpClient()
    80  	if err != nil {
    81  		return fmt.Errorf("could not create http client: %w", err)
    82  	}
    83  
    84  	orgName := opts.OrgName
    85  	envName := opts.EnvName
    86  
    87  	var baseRepo ghrepo.Interface
    88  	if orgName == "" && !opts.UserSecrets {
    89  		baseRepo, err = opts.BaseRepo()
    90  		if err != nil {
    91  			return fmt.Errorf("could not determine base repo: %w", err)
    92  		}
    93  	}
    94  
    95  	secretEntity, err := shared.GetSecretEntity(orgName, envName, opts.UserSecrets)
    96  	if err != nil {
    97  		return err
    98  	}
    99  
   100  	secretApp, err := shared.GetSecretApp(opts.Application, secretEntity)
   101  	if err != nil {
   102  		return err
   103  	}
   104  
   105  	if !shared.IsSupportedSecretEntity(secretApp, secretEntity) {
   106  		return fmt.Errorf("%s secrets are not supported for %s", secretEntity, secretApp)
   107  	}
   108  
   109  	var secrets []*Secret
   110  	showSelectedRepoInfo := opts.IO.IsStdoutTTY()
   111  
   112  	switch secretEntity {
   113  	case shared.Repository:
   114  		secrets, err = getRepoSecrets(client, baseRepo, secretApp)
   115  	case shared.Environment:
   116  		secrets, err = getEnvSecrets(client, baseRepo, envName)
   117  	case shared.Organization, shared.User:
   118  		var cfg config.Config
   119  		var host string
   120  
   121  		cfg, err = opts.Config()
   122  		if err != nil {
   123  			return err
   124  		}
   125  
   126  		host, _ = cfg.DefaultHost()
   127  
   128  		if secretEntity == shared.User {
   129  			secrets, err = getUserSecrets(client, host, showSelectedRepoInfo)
   130  		} else {
   131  			secrets, err = getOrgSecrets(client, host, orgName, showSelectedRepoInfo, secretApp)
   132  		}
   133  	}
   134  
   135  	if err != nil {
   136  		return fmt.Errorf("failed to get secrets: %w", err)
   137  	}
   138  
   139  	if len(secrets) == 0 {
   140  		return cmdutil.NewNoResultsError("no secrets found")
   141  	}
   142  
   143  	if err := opts.IO.StartPager(); err == nil {
   144  		defer opts.IO.StopPager()
   145  	} else {
   146  		fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err)
   147  	}
   148  
   149  	//nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter
   150  	tp := utils.NewTablePrinter(opts.IO)
   151  	for _, secret := range secrets {
   152  		tp.AddField(secret.Name, nil, nil)
   153  		updatedAt := secret.UpdatedAt.Format("2006-01-02")
   154  		if opts.IO.IsStdoutTTY() {
   155  			updatedAt = fmt.Sprintf("Updated %s", updatedAt)
   156  		}
   157  		tp.AddField(updatedAt, nil, nil)
   158  		if secret.Visibility != "" {
   159  			if showSelectedRepoInfo {
   160  				tp.AddField(fmtVisibility(*secret), nil, nil)
   161  			} else {
   162  				tp.AddField(strings.ToUpper(string(secret.Visibility)), nil, nil)
   163  			}
   164  		}
   165  		tp.EndRow()
   166  	}
   167  
   168  	err = tp.Render()
   169  	if err != nil {
   170  		return err
   171  	}
   172  
   173  	return nil
   174  }
   175  
   176  type Secret struct {
   177  	Name             string
   178  	UpdatedAt        time.Time `json:"updated_at"`
   179  	Visibility       shared.Visibility
   180  	SelectedReposURL string `json:"selected_repositories_url"`
   181  	NumSelectedRepos int
   182  }
   183  
   184  func fmtVisibility(s Secret) string {
   185  	switch s.Visibility {
   186  	case shared.All:
   187  		return "Visible to all repositories"
   188  	case shared.Private:
   189  		return "Visible to private repositories"
   190  	case shared.Selected:
   191  		if s.NumSelectedRepos == 1 {
   192  			return "Visible to 1 selected repository"
   193  		} else {
   194  			return fmt.Sprintf("Visible to %d selected repositories", s.NumSelectedRepos)
   195  		}
   196  	}
   197  	return ""
   198  }
   199  
   200  func getOrgSecrets(client httpClient, host, orgName string, showSelectedRepoInfo bool, app shared.App) ([]*Secret, error) {
   201  	secrets, err := getSecrets(client, host, fmt.Sprintf("orgs/%s/%s/secrets", orgName, app))
   202  	if err != nil {
   203  		return nil, err
   204  	}
   205  
   206  	if showSelectedRepoInfo {
   207  		err = getSelectedRepositoryInformation(client, secrets)
   208  		if err != nil {
   209  			return nil, err
   210  		}
   211  	}
   212  	return secrets, nil
   213  }
   214  
   215  func getUserSecrets(client httpClient, host string, showSelectedRepoInfo bool) ([]*Secret, error) {
   216  	secrets, err := getSecrets(client, host, "user/codespaces/secrets")
   217  	if err != nil {
   218  		return nil, err
   219  	}
   220  
   221  	if showSelectedRepoInfo {
   222  		err = getSelectedRepositoryInformation(client, secrets)
   223  		if err != nil {
   224  			return nil, err
   225  		}
   226  	}
   227  
   228  	return secrets, nil
   229  }
   230  
   231  func getEnvSecrets(client httpClient, repo ghrepo.Interface, envName string) ([]*Secret, error) {
   232  	path := fmt.Sprintf("repos/%s/environments/%s/secrets", ghrepo.FullName(repo), envName)
   233  	return getSecrets(client, repo.RepoHost(), path)
   234  }
   235  
   236  func getRepoSecrets(client httpClient, repo ghrepo.Interface, app shared.App) ([]*Secret, error) {
   237  	return getSecrets(client, repo.RepoHost(), fmt.Sprintf("repos/%s/%s/secrets",
   238  		ghrepo.FullName(repo), app))
   239  }
   240  
   241  type secretsPayload struct {
   242  	Secrets []*Secret
   243  }
   244  
   245  type httpClient interface {
   246  	Do(*http.Request) (*http.Response, error)
   247  }
   248  
   249  func getSecrets(client httpClient, host, path string) ([]*Secret, error) {
   250  	var results []*Secret
   251  	url := fmt.Sprintf("%s%s?per_page=100", ghinstance.RESTPrefix(host), path)
   252  
   253  	for {
   254  		var payload secretsPayload
   255  		nextURL, err := apiGet(client, url, &payload)
   256  		if err != nil {
   257  			return nil, err
   258  		}
   259  		results = append(results, payload.Secrets...)
   260  
   261  		if nextURL == "" {
   262  			break
   263  		}
   264  		url = nextURL
   265  	}
   266  
   267  	return results, nil
   268  }
   269  
   270  func apiGet(client httpClient, url string, data interface{}) (string, error) {
   271  	req, err := http.NewRequest("GET", url, nil)
   272  	if err != nil {
   273  		return "", err
   274  	}
   275  	req.Header.Set("Content-Type", "application/json; charset=utf-8")
   276  
   277  	resp, err := client.Do(req)
   278  	if err != nil {
   279  		return "", err
   280  	}
   281  	defer resp.Body.Close()
   282  
   283  	if resp.StatusCode > 299 {
   284  		return "", api.HandleHTTPError(resp)
   285  	}
   286  
   287  	dec := json.NewDecoder(resp.Body)
   288  	if err := dec.Decode(data); err != nil {
   289  		return "", err
   290  	}
   291  
   292  	return findNextPage(resp.Header.Get("Link")), nil
   293  }
   294  
   295  var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`)
   296  
   297  func findNextPage(link string) string {
   298  	for _, m := range linkRE.FindAllStringSubmatch(link, -1) {
   299  		if len(m) > 2 && m[2] == "next" {
   300  			return m[1]
   301  		}
   302  	}
   303  	return ""
   304  }
   305  
   306  func getSelectedRepositoryInformation(client httpClient, secrets []*Secret) error {
   307  	type responseData struct {
   308  		TotalCount int `json:"total_count"`
   309  	}
   310  
   311  	for _, secret := range secrets {
   312  		if secret.SelectedReposURL == "" {
   313  			continue
   314  		}
   315  		var result responseData
   316  		if _, err := apiGet(client, secret.SelectedReposURL, &result); err != nil {
   317  			return fmt.Errorf("failed determining selected repositories for %s: %w", secret.Name, err)
   318  		}
   319  		secret.NumSelectedRepos = result.TotalCount
   320  	}
   321  
   322  	return nil
   323  }