fix(pagination): keep the cursor and filters when fetching the next page - #134
fix(pagination): keep the cursor and filters when fetching the next page#134pjcdawkins wants to merge 2 commits into
Conversation
The API list endpoints paginate with an opaque cursor in the _links.next URL, and that URL does not always repeat the parameters that were sent: the subscriptions endpoint drops a filter written as filter[status][value][], which is the form the CLI uses. The commands passed their query parameters again as Guzzle's "query" request option, which replaces a URL's query string rather than adding to it, so the cursor was discarded and the first page was fetched repeatedly. Loops bounded by a "next URL differs" check returned each item twice and stopped early: organization:subscription:list returned 100 rows with 50 distinct values for an organization with 89 subscriptions. Unbounded loops never terminated at all, for example organization:user:list when pagination is disabled in the configuration. PaginationUtil::nextPage() merges the next page URL's query with the current one, and returns null when the next request would repeat the one just made, so a loop cannot spin forever. organization:subscription:list and team:project:list also sent the page size as "range", which the API ignores; the parameter is "page[size]". That is why --count had no effect. organization:subscription:list now accepts up to the API maximum of 100, and --page walks the cursor to reach the requested page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes cursor-based pagination in the legacy PHP CLI by ensuring “next” links preserve the cursor and any prior query filters/sorts when fetching subsequent pages, and by preventing infinite loops caused by repeating requests. It centralizes the behavior in a new PaginationUtil::nextPage() helper and updates affected commands to use API-correct paging parameters.
Changes:
- Add
PaginationUtil::nextPage()to merge the current request query with the API-provided_links.nextquery (cursor), with a guard to avoid repeat requests. - Refactor multiple command pagination loops to route “next page” handling through
PaginationUtil. - Update affected commands to use
page[size](and cursor-walking for--page) instead of unsupported parameters likerange/page, and add PHPUnit coverage for the helper.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| legacy/src/Util/PaginationUtil.php | New helper to compute the next request (URL + merged query) for cursor pagination and prevent repeat-request loops. |
| legacy/tests/Util/PaginationUtilTest.php | Unit tests covering query merge behavior (filters/sorts), precedence, no-next-page, and repeat-request guard. |
| legacy/src/Command/Team/User/TeamUserListCommand.php | Use PaginationUtil for safe next-page traversal while listing team members. |
| legacy/src/Command/Team/TeamCreateCommand.php | Fix team label uniqueness check pagination by preserving cursor + query across pages. |
| legacy/src/Command/Team/TeamCommandBase.php | Update shared team/team-project loaders to follow next links without losing query/cursor and avoid infinite loops. |
| legacy/src/Command/Team/Project/TeamProjectListCommand.php | Use page[size] and PaginationUtil for stable paging and correct --count behavior. |
| legacy/src/Command/Team/Project/TeamProjectAddCommand.php | Ensure org-project lookup pagination preserves query/cursor across pages. |
| legacy/src/Command/Organization/User/OrganizationUserProjectsCommand.php | Use PaginationUtil for paging user extended-access results safely. |
| legacy/src/Command/Organization/User/OrganizationUserListCommand.php | Use PaginationUtil to preserve paging cursor and avoid repeated requests when loading org members. |
| legacy/src/Command/Organization/OrganizationSubscriptionListCommand.php | Correct subscription paging (page[size], cursor-walking for --page), and preserve filters across pages. |
| legacy/src/Command/Organization/OrganizationCommandBase.php | Use PaginationUtil in interactive member selection paging. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ->addOption('page', null, InputOption::VALUE_REQUIRED, 'Page number. This enables pagination, despite configuration or --count.') | ||
| ->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'The number of items to display per page. Use 0 to disable pagination. Ignored if --page is specified.'); | ||
| ->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'The number of items to display per page (max: ' . self::MAX_COUNT . '). Use 0 to disable pagination.'); |
There was a problem hiding this comment.
Right. Reworded to "despite the configuration or --count 0", which is what --page actually overrides: --count still sets the page size when --page is used.
--count still sets the page size when --page is used. Only "--count 0", which disables pagination, is overridden. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem
organization:subscription:listrepeats the first 50 projects regardless of--pageor--count. For an organization with 89 subscriptions,--count 0 --format tsv --no-headerreturned 100 rows containing 50 distinct values.Three defects, all verified against the API:
_links.nextURL (page[after]=…). The commands requested that URL but passed their query parameters again as Guzzle'squeryrequest option, which replaces a URL's query string rather than adding to it. The cursor was stripped and page 1 was fetched again.nextURL cannot simply be followed as-is. The subscriptions endpoint omits a filter written asfilter[status][value][], which is the form the CLI sends, so the two query strings have to be merged.rangeis not a real parameter. The page size parameter ispage[size](max 100 on this endpoint).rangewas silently ignored, which is why--counthad no effect.--page Nwas likewise sent as apagequery parameter that the endpoint does not accept.Defect 1 affects 10 loops across 9 files. Six are unbounded
while ($url)loops, so rather than duplicating items they never terminate —organization:user:listissued 183 identical requests in 30s with pagination disabled in the configuration, and never returned. Also affected:team:user:listandteam:project:listwith--count 0, the shared team and organization-member loaders,team:create's label uniqueness check, andteam:project:add's project lookup.Change
PaginationUtil::nextPage()merges the next page URL's query with the query used for the current page, and returnsnullwhen the next request would repeat the one just made, so a loop cannot spin forever. Every loop that follows anextlink with request options now goes through it.organization:subscription:listandteam:project:listsendpage[size]instead ofrange.--counton the subscription list accepts up to the API maximum of 100, and--count 0uses that page size.--page Nwalks the cursor to reach page N, which costs N sequential requests.Loops that already passed no request options are unchanged (
Api::listMembers(),TeamUserDeleteCommand,TeamListCommand::loadTeamsOnProject()), as isAccessApi, which usesCollection::fetchNextPage().Verification
make lintandmake testinlegacy/pass.PaginationUtilTestcovers filter and sort preservation, page size precedence, no next page, and the repeat-request guard. (WelcomeCommandTest::testWelcomeOnLocalContainerfails onmaintoo, for an unrelated missingdetection.git_remote_namedefault.)Against an organization with 89 subscriptions, 52 members and a team of 31 projects:
org:subs --count 0org:subs --page 1 -c 50/--page 2 -c 50org:subs -c 5org:userswith pagination disabledteam:projects -c 5team:projects --count 0Verbose output confirms that follow-up requests carry both
page[after]and thefilter[...]parameters, and that--page 3 -c 5makes exactly three requests with an advancing cursor.The multi-page paths in
team:createandteam:project:addwere not exercised live — reaching them needs an organization with more than 50 teams, or a write to a real team — but they use the same helper and loop shape as the paths above.🤖 Generated with Claude Code