diff --git a/cmd/kosli/list.go b/cmd/kosli/list.go index a66cda71a..f4c335036 100644 --- a/cmd/kosli/list.go +++ b/cmd/kosli/list.go @@ -53,6 +53,7 @@ func newListCmd(out io.Writer) *cobra.Command { newListTrailsCmd(out), newListPoliciesCmd(out), newListAttestationTypesCmd(out), + newListReposCmd(out), ) return cmd diff --git a/cmd/kosli/listRepos.go b/cmd/kosli/listRepos.go new file mode 100644 index 000000000..1e981e3cb --- /dev/null +++ b/cmd/kosli/listRepos.go @@ -0,0 +1,93 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/kosli-dev/cli/internal/output" + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const listReposDesc = `List repos for an org.` + +type listReposOptions struct { + listOptions +} + +func newListReposCmd(out io.Writer) *cobra.Command { + o := new(listReposOptions) + cmd := &cobra.Command{ + Use: "repos", + Hidden: true, + Short: listReposDesc, + Long: listReposDesc, + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + return o.validate(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out) + }, + } + + addListFlags(cmd, &o.listOptions) + + 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) + + reqParams := &requests.RequestParams{ + Method: http.MethodGet, + URL: url, + Token: global.ApiToken, + } + response, err := kosliClient.Do(reqParams) + if err != nil { + return err + } + + return output.FormattedPrint(response.Body, o.listOptions.output, out, o.pageNumber, + map[string]output.FormatOutputFunc{ + "table": printReposListAsTable, + "json": output.PrintJson, + }) +} + +func printReposListAsTable(raw string, out io.Writer, page int) error { + var repos []map[string]any + 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("No repos were found.") + return nil + } + + header := []string{"NAME", "URL", "LAST_ACTIVITY"} + rows := []string{} + for _, repo := range repos { + row := fmt.Sprintf("%s\t%s\t%s", repo["name"], repo["url"], repo["latest_activity"]) + rows = append(rows, row) + } + tabFormattedPrint(out, header, rows) + + return nil +} diff --git a/cmd/kosli/listRepos_test.go b/cmd/kosli/listRepos_test.go new file mode 100644 index 000000000..8d83e3a0c --- /dev/null +++ b/cmd/kosli/listRepos_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 ListReposCommandTestSuite struct { + suite.Suite + defaultKosliArguments string + acmeOrgKosliArguments string +} + +func (suite *ListReposCommandTestSuite) 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 = "acme-org" + 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.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.Suite.T()) + BeginTrail("trail-name", "list-repos", "", suite.Suite.T()) +} + +func (suite *ListReposCommandTestSuite) TearDownTest() { + UnSetEnvVars(map[string]string{ + "GITHUB_RUN_NUMBER": "", + "GITHUB_SERVER_URL": "", + "GITHUB_REPOSITORY": "", + "GITHUB_REPOSITORY_ID": "", + }, suite.Suite.T()) +} + +func (suite *ListReposCommandTestSuite) TestListReposCmd() { + tests := []cmdTestCase{ + // THIS TEST IS FLAKY IN CI SINCE CI VARIABLES ARE SET THERE AND REPOS MAY EXIST FROM OTHER TESTS + // { + // name: "01-listing repos works when there are repos", + // cmd: fmt.Sprintf(`list repos %s`, suite.defaultKosliArguments), + // golden: "No repos were found.\n", + // }, + { + 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.*", + }, + { + name: "03-listing repos with --output json works when there are repos", + cmd: fmt.Sprintf(`list repos --output json %s`, suite.acmeOrgKosliArguments), + goldenJson: []jsonCheck{{"_embedded.repos", "non-empty"}}, + }, + // THIS TEST IS FLAKY IN CI SINCE CI VARIABLES ARE SET THERE AND REPOS MAY EXIST FROM OTHER TESTS + // { + // name: "04-listing repos with --output json works when there are no repos", + // cmd: fmt.Sprintf(`list repos --output json %s`, suite.defaultKosliArguments), + // goldenJson: []jsonCheck{{"_embedded.repos", "[]"}}, + // }, + { + wantError: true, + name: "05-providing an argument causes an error", + cmd: fmt.Sprintf(`list repos xxx %s`, suite.defaultKosliArguments), + golden: "Error: unknown command \"xxx\" for \"kosli list repos\"\n", + }, + { + wantError: true, + name: "06-negative page limit causes an error", + cmd: fmt.Sprintf(`list repos --page-limit -1 %s`, suite.defaultKosliArguments), + golden: "Error: flag '--page-limit' has value '-1' which is illegal\n", + }, + { + wantError: true, + name: "07-negative page number causes an error", + cmd: fmt.Sprintf(`list repos --page -1 %s`, suite.defaultKosliArguments), + golden: "Error: flag '--page' has value '-1' which is illegal\n", + }, + { + name: "08-can list repos with pagination", + cmd: fmt.Sprintf(`list repos --page-limit 15 --page 2 %s`, suite.defaultKosliArguments), + golden: "", + }, + } + + runTestCmd(suite.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 TestListReposCommandTestSuite(t *testing.T) { + suite.Run(t, new(ListReposCommandTestSuite)) +} diff --git a/cmd/kosli/testHelpers.go b/cmd/kosli/testHelpers.go index 3f9e57483..3f914b905 100644 --- a/cmd/kosli/testHelpers.go +++ b/cmd/kosli/testHelpers.go @@ -20,7 +20,7 @@ import ( type jsonCheck struct { Path string - Want interface{} + Want any } // cmdTestCase describes a cmd test case. @@ -106,38 +106,53 @@ func goldenPath(filename string) string { return filepath.Join("testdata", filename) } -func goldenJsonContains(t *testing.T, output string, path string, want interface{}) { - var data interface{} +func goldenJsonContains(t *testing.T, output string, path string, want any) { + var data any err := json.Unmarshal([]byte(output), &data) require.NoError(t, err, "invalid JSON in command output") - // Handle empty path - check root value directly - if path == "" { - // Special case: check for empty array - if want == "[]" || want == "empty" { - list, ok := data.([]interface{}) - require.True(t, ok, "expected array at root") - require.Equal(t, 0, len(list), "expected empty array") - return - } - // Special case: check for non-empty array - if want == "non-empty" { - list, ok := data.([]interface{}) - require.True(t, ok, "expected array at root") - require.Greater(t, len(list), 0, "expected non-empty array") - return - } - // Special case: check for empty object - if want == "{}" { - obj, ok := data.(map[string]interface{}) - require.True(t, ok, "expected object at root") - require.Equal(t, 0, len(obj), "expected empty object") - return - } - require.Equal(t, want, data, "unexpected value at root") + if path != "" { + data = parseJsonData(data, path, t) + } + + // Special case: check for empty array + if want == "[]" || want == "empty" { + list, ok := data.([]any) + require.True(t, ok, "expected array at root") + require.Equal(t, 0, len(list), "expected empty array") + return + } + // Special case: check for non-empty array + if want == "non-empty" { + list, ok := data.([]any) + require.True(t, ok, "expected array at root") + require.Greater(t, len(list), 0, "expected non-empty array") + return + } + // Special case: check for empty object + if want == "{}" { + obj, ok := data.(map[string]any) + require.True(t, ok, "expected object at root") + require.Equal(t, 0, len(obj), "expected empty object") return } + // Special case: check array length + if wantStr, ok := want.(string); ok && strings.HasPrefix(wantStr, "length:") { + lengthStr := strings.TrimPrefix(wantStr, "length:") + expectedLength, err := strconv.Atoi(lengthStr) + require.NoError(t, err, "invalid length specification: %s", wantStr) + + list, ok := data.([]any) + require.True(t, ok, "expected array at path %s", path) + require.Equal(t, expectedLength, len(list), "unexpected array length at path %s", path) + return + } + + require.Equal(t, want, data, "unexpected value at path %s", path) +} + +func parseJsonData(data any, path string, t *testing.T) any { current := data segments := strings.Split(path, ".") for _, seg := range segments { @@ -147,13 +162,13 @@ func goldenJsonContains(t *testing.T, output string, path string, want interface idx, err := strconv.Atoi(idxStr) require.NoError(t, err, "invalid array index in path: %s", seg) - list, ok := current.([]interface{}) + list, ok := current.([]any) require.True(t, ok, "expected array at %s", seg) require.True(t, idx < len(list), "index %d out of range", idx) current = list[idx] } else { // map lookup - m, ok := current.(map[string]interface{}) + m, ok := current.(map[string]any) require.True(t, ok, "expected object at %s", seg) val, exists := m[seg] @@ -161,20 +176,7 @@ func goldenJsonContains(t *testing.T, output string, path string, want interface current = val } } - - // Special case: check array length - if wantStr, ok := want.(string); ok && strings.HasPrefix(wantStr, "length:") { - lengthStr := strings.TrimPrefix(wantStr, "length:") - expectedLength, err := strconv.Atoi(lengthStr) - require.NoError(t, err, "invalid length specification: %s", wantStr) - - list, ok := current.([]interface{}) - require.True(t, ok, "expected array at path %s", path) - require.Equal(t, expectedLength, len(list), "unexpected array length at path %s", path) - return - } - - require.Equal(t, want, current, "unexpected value at path %s", path) + return current } func compareTwoFiles(actualFilename, expectedFilename string) error { @@ -329,6 +331,13 @@ func BeginTrail(trailName, flowName, templatePath string, t *testing.T) { payload: TrailPayload{ Name: trailName, Description: "test trail", + GitRepoInfo: &gitview.GitRepoInfo{ + URL: "https://github.com/kosli-dev/cli", + Name: "main", + ID: "1234567890", + Description: "test description", + Provider: "github", + }, }, templateFile: templatePath, flow: flowName,