Fix project list pagination - #220
Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Missing JSON pagination cursor
- Added
--output jsonsupport toprojects listthat returns a{projects, next_offset}envelope (omittingnext_offsetwhen absent) and wired the output flag through command input handling.
- Added
Or push these changes by commenting:
@cursor push 8b7d2640e7
Preview (8b7d2640e7)
diff --git a/cmd/projects.go b/cmd/projects.go
--- a/cmd/projects.go
+++ b/cmd/projects.go
@@ -2,6 +2,7 @@
import (
"context"
+ "encoding/json"
"fmt"
"net/http"
"strconv"
@@ -42,6 +43,7 @@
type ProjectsListInput struct {
Limit int
Offset int
+ Output string
}
type ProjectsCreateInput struct {
@@ -92,6 +94,9 @@
}
func (c ProjectsCmd) List(ctx context.Context, in ProjectsListInput) error {
+ if err := validateJSONOutput(in.Output); err != nil {
+ return err
+ }
if in.Limit < 1 || in.Limit > 100 {
return fmt.Errorf("--limit must be between 1 and 100")
}
@@ -108,13 +113,37 @@
return util.CleanedUpSdkError{Err: err}
}
- if projects == nil || len(projects.Items) == 0 {
+ items := []kernel.Project{}
+ if projects != nil {
+ items = projects.Items
+ }
+
+ nextOffset := projectListNextOffsetRaw(response)
+ if in.Output == "json" {
+ payload := struct {
+ Projects []kernel.Project `json:"projects"`
+ NextOffset string `json:"next_offset,omitempty"`
+ }{
+ Projects: items,
+ }
+ if nextOffset != "" {
+ payload.NextOffset = nextOffset
+ }
+ data, err := json.MarshalIndent(payload, "", " ")
+ if err != nil {
+ return err
+ }
+ fmt.Println(string(data))
+ return nil
+ }
+
+ if len(items) == 0 {
pterm.Info.Println("No projects found")
return nil
}
table := pterm.TableData{{"ID", "Name", "Status", "Created At", "idx"}}
- for i, p := range projects.Items {
+ for i, p := range items {
table = append(table, []string{
p.ID,
p.Name,
@@ -125,20 +154,24 @@
}
PrintTableNoPad(table, true)
- if nextOffset, ok := projectListNextOffset(response); ok {
+ if nextOffset, ok := projectListNextOffset(nextOffset); ok {
pterm.Warning.Printfln(
"Output truncated after index %d. Continue with: kernel projects list --limit %d --offset %d",
- in.Offset+len(projects.Items)-1, in.Limit, nextOffset,
+ in.Offset+len(items)-1, in.Limit, nextOffset,
)
}
return nil
}
-func projectListNextOffset(response *http.Response) (int, bool) {
+func projectListNextOffsetRaw(response *http.Response) string {
if response == nil {
- return 0, false
+ return ""
}
- nextOffset, err := strconv.Atoi(response.Header.Get("X-Next-Offset"))
+ return strings.TrimSpace(response.Header.Get("X-Next-Offset"))
+}
+
+func projectListNextOffset(nextOffsetRaw string) (int, bool) {
+ nextOffset, err := strconv.Atoi(nextOffsetRaw)
return nextOffset, err == nil && nextOffset > 0
}
@@ -362,7 +395,8 @@
c := getProjectsHandler(cmd)
limit, _ := cmd.Flags().GetInt("limit")
offset, _ := cmd.Flags().GetInt("offset")
- return c.List(cmd.Context(), ProjectsListInput{Limit: limit, Offset: offset})
+ output, _ := cmd.Flags().GetString("output")
+ return c.List(cmd.Context(), ProjectsListInput{Limit: limit, Offset: offset, Output: output})
}
func runProjectsCreate(cmd *cobra.Command, args []string) error {
@@ -518,6 +552,7 @@
func init() {
projectsListCmd.Flags().Int("limit", 100, "Maximum number of projects to return (1-100)")
projectsListCmd.Flags().Int("offset", 0, "Number of projects to skip (for pagination)")
+ addJSONOutputFlag(projectsListCmd)
projectsUpdateCmd.Flags().String("name", "", "New project name (1-255 characters)")
projectsUpdateCmd.Flags().String("status", "", "New project status: active or archived")
diff --git a/cmd/projects_test.go b/cmd/projects_test.go
--- a/cmd/projects_test.go
+++ b/cmd/projects_test.go
@@ -2,6 +2,7 @@
import (
"context"
+ "encoding/json"
"errors"
"net/http"
"testing"
@@ -103,6 +104,36 @@
assert.Contains(t, out, "21")
}
+func TestProjectsList_JSONOutputEnvelope(t *testing.T) {
+ fakeProjects := &FakeProjectsService{
+ ListFunc: func(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Project], error) {
+ return &pagination.OffsetPagination[kernel.Project]{
+ Items: []kernel.Project{
+ {ID: "proj_1", Name: "one", Status: kernel.ProjectStatusActive},
+ {ID: "proj_2", Name: "two", Status: kernel.ProjectStatusArchived},
+ },
+ }, nil
+ },
+ }
+ c := ProjectsCmd{projects: fakeProjects, limits: &FakeProjectLimitsService{}}
+
+ out := captureStdout(t, func() {
+ err := c.List(context.Background(), ProjectsListInput{Limit: 2, Offset: 0, Output: "json"})
+ assert.NoError(t, err)
+ })
+
+ var payload struct {
+ Projects []kernel.Project `json:"projects"`
+ NextOffset string `json:"next_offset"`
+ }
+ if !assert.NoError(t, json.Unmarshal([]byte(out), &payload)) {
+ return
+ }
+ assert.Len(t, payload.Projects, 2)
+ assert.Equal(t, "proj_1", payload.Projects[0].ID)
+ assert.Empty(t, payload.NextOffset)
+}
+
func TestProjectsList_RejectsInvalidPagination(t *testing.T) {
fakeProjects := &FakeProjectsService{
ListFunc: func(ctx context.Context, query kernel.ProjectListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.Project], error) {
@@ -123,13 +154,16 @@
func TestProjectListNextOffset(t *testing.T) {
response := &http.Response{Header: http.Header{"X-Next-Offset": []string{"120"}}}
- nextOffset, ok := projectListNextOffset(response)
+ nextOffsetRaw := projectListNextOffsetRaw(response)
+ assert.Equal(t, "120", nextOffsetRaw)
+
+ nextOffset, ok := projectListNextOffset(nextOffsetRaw)
assert.True(t, ok)
assert.Equal(t, 120, nextOffset)
- _, ok = projectListNextOffset(&http.Response{Header: http.Header{}})
+ _, ok = projectListNextOffset(projectListNextOffsetRaw(&http.Response{Header: http.Header{}}))
assert.False(t, ok)
- _, ok = projectListNextOffset(nil)
+ _, ok = projectListNextOffset(projectListNextOffsetRaw(nil))
assert.False(t, ok)
}You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 29d5d69. Configure here.
masnwilliams
left a comment
There was a problem hiding this comment.
requesting changes on three maintainability issues:
-
projectListNextOffsetcollapses a missing response, malformed/missing cursor, and the valid terminal cursor into the same "no more results" state. it also infershasMorefromX-Next-Offsetinstead of consumingX-Has-More. please parse both headers into one explicit pagination result and return an error whenhas_more=truelacks a positive valid cursor. otherwise incomplete output can be presented as complete. -
marshalProjectsListJSONdecomposes the page into per-itemRawJSONvalues and reconstructs an array that the SDK page already retains inprojects.RawJSON(). please preserve the page payload directly as a singlejson.RawMessage(with[]only for an absent/empty page), or put shared paginated-envelope handling inpkg/util/json.go. this deletes the loop and the silent{}fallback policy. -
the tests do not exercise the central integration path. the fake ignores
option.WithResponseInto, soProjectsCmd.Listnever receives pagination headers; removing the response option, warning, or JSON cursor wiring would leave the suite green. the index assertions are also vacuous because20and21already appear in the project IDs. please add anhttptestpath through the real SDK or make pagination metadata explicit in the service seam, and assert indexes using IDs/names that do not contain the expected values.
go test ./..., go vet ./..., and the targeted projects race tests pass. the file remains well below the 1k-line threshold; these are focused boundary, simplification, and coverage concerns rather than a broad file-size issue.
|
addressed all three review points in 17ae47b:
verified with |
masnwilliams
left a comment
There was a problem hiding this comment.
re-checked 17ae47b. all three requested changes are addressed: pagination metadata is validated explicitly, JSON preserves the SDK page payload directly, and the real-SDK httptest coverage exercises headers, continuation output, JSON cursors, and exact indexes. go test ./..., go vet ./..., and the targeted projects race tests pass.


Summary
--limitand--offsetflags and show absolute zero-based indexes in table outputReplays
idx, and exact continuation commandprojectsandnext_offsetTesting
go vet ./...go test ./...kernel projects list --limit 1Note
Low Risk
Read-only CLI listing changes with validation and tests; no auth or mutating API behavior.
Overview
kernel projects listnow sends explicitlimit(default 100, max 100) andoffsetto the API instead of inheriting a smaller server default, and readsX-Has-More/X-Next-Offsetfrom the HTTP response to drive continuation.Table output adds a zero-based
idxcolumn aligned with--offset, and when more pages exist the CLI prints a ready-to-runkernel projects list --limit … --offset …command.--output jsonreturns{ "projects": [...], "next_offset": <n> }(omittingnext_offseton the last page). Invalid flag ranges and inconsistent pagination headers fail fast with clear errors.README documents the new flags and JSON shape; tests cover integration, header parsing, and JSON marshaling for empty pages.
Reviewed by Cursor Bugbot for commit 17ae47b. Bugbot is set up for automated code reviews on this repo. Configure here.