From 211c87b8837507083c31f8802823be34a39265ba Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Mon, 29 Jun 2026 22:47:57 +0100 Subject: [PATCH 1/3] feat(list flows): add pagination support Bring `kosli list flows` in line with `kosli list trails`/`list artifacts` by adding `--page` and `--page-limit` flags (issue #978). - embed shared listOptions, wire addListFlags and validate() - always send page/per_page; sending per_page switches the endpoint to the {data, pagination} envelope response - parse the envelope and print a "Showing page X of Y, total Z items" footer; include the page number in the empty-result message - move the `--name` shorthand from `-n` to `-N` so it no longer clashes with `--page-limit`'s `-n` (the family-wide convention) Note: `--output json` now returns the {data, pagination} envelope instead of a plain array, matching `list trails`. --- cmd/kosli/listFlows.go | 43 ++++++++++++++++++++-------- cmd/kosli/listFlows_test.go | 57 +++++++++++++++++++++++++++++++------ 2 files changed, 79 insertions(+), 21 deletions(-) diff --git a/cmd/kosli/listFlows.go b/cmd/kosli/listFlows.go index 006d04ecb..0cb322e17 100644 --- a/cmd/kosli/listFlows.go +++ b/cmd/kosli/listFlows.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "github.com/kosli-dev/cli/internal/output" @@ -16,11 +17,15 @@ import ( const listFlowsDesc = `List flows for an org.` type listFlowsOptions struct { - output string + listOptions name string ignoreCase bool } +func (o *listFlowsOptions) validate(cmd *cobra.Command) error { + return o.listOptions.validate(cmd) +} + func newListFlowsCmd(out io.Writer) *cobra.Command { o := new(listFlowsOptions) cmd := &cobra.Command{ @@ -33,15 +38,15 @@ func newListFlowsCmd(out io.Writer) *cobra.Command { if err != nil { return ErrorBeforePrintingUsage(cmd, err.Error()) } - return nil + return o.validate(cmd) }, RunE: func(cmd *cobra.Command, args []string) error { return o.run(out) }, } - cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) - cmd.Flags().StringVarP(&o.name, "name", "n", "", searchByNameFlag) + addListFlags(cmd, &o.listOptions) + cmd.Flags().StringVarP(&o.name, "name", "N", "", searchByNameFlag) cmd.Flags().BoolVarP(&o.ignoreCase, "ignore-case", "i", false, ignoreCaseFlag) return cmd @@ -54,6 +59,9 @@ func (o *listFlowsOptions) run(out io.Writer) error { } params := url.Values{} + // sending per_page switches the endpoint to the paginated envelope response + params.Set("page", strconv.Itoa(o.pageNumber)) + params.Set("per_page", strconv.Itoa(o.pageLimit)) if o.name != "" { params.Set("search_by_name", o.name) // case_sensitive only affects search, so only send it alongside a search term @@ -61,10 +69,7 @@ func (o *listFlowsOptions) run(out io.Writer) error { params.Set("case_sensitive", "false") } } - reqURL := base - if encoded := params.Encode(); encoded != "" { - reqURL = base + "?" + encoded - } + reqURL := base + "?" + params.Encode() reqParams := &requests.RequestParams{ Method: http.MethodGet, @@ -76,22 +81,32 @@ func (o *listFlowsOptions) run(out io.Writer) error { return err } - return output.FormattedPrint(response.Body, o.output, out, 0, + return output.FormattedPrint(response.Body, o.output, out, o.pageNumber, map[string]output.FormatOutputFunc{ "table": printFlowsListAsTable, "json": output.PrintJson, }) } +type listFlowsResponse struct { + Data []map[string]interface{} `json:"data"` + Pagination Pagination `json:"pagination"` +} + func printFlowsListAsTable(raw string, out io.Writer, page int) error { - var flows []map[string]interface{} - err := json.Unmarshal([]byte(raw), &flows) + response := &listFlowsResponse{} + err := json.Unmarshal([]byte(raw), response) if err != nil { return err } + flows := response.Data if len(flows) == 0 { - logger.Info("No flows were found.") + msg := "No flows were found" + if page != 1 { + msg = fmt.Sprintf("%s at page number %d", msg, page) + } + logger.Info(msg + ".") return nil } @@ -107,6 +122,10 @@ func printFlowsListAsTable(raw string, out io.Writer, page int) error { row := fmt.Sprintf("%s\t%s\t%s\t%s", flow["name"], flow["description"], flow["visibility"], tagsOutput) rows = append(rows, row) } + pagination := response.Pagination + paginationInfo := fmt.Sprintf("\nShowing page %.0f of %.0f, total %.0f items", pagination.Page, pagination.PageCount, pagination.Total) + rows = append(rows, paginationInfo) + tabFormattedPrint(out, header, rows) return nil diff --git a/cmd/kosli/listFlows_test.go b/cmd/kosli/listFlows_test.go index 7135c8d94..c0f1a4319 100644 --- a/cmd/kosli/listFlows_test.go +++ b/cmd/kosli/listFlows_test.go @@ -48,37 +48,76 @@ func (suite *ListFlowsCommandTestSuite) TestListFlowsCmd() { { name: "listing flows with --output json works when there are flows", cmd: fmt.Sprintf(`list flows --output json %s`, suite.defaultKosliArguments), - goldenJson: []jsonCheck{{"", "non-empty"}}, + goldenJson: []jsonCheck{{"data", "non-empty"}}, }, { name: "listing flows with --output json works when there are no flows", cmd: fmt.Sprintf(`list flows --output json %s`, suite.acmeOrgKosliArguments), - goldenJson: []jsonCheck{{"", "[]"}}, + goldenJson: []jsonCheck{{"data", "[]"}}, }, { name: "--name matches flows whose name contains the substring", cmd: fmt.Sprintf(`list flows --name list-flows-search-target --output json %s`, suite.defaultKosliArguments), - goldenJson: []jsonCheck{{"", "non-empty"}, {"[0].name", "list-flows-search-target"}}, + goldenJson: []jsonCheck{{"data", "non-empty"}, {"data.[0].name", "list-flows-search-target"}}, }, { name: "--name with no matching substring returns an empty list", cmd: fmt.Sprintf(`list flows --name no-such-flow-substring-xyz --output json %s`, suite.defaultKosliArguments), - goldenJson: []jsonCheck{{"", "[]"}}, + goldenJson: []jsonCheck{{"data", "[]"}}, }, { name: "--name matching is case sensitive by default so a wrong-case substring does not match", cmd: fmt.Sprintf(`list flows --name LIST-FLOWS-SEARCH-TARGET --output json %s`, suite.defaultKosliArguments), - goldenJson: []jsonCheck{{"", "[]"}}, + goldenJson: []jsonCheck{{"data", "[]"}}, }, { name: "--ignore-case makes a wrong-case substring match", cmd: fmt.Sprintf(`list flows --name LIST-FLOWS-SEARCH-TARGET --ignore-case --output json %s`, suite.defaultKosliArguments), - goldenJson: []jsonCheck{{"", "non-empty"}, {"[0].name", "list-flows-search-target"}}, + goldenJson: []jsonCheck{{"data", "non-empty"}, {"data.[0].name", "list-flows-search-target"}}, }, { - name: "short flags -n and -i work like --name and --ignore-case", - cmd: fmt.Sprintf(`list flows -n LIST-FLOWS-SEARCH-TARGET -i --output json %s`, suite.defaultKosliArguments), - goldenJson: []jsonCheck{{"", "non-empty"}, {"[0].name", "list-flows-search-target"}}, + name: "short flags -N and -i work like --name and --ignore-case", + cmd: fmt.Sprintf(`list flows -N LIST-FLOWS-SEARCH-TARGET -i --output json %s`, suite.defaultKosliArguments), + goldenJson: []jsonCheck{{"data", "non-empty"}, {"data.[0].name", "list-flows-search-target"}}, + }, + { + name: "pagination metadata is returned on page 1", + cmd: fmt.Sprintf(`list flows --page-limit 1 --page 1 --output json %s`, suite.defaultKosliArguments), + goldenJson: []jsonCheck{{"data", "non-empty"}, {"pagination.page", float64(1)}}, + }, + { + name: "can page through flows with --page", + cmd: fmt.Sprintf(`list flows --page-limit 1 --page 2 --output json %s`, suite.defaultKosliArguments), + goldenJson: []jsonCheck{{"pagination.page", float64(2)}}, + }, + { + name: "an empty page reports the page number", + cmd: fmt.Sprintf(`list flows --page 99 %s`, suite.defaultKosliArguments), + golden: "No flows were found at page number 99.\n", + }, + { + wantError: true, + name: "negative page limit causes an error", + cmd: fmt.Sprintf(`list flows --page-limit -1 %s`, suite.defaultKosliArguments), + golden: "Error: flag '--page-limit' has value '-1' which is illegal\n", + }, + { + wantError: true, + name: "negative page number causes an error", + cmd: fmt.Sprintf(`list flows --page -1 %s`, suite.defaultKosliArguments), + golden: "Error: flag '--page' has value '-1' which is illegal\n", + }, + { + wantError: true, + name: "zero page limit causes an error", + cmd: fmt.Sprintf(`list flows --page-limit 0 %s`, suite.defaultKosliArguments), + golden: "Error: page limit must be a positive integer\nUsage: kosli list flows [flags]\n", + }, + { + wantError: true, + name: "zero page number causes an error", + cmd: fmt.Sprintf(`list flows --page 0 %s`, suite.defaultKosliArguments), + golden: "Error: page number must be a positive integer\nUsage: kosli list flows [flags]\n", }, { wantError: true, From adb09f0f79a00fdb683ed6e4677e36b3a5b74c2b Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Mon, 29 Jun 2026 23:05:05 +0100 Subject: [PATCH 2/3] feat: flows pagination alignement and feedabck --- cmd/kosli/listFlows.go | 48 +++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/cmd/kosli/listFlows.go b/cmd/kosli/listFlows.go index 0cb322e17..5253f03b5 100644 --- a/cmd/kosli/listFlows.go +++ b/cmd/kosli/listFlows.go @@ -14,7 +14,38 @@ import ( "github.com/spf13/cobra" ) -const listFlowsDesc = `List flows for an org.` +const listFlowsShortDesc = `List flows for an org. ` + +const listFlowsLongDesc = listFlowsShortDesc + `The results are paginated and ordered from latest to oldest. +By default, the page limit is 20 flows per page. +The list can be filtered by name with --name (and --ignore-case for case-insensitive matching).` + +const listFlowsExample = ` +# list the first page of flows (20 per page): +kosli list flows \ + --api-token yourAPIToken \ + --org yourOrgName + +# list the first 30 flows: +kosli list flows \ + --page-limit 30 \ + --api-token yourAPIToken \ + --org yourOrgName + +# show the second page of flows: +kosli list flows \ + --page-limit 30 \ + --page 2 \ + --api-token yourAPIToken \ + --org yourOrgName + +# list flows whose name contains "backend" (in JSON): +kosli list flows \ + --name backend \ + --api-token yourAPIToken \ + --org yourOrgName \ + --output json +` type listFlowsOptions struct { listOptions @@ -22,17 +53,14 @@ type listFlowsOptions struct { ignoreCase bool } -func (o *listFlowsOptions) validate(cmd *cobra.Command) error { - return o.listOptions.validate(cmd) -} - func newListFlowsCmd(out io.Writer) *cobra.Command { o := new(listFlowsOptions) cmd := &cobra.Command{ - Use: "flows", - Short: listFlowsDesc, - Long: listFlowsDesc, - Args: cobra.NoArgs, + Use: "flows", + Short: listFlowsShortDesc, + Long: listFlowsLongDesc, + Example: listFlowsExample, + Args: cobra.NoArgs, PreRunE: func(cmd *cobra.Command, args []string) error { err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) if err != nil { @@ -45,7 +73,7 @@ func newListFlowsCmd(out io.Writer) *cobra.Command { }, } - addListFlags(cmd, &o.listOptions) + addListFlags(cmd, &o.listOptions, 20) cmd.Flags().StringVarP(&o.name, "name", "N", "", searchByNameFlag) cmd.Flags().BoolVarP(&o.ignoreCase, "ignore-case", "i", false, ignoreCaseFlag) From 2717f06738be3f0d0b8d3e9df7c17bdbbfd78213 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Mon, 29 Jun 2026 23:11:55 +0100 Subject: [PATCH 3/3] feat: default doen't pagination flows --- cmd/kosli/listFlows.go | 58 ++++++++++++++++++++++++++----------- cmd/kosli/listFlows_test.go | 14 ++++----- 2 files changed, 48 insertions(+), 24 deletions(-) diff --git a/cmd/kosli/listFlows.go b/cmd/kosli/listFlows.go index 5253f03b5..79ce70b3a 100644 --- a/cmd/kosli/listFlows.go +++ b/cmd/kosli/listFlows.go @@ -16,23 +16,24 @@ import ( const listFlowsShortDesc = `List flows for an org. ` -const listFlowsLongDesc = listFlowsShortDesc + `The results are paginated and ordered from latest to oldest. -By default, the page limit is 20 flows per page. +const listFlowsLongDesc = listFlowsShortDesc + `By default, all flows for the org are returned. +Pass --page-limit and/or --page to paginate the results; when pagination is requested the output +includes pagination metadata and a page footer (table) or a "data"/"pagination" envelope (JSON). The list can be filtered by name with --name (and --ignore-case for case-insensitive matching).` const listFlowsExample = ` -# list the first page of flows (20 per page): +# list all flows for an org: kosli list flows \ --api-token yourAPIToken \ --org yourOrgName -# list the first 30 flows: +# list the first 30 flows (paginated): kosli list flows \ --page-limit 30 \ --api-token yourAPIToken \ --org yourOrgName -# show the second page of flows: +# show the second page of flows (30 per page): kosli list flows \ --page-limit 30 \ --page 2 \ @@ -51,6 +52,7 @@ type listFlowsOptions struct { listOptions name string ignoreCase bool + paginate bool } func newListFlowsCmd(out io.Writer) *cobra.Command { @@ -69,6 +71,10 @@ func newListFlowsCmd(out io.Writer) *cobra.Command { return o.validate(cmd) }, RunE: func(cmd *cobra.Command, args []string) error { + // pagination is opt-in: only when the user explicitly sets --page or + // --page-limit do we paginate. Otherwise all flows are returned (the + // historical behaviour), keeping output backwards compatible. + o.paginate = cmd.Flags().Changed("page") || cmd.Flags().Changed("page-limit") return o.run(out) }, } @@ -87,9 +93,12 @@ func (o *listFlowsOptions) run(out io.Writer) error { } params := url.Values{} - // sending per_page switches the endpoint to the paginated envelope response - params.Set("page", strconv.Itoa(o.pageNumber)) - params.Set("per_page", strconv.Itoa(o.pageLimit)) + if o.paginate { + // sending per_page switches the endpoint to the paginated {data, pagination} + // envelope; omitting it returns all flows as a plain array (the default) + params.Set("page", strconv.Itoa(o.pageNumber)) + params.Set("per_page", strconv.Itoa(o.pageLimit)) + } if o.name != "" { params.Set("search_by_name", o.name) // case_sensitive only affects search, so only send it alongside a search term @@ -97,7 +106,10 @@ func (o *listFlowsOptions) run(out io.Writer) error { params.Set("case_sensitive", "false") } } - reqURL := base + "?" + params.Encode() + reqURL := base + if encoded := params.Encode(); encoded != "" { + reqURL = base + "?" + encoded + } reqParams := &requests.RequestParams{ Method: http.MethodGet, @@ -122,12 +134,23 @@ type listFlowsResponse struct { } func printFlowsListAsTable(raw string, out io.Writer, page int) error { - response := &listFlowsResponse{} - err := json.Unmarshal([]byte(raw), response) - if err != nil { - return err + var flows []map[string]interface{} + var pagination *Pagination + + // The endpoint returns a plain array when unpaginated and a {data, pagination} + // envelope when paginated; handle both so the default behaviour is unchanged. + if strings.HasPrefix(strings.TrimSpace(raw), "[") { + if err := json.Unmarshal([]byte(raw), &flows); err != nil { + return err + } + } else { + response := &listFlowsResponse{} + if err := json.Unmarshal([]byte(raw), response); err != nil { + return err + } + flows = response.Data + pagination = &response.Pagination } - flows := response.Data if len(flows) == 0 { msg := "No flows were found" @@ -150,9 +173,10 @@ func printFlowsListAsTable(raw string, out io.Writer, page int) error { row := fmt.Sprintf("%s\t%s\t%s\t%s", flow["name"], flow["description"], flow["visibility"], tagsOutput) rows = append(rows, row) } - pagination := response.Pagination - paginationInfo := fmt.Sprintf("\nShowing page %.0f of %.0f, total %.0f items", pagination.Page, pagination.PageCount, pagination.Total) - rows = append(rows, paginationInfo) + if pagination != nil { + paginationInfo := fmt.Sprintf("\nShowing page %.0f of %.0f, total %.0f items", pagination.Page, pagination.PageCount, pagination.Total) + rows = append(rows, paginationInfo) + } tabFormattedPrint(out, header, rows) diff --git a/cmd/kosli/listFlows_test.go b/cmd/kosli/listFlows_test.go index c0f1a4319..2612060ce 100644 --- a/cmd/kosli/listFlows_test.go +++ b/cmd/kosli/listFlows_test.go @@ -48,37 +48,37 @@ func (suite *ListFlowsCommandTestSuite) TestListFlowsCmd() { { name: "listing flows with --output json works when there are flows", cmd: fmt.Sprintf(`list flows --output json %s`, suite.defaultKosliArguments), - goldenJson: []jsonCheck{{"data", "non-empty"}}, + goldenJson: []jsonCheck{{"", "non-empty"}}, // unpaginated default returns a plain array }, { name: "listing flows with --output json works when there are no flows", cmd: fmt.Sprintf(`list flows --output json %s`, suite.acmeOrgKosliArguments), - goldenJson: []jsonCheck{{"data", "[]"}}, + goldenJson: []jsonCheck{{"", "[]"}}, }, { name: "--name matches flows whose name contains the substring", cmd: fmt.Sprintf(`list flows --name list-flows-search-target --output json %s`, suite.defaultKosliArguments), - goldenJson: []jsonCheck{{"data", "non-empty"}, {"data.[0].name", "list-flows-search-target"}}, + goldenJson: []jsonCheck{{"", "non-empty"}, {"[0].name", "list-flows-search-target"}}, }, { name: "--name with no matching substring returns an empty list", cmd: fmt.Sprintf(`list flows --name no-such-flow-substring-xyz --output json %s`, suite.defaultKosliArguments), - goldenJson: []jsonCheck{{"data", "[]"}}, + goldenJson: []jsonCheck{{"", "[]"}}, }, { name: "--name matching is case sensitive by default so a wrong-case substring does not match", cmd: fmt.Sprintf(`list flows --name LIST-FLOWS-SEARCH-TARGET --output json %s`, suite.defaultKosliArguments), - goldenJson: []jsonCheck{{"data", "[]"}}, + goldenJson: []jsonCheck{{"", "[]"}}, }, { name: "--ignore-case makes a wrong-case substring match", cmd: fmt.Sprintf(`list flows --name LIST-FLOWS-SEARCH-TARGET --ignore-case --output json %s`, suite.defaultKosliArguments), - goldenJson: []jsonCheck{{"data", "non-empty"}, {"data.[0].name", "list-flows-search-target"}}, + goldenJson: []jsonCheck{{"", "non-empty"}, {"[0].name", "list-flows-search-target"}}, }, { name: "short flags -N and -i work like --name and --ignore-case", cmd: fmt.Sprintf(`list flows -N LIST-FLOWS-SEARCH-TARGET -i --output json %s`, suite.defaultKosliArguments), - goldenJson: []jsonCheck{{"data", "non-empty"}, {"data.[0].name", "list-flows-search-target"}}, + goldenJson: []jsonCheck{{"", "non-empty"}, {"[0].name", "list-flows-search-target"}}, }, { name: "pagination metadata is returned on page 1",