diff --git a/Dockerfile b/Dockerfile index fba865b94..cb97a9952 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ ARG ALPINE_VERSION="3.21" ### Go Builder ### -FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} as builder +FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS builder RUN apk add --update --no-cache git bash make ca-certificates diff --git a/cmd/kosli/get.go b/cmd/kosli/get.go index 8ce43bafc..dd5905ab3 100644 --- a/cmd/kosli/get.go +++ b/cmd/kosli/get.go @@ -27,6 +27,7 @@ func newGetCmd(out io.Writer) *cobra.Command { newGetPolicyCmd(out), newGetAttestationTypeCmd(out), newGetAttestationCmd(out), + newGetRepoCmd(out), ) return cmd } diff --git a/cmd/kosli/getRepo.go b/cmd/kosli/getRepo.go new file mode 100644 index 000000000..e86469490 --- /dev/null +++ b/cmd/kosli/getRepo.go @@ -0,0 +1,136 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + neturl "net/url" + + "github.com/kosli-dev/cli/internal/output" + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const getRepoShortDesc = `Get a repo for an org.` + +const getRepoLongDesc = getRepoShortDesc + ` +The name of the repo is specified as an argument (e.g. "my-org/my-repo"). +Use --provider or --repo-id to narrow down the result when multiple repos +match the given name.` + +const getRepoExample = ` +# get a repo +kosli get repo my-org/my-repo \ + --api-token yourAPIToken \ + --org KosliOrgName + +# get a repo filtering by provider +kosli get repo my-org/my-repo \ + --provider github \ + --api-token yourAPIToken \ + --org KosliOrgName` + +type getRepoOptions struct { + output string + provider string + repoID string +} + +func newGetRepoCmd(out io.Writer) *cobra.Command { + o := new(getRepoOptions) + cmd := &cobra.Command{ + Use: "repo REPO-NAME", + Hidden: true, + Short: getRepoShortDesc, + Long: getRepoLongDesc, + Example: getRepoExample, + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out, args) + }, + } + + cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) + cmd.Flags().StringVar(&o.provider, "provider", "", "[optional] The VCS provider to filter repos by (e.g. github, gitlab).") + cmd.Flags().StringVar(&o.repoID, "repo-id", "", "[optional] The external repo ID to filter repos by.") + + return cmd +} + +func (o *getRepoOptions) run(out io.Writer, args []string) error { + params := neturl.Values{} + params.Set("name", args[0]) + if o.provider != "" { + params.Set("provider", o.provider) + } + if o.repoID != "" { + params.Set("repo_id", o.repoID) + } + reqURL := fmt.Sprintf("%s/api/v2/repos/%s?%s", global.Host, global.Org, params.Encode()) + + reqParams := &requests.RequestParams{ + Method: http.MethodGet, + URL: reqURL, + Token: global.ApiToken, + } + response, err := kosliClient.Do(reqParams) + if err != nil { + return err + } + + var parsed struct { + Embedded struct { + Repos []map[string]any `json:"repos"` + } `json:"_embedded"` + } + if err := json.Unmarshal([]byte(response.Body), &parsed); err != nil { + return err + } + if len(parsed.Embedded.Repos) > 1 { + return fmt.Errorf("found %d repos matching %q. Use --provider or --repo-id to narrow down the search", len(parsed.Embedded.Repos), args[0]) + } + + return output.FormattedPrint(response.Body, o.output, out, 0, + map[string]output.FormatOutputFunc{ + "table": printRepoAsTable, + "json": output.PrintJson, + }) +} + +func printRepoAsTable(raw string, out io.Writer, page int) error { + var response struct { + Embedded struct { + Repos []map[string]any `json:"repos"` + } `json:"_embedded"` + } + + err := json.Unmarshal([]byte(raw), &response) + if err != nil { + return err + } + + repos := response.Embedded.Repos + if len(repos) == 0 { + logger.Info("Repo was not found.") + return nil + } + + repo := repos[0] + rows := []string{ + fmt.Sprintf("Name:\t%s", repo["name"]), + fmt.Sprintf("URL:\t%s", repo["url"]), + fmt.Sprintf("Provider:\t%s", repo["provider"]), + fmt.Sprintf("Latest Activity:\t%s", repo["latest_activity"]), + } + + tabFormattedPrint(out, []string{}, rows) + return nil +} diff --git a/cmd/kosli/getRepo_test.go b/cmd/kosli/getRepo_test.go new file mode 100644 index 000000000..6554fb7e7 --- /dev/null +++ b/cmd/kosli/getRepo_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +// Define the suite, and absorb the built-in basic suite +// functionality from testify - including a T() method which +// returns the current testing context +type GetRepoCommandTestSuite struct { + suite.Suite + defaultKosliArguments string + acmeOrgKosliArguments string +} + +func (suite *GetRepoCommandTestSuite) SetupTest() { + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) + + global.Org = "iu-org-shared" + global.ApiToken = "qM9u2_grv6pJLbACwsMMMT5LIQy82tQj2k1zjZnlXti1smnFaGwCKW4jzk0La7ae9RrSYvEwCXSsXknD6YZqd-onLaaIUUKtEn6-B6yh53vWIe9EC5u85FCbKZjFbaicp_d0Me0Zcqq_KcCgrAZRX9xggl_pBb2oaCsNdllqNjk" + suite.acmeOrgKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) + CreateFlowWithTemplate("get-repo", "testdata/valid_template.yml", suite.T()) + SetEnvVars(map[string]string{ + "GITHUB_RUN_NUMBER": "1234", + "GITHUB_SERVER_URL": "https://github.com", + "GITHUB_REPOSITORY": "kosli-dev/cli", + "GITHUB_REPOSITORY_ID": "1234567890", + }, suite.T()) + BeginTrail("trail-name", "get-repo", "", suite.T()) +} + +func (suite *GetRepoCommandTestSuite) TearDownTest() { + UnSetEnvVars(map[string]string{ + "GITHUB_RUN_NUMBER": "", + "GITHUB_SERVER_URL": "", + "GITHUB_REPOSITORY": "", + "GITHUB_REPOSITORY_ID": "", + }, suite.T()) +} + +func (suite *GetRepoCommandTestSuite) TestGetRepoCmd() { + tests := []cmdTestCase{ + { + name: "01-getting a non-existing repo returns not-found message", + cmd: fmt.Sprintf(`get repo non-existing/repo %s`, suite.defaultKosliArguments), + golden: "Repo was not found.\n", + }, + { + name: "02-getting an existing repo works", + cmd: fmt.Sprintf(`get repo kosli-dev/cli %s`, suite.acmeOrgKosliArguments), + }, + { + name: "03-getting an existing repo with --output json works", + cmd: fmt.Sprintf(`get repo kosli-dev/cli --output json %s`, suite.acmeOrgKosliArguments), + goldenJson: []jsonCheck{{"_embedded.repos", "non-empty"}}, + }, + { + name: "04-getting an existing repo with matching --provider works", + cmd: fmt.Sprintf(`get repo kosli-dev/cli --provider github %s`, suite.acmeOrgKosliArguments), + }, + { + name: "05-getting an existing repo with matching --provider and --output json works", + cmd: fmt.Sprintf(`get repo kosli-dev/cli --provider github --output json %s`, suite.acmeOrgKosliArguments), + goldenJson: []jsonCheck{{"_embedded.repos", "non-empty"}}, + }, + { + name: "06-getting a repo with a non-matching --provider returns not-found message", + cmd: fmt.Sprintf(`get repo kosli-dev/cli --provider gitlab %s`, suite.acmeOrgKosliArguments), + golden: "Repo was not found.\n", + }, + { + name: "07-getting a repo with a non-matching --repo-id returns not-found message", + cmd: fmt.Sprintf(`get repo kosli-dev/cli --repo-id non-existing-id %s`, suite.acmeOrgKosliArguments), + golden: "Repo was not found.\n", + }, + { + wantError: true, + name: "08-providing no argument fails", + cmd: fmt.Sprintf(`get repo %s`, suite.defaultKosliArguments), + golden: "Error: accepts 1 arg(s), received 0\n", + }, + { + wantError: true, + name: "09-providing more than one argument fails", + cmd: fmt.Sprintf(`get repo foo bar %s`, suite.defaultKosliArguments), + golden: "Error: accepts 1 arg(s), received 2\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +// In order for 'go test' to run this suite, we need to create +// a normal test function and pass our suite to suite.Run +func TestGetRepoCommandTestSuite(t *testing.T) { + suite.Run(t, new(GetRepoCommandTestSuite)) +} diff --git a/cmd/kosli/listRepos.go b/cmd/kosli/listRepos.go index db81d91b6..c505b2563 100644 --- a/cmd/kosli/listRepos.go +++ b/cmd/kosli/listRepos.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + neturl "net/url" "github.com/kosli-dev/cli/internal/output" "github.com/kosli-dev/cli/internal/requests" @@ -15,6 +16,9 @@ const listReposDesc = `List repos for an org.` type listReposOptions struct { listOptions + name string + provider string + repoID string } func newListReposCmd(out io.Writer) *cobra.Command { @@ -38,16 +42,31 @@ func newListReposCmd(out io.Writer) *cobra.Command { } addListFlags(cmd, &o.listOptions) + cmd.Flags().StringVar(&o.name, "name", "", "[optional] The repo name to filter by.") + cmd.Flags().StringVar(&o.provider, "provider", "", "[optional] The VCS provider to filter repos by (e.g. github, gitlab).") + cmd.Flags().StringVar(&o.repoID, "repo-id", "", "[optional] The external repo ID to filter repos by.") return cmd } func (o *listReposOptions) run(out io.Writer) error { - url := fmt.Sprintf("%s/api/v2/repos/%s?page=%d&per_page=%d", global.Host, global.Org, o.pageNumber, o.pageLimit) + params := neturl.Values{} + params.Set("page", fmt.Sprintf("%d", o.pageNumber)) + params.Set("per_page", fmt.Sprintf("%d", o.pageLimit)) + if o.name != "" { + params.Set("name", o.name) + } + if o.provider != "" { + params.Set("provider", o.provider) + } + if o.repoID != "" { + params.Set("repo_id", o.repoID) + } + reqURL := fmt.Sprintf("%s/api/v2/repos/%s?%s", global.Host, global.Org, params.Encode()) reqParams := &requests.RequestParams{ Method: http.MethodGet, - URL: url, + URL: reqURL, Token: global.ApiToken, } response, err := kosliClient.Do(reqParams) @@ -81,10 +100,10 @@ func printReposListAsTable(raw string, out io.Writer, page int) error { return nil } - header := []string{"NAME", "URL", "LAST_ACTIVITY"} + header := []string{"NAME", "URL", "PROVIDER", "LAST_ACTIVITY"} rows := []string{} for _, repo := range repos { - row := fmt.Sprintf("%s\t%s\t%s", repo["name"], repo["url"], repo["latest_activity"]) + row := fmt.Sprintf("%s\t%s\t%s\t%s", repo["name"], repo["url"], repo["provider"], repo["latest_activity"]) rows = append(rows, row) } tabFormattedPrint(out, header, rows) diff --git a/cmd/kosli/listRepos_test.go b/cmd/kosli/listRepos_test.go index d3379b551..8c2200af1 100644 --- a/cmd/kosli/listRepos_test.go +++ b/cmd/kosli/listRepos_test.go @@ -24,7 +24,7 @@ func (suite *ListReposCommandTestSuite) SetupTest() { } suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) - global.Org = "acme-org" + global.Org = "acme-org-shared" global.ApiToken = "v3OWZiYWu9G2IMQStYg9BcPQUQ88lJNNnTJTNq8jfvmkR1C5wVpHSs7F00JcB5i6OGeUzrKt3CwRq7ndcN4TTfMeo8ASVJ5NdHpZT7DkfRfiFvm8s7GbsIHh2PtiQJYs2UoN13T8DblV5C4oKb6-yWH73h67OhotPlKfVKazR-c" suite.acmeOrgKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) CreateFlowWithTemplate("list-repos", "testdata/valid_template.yml", suite.T()) @@ -57,7 +57,7 @@ func (suite *ListReposCommandTestSuite) TestListReposCmd() { { name: "02-listing repos works when there are no repos", cmd: fmt.Sprintf(`list repos %s`, suite.acmeOrgKosliArguments), - goldenRegex: ".*\nkosli-dev/cli https://github.com/kosli-dev/cli Trail Started at.*", + goldenRegex: ".*\nkosli-dev/cli.*https://github.com/kosli-dev/cli.*github.*Trail Started at.*", }, { name: "03-listing repos with --output json works when there are repos", @@ -93,6 +93,31 @@ func (suite *ListReposCommandTestSuite) TestListReposCmd() { cmd: fmt.Sprintf(`list repos --page-limit 15 --page 2 %s`, suite.defaultKosliArguments), golden: "", }, + { + name: "09-listing repos with --name filter works", + cmd: fmt.Sprintf(`list repos --name kosli-dev/cli %s`, suite.acmeOrgKosliArguments), + goldenRegex: ".*\nkosli-dev/cli.*https://github.com/kosli-dev/cli.*github.*Trail Started at.*", + }, + { + name: "10-listing repos with --name filter and --output json works", + cmd: fmt.Sprintf(`list repos --name kosli-dev/cli --output json %s`, suite.acmeOrgKosliArguments), + goldenJson: []jsonCheck{{"_embedded.repos", "non-empty"}}, + }, + { + name: "11-listing repos with --provider filter works", + cmd: fmt.Sprintf(`list repos --provider github %s`, suite.acmeOrgKosliArguments), + goldenRegex: ".*\nkosli-dev/cli.*https://github.com/kosli-dev/cli.*github.*Trail Started at.*", + }, + { + name: "12-listing repos with non-matching --provider returns no repos message", + cmd: fmt.Sprintf(`list repos --provider gitlab %s`, suite.acmeOrgKosliArguments), + golden: "No repos were found.\n", + }, + { + name: "13-listing repos with non-matching --repo-id returns no repos message", + cmd: fmt.Sprintf(`list repos --repo-id non-existing-id %s`, suite.acmeOrgKosliArguments), + golden: "No repos were found.\n", + }, } runTestCmd(suite.T(), tests) diff --git a/go.mod b/go.mod index ef1302157..c94a94b0d 100644 --- a/go.mod +++ b/go.mod @@ -78,7 +78,7 @@ require ( github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cloudflare/circl v1.6.1 // indirect + github.com/cloudflare/circl v1.6.3 // indirect github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect github.com/containers/ocicrypt v1.2.1 // indirect github.com/containers/storage v1.57.2 // indirect diff --git a/go.sum b/go.sum index b5965735d..a33f1d1f6 100644 --- a/go.sum +++ b/go.sum @@ -143,8 +143,8 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= -github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containers/image/v5 v5.34.3 h1:/cMgfyA4Y7ILH7nzWP/kqpkE5Df35Ek4bp5ZPvJOVmI= diff --git a/internal/requests/requests.go b/internal/requests/requests.go index 4f648ebed..c444afb9a 100644 --- a/internal/requests/requests.go +++ b/internal/requests/requests.go @@ -320,7 +320,7 @@ func (c *Client) PayloadOutput(req *http.Request, jsonFields map[string]any, mes func customCheckRetry(ctx context.Context, resp *http.Response, err error) (bool, error) { // Get the default retry policy for errors and certain status codes. - // It will retry on 5xx, 429 and some special cases + // It will retry on 5xx, 429 (rate limit) and we add 409 (lock conflict) via a custom check. shouldRetry, retryErr := retryablehttp.DefaultRetryPolicy(ctx, resp, err) if retryErr != nil { return false, retryErr @@ -328,7 +328,7 @@ func customCheckRetry(ctx context.Context, resp *http.Response, err error) (bool if shouldRetry { return true, nil } - // The sever gives 409 if we have a lock conflict. + // The server gives 409 if we have a lock conflict. if resp != nil && resp.StatusCode == 409 { return true, nil }