diff --git a/cmd/kosli/listRepos.go b/cmd/kosli/listRepos.go index c7727e160..21a35114c 100644 --- a/cmd/kosli/listRepos.go +++ b/cmd/kosli/listRepos.go @@ -12,13 +12,15 @@ import ( "github.com/spf13/cobra" ) -const listReposShortDesc = `List repos for an org. ` +const listReposShortDesc = `List repos for an org.` -const listReposLongDesc = listReposShortDesc + `The results are always paginated: +const listReposLongDesc = listReposShortDesc + ` The results are always paginated: by default the first page is returned with 15 repos per page. Use --page to select a page and --page-limit to change the page size (maximum 50). -The list can be filtered by name with --name (exact match), by VCS provider with ---provider, and by external repo ID with --repo-id.` +The list can be filtered by name with --name (exact match), by name substring with +--search (case-insensitive, mutually exclusive with --name), by VCS provider with +--provider, by external repo ID with --repo-id, and by tags with --tag. +Results are sorted by repo name; use --sort-direction to choose asc or desc.` const listReposExample = ` # list repos for an org (first page, 15 per page): @@ -32,6 +34,12 @@ kosli list repos \ --api-token yourAPIToken \ --org yourOrgName +# list repos whose name contains a substring (case-insensitive): +kosli list repos \ + --search cli \ + --api-token yourAPIToken \ + --org yourOrgName + # list repos filtered by VCS provider (in JSON): kosli list repos \ --provider github \ @@ -39,6 +47,18 @@ kosli list repos \ --org yourOrgName \ --output json +# list repos tagged with team=platform: +kosli list repos \ + --tag team:platform \ + --api-token yourAPIToken \ + --org yourOrgName + +# list repos sorted by name, Z–A: +kosli list repos \ + --sort-direction desc \ + --api-token yourAPIToken \ + --org yourOrgName + # show the second page of repos (25 per page): kosli list repos \ --page-limit 25 \ @@ -49,9 +69,12 @@ kosli list repos \ type listReposOptions struct { listOptions - name string - provider string - repoID string + name string + search string + provider string + repoID string + sortDirection string + tags []string } func newListReposCmd(out io.Writer) *cobra.Command { @@ -76,8 +99,12 @@ func newListReposCmd(out io.Writer) *cobra.Command { addListFlags(cmd, &o.listOptions) cmd.Flags().StringVar(&o.name, "name", "", "[optional] The repo name to filter by (exact match).") + cmd.Flags().StringVar(&o.search, "search", "", "[optional] Filter repos whose name contains this substring (case-insensitive). Mutually exclusive with --name.") 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.") + cmd.Flags().StringVar(&o.sortDirection, "sort-direction", "", "[optional] The direction to sort repos by name. Valid values are: [asc, desc]. (defaults to asc)") + cmd.Flags().StringArrayVar(&o.tags, "tag", []string{}, "[optional] Only list repos that have this tag, given as 'key' or 'key:value'. Can be repeated to match more than one tag.") + cmd.MarkFlagsMutuallyExclusive("name", "search") return cmd } @@ -89,12 +116,21 @@ func (o *listReposOptions) run(out io.Writer) error { if o.name != "" { params.Set("name", o.name) } + if o.search != "" { + params.Set("search", o.search) + } if o.provider != "" { params.Set("provider", o.provider) } if o.repoID != "" { params.Set("repo_id", o.repoID) } + if o.sortDirection != "" { + params.Set("sort_direction", o.sortDirection) + } + for _, tag := range o.tags { + params.Add("tag", tag) + } base, err := neturl.JoinPath(global.Host, "api/v2/repos", global.Org) if err != nil { return err diff --git a/cmd/kosli/listRepos_test.go b/cmd/kosli/listRepos_test.go index 0a29e410b..cf39b45fa 100644 --- a/cmd/kosli/listRepos_test.go +++ b/cmd/kosli/listRepos_test.go @@ -48,6 +48,21 @@ func (suite *ListReposCommandTestSuite) SetupTest() { "GITHUB_REPOSITORY_ID": "555", }, suite.T()) BeginTrail("untagged-trail", "list-repos", "", suite.T()) + + // three repos sharing a search substring, in a known A–Z order, so the + // --sort-direction tests can assert the ordering actually reverses + sortRepos := []struct{ name, id string }{ + {"list-repos-suite-org/sort-a", "701"}, + {"list-repos-suite-org/sort-b", "702"}, + {"list-repos-suite-org/sort-c", "703"}, + } + for _, r := range sortRepos { + SetEnvVars(map[string]string{ + "GITHUB_REPOSITORY": r.name, + "GITHUB_REPOSITORY_ID": r.id, + }, suite.T()) + BeginTrail("sort-trail-"+r.id, "list-repos", "", suite.T()) + } } func (suite *ListReposCommandTestSuite) TearDownTest() { @@ -131,6 +146,55 @@ func (suite *ListReposCommandTestSuite) TestListReposCmd() { cmd: fmt.Sprintf(`list repos --repo-id non-existing-id %s`, suite.acmeOrgKosliArguments), golden: "No repos were found.\n", }, + { + name: "14a-listing repos with --search substring works", + cmd: fmt.Sprintf(`list repos --search cli %s`, suite.acmeOrgKosliArguments), + goldenRegex: `(?m)^kosli-dev/cli\s+https://github\.com/kosli-dev/cli\s+github\b`, + }, + { + name: "14b-listing repos with --search and --output json works", + cmd: fmt.Sprintf(`list repos --search cli --output json %s`, suite.acmeOrgKosliArguments), + goldenJson: []jsonCheck{{"repos", "non-empty"}}, + }, + { + wantError: true, + name: "14c-using --name and --search together causes an error", + cmd: fmt.Sprintf(`list repos --name kosli-dev/cli --search cli %s`, suite.acmeOrgKosliArguments), + golden: "Error: if any flags in the group [name search] are set none of the others can be; [name search] were all set\n", + }, + { + name: "14d-listing repos filtered by --tag key:value works", + cmd: fmt.Sprintf(`list repos --tag team:platform %s`, suite.acmeOrgKosliArguments), + goldenRegex: `(?m)^kosli-dev/cli\s+https://github\.com/kosli-dev/cli\s+github\s+team=platform`, + }, + { + name: "14e-listing repos filtered by --tag key only works", + cmd: fmt.Sprintf(`list repos --tag team %s`, suite.acmeOrgKosliArguments), + goldenRegex: `(?m)^kosli-dev/cli\s+https://github\.com/kosli-dev/cli\s+github\s+team=platform`, + }, + { + name: "14f-listing repos with a non-matching --tag returns no repos message", + cmd: fmt.Sprintf(`list repos --tag team:doesnotexist %s`, suite.acmeOrgKosliArguments), + golden: "No repos were found.\n", + }, + { + name: "14g-listing repos with --sort-direction asc keeps A–Z order", + cmd: fmt.Sprintf(`list repos --search list-repos-suite-org/sort- --sort-direction asc --page 1 --page-limit 50 --output json %s`, suite.acmeOrgKosliArguments), + goldenJson: []jsonCheck{ + {"repos", "length:3"}, + {"repos.[0].name", "list-repos-suite-org/sort-a"}, + {"repos.[2].name", "list-repos-suite-org/sort-c"}, + }, + }, + { + name: "14h-listing repos with --sort-direction desc reverses the order", + cmd: fmt.Sprintf(`list repos --search list-repos-suite-org/sort- --sort-direction desc --page 1 --page-limit 50 --output json %s`, suite.acmeOrgKosliArguments), + goldenJson: []jsonCheck{ + {"repos", "length:3"}, + {"repos.[0].name", "list-repos-suite-org/sort-c"}, + {"repos.[2].name", "list-repos-suite-org/sort-a"}, + }, + }, { name: "14-a repo without tags renders a blank TAGS cell", cmd: fmt.Sprintf(`list repos %s`, suite.acmeOrgKosliArguments),