From 5db1a6c4aab875a378e1a0ab025dc9a679b0b70d Mon Sep 17 00:00:00 2001 From: Thijs Kok Date: Wed, 22 Apr 2026 09:39:13 +0200 Subject: [PATCH 1/8] Added paginated response support for work items --- src/Actions/ManagesWorkItems.php | 49 ++++++--- .../LengthAwarePaginatedResponse.php | 104 ++++++++++++++++++ tests/WorkItemsTest.php | 58 +++++++--- 3 files changed, 179 insertions(+), 32 deletions(-) create mode 100644 src/Responses/LengthAwarePaginatedResponse.php diff --git a/src/Actions/ManagesWorkItems.php b/src/Actions/ManagesWorkItems.php index 41da692..28eb39c 100644 --- a/src/Actions/ManagesWorkItems.php +++ b/src/Actions/ManagesWorkItems.php @@ -5,6 +5,7 @@ use TestMonitor\DevOps\Builders\WIQL\WIQL; use TestMonitor\DevOps\Resources\WorkItem; use TestMonitor\DevOps\Transforms\TransformsWorkItems; +use TestMonitor\DevOps\Responses\LengthAwarePaginatedResponse; trait ManagesWorkItems { @@ -28,22 +29,29 @@ public function workitem(string $id, string $projectId): WorkItem } /** - * Get a list of work items. + * Get a paginated list of work items. * * @param string $projectId - * @param \TestMonitor\DevOps\Builders\WIQL $query + * @param \TestMonitor\DevOps\Builders\WIQL\WIQL|null $query * @param int $limit + * @param int $offset + * @param int $wiqlLimit Keep this under 20.000 to avoid API errors. * - * @throws \TestMonitor\DevOps\Exceptions\InvalidDataException * - * @return \TestMonitor\DevOps\Resources\WorkItem[] + * @throws \TestMonitor\DevOps\Exceptions\InvalidDataException + * @return \TestMonitor\DevOps\Responses\LengthAwarePaginatedResponse */ - public function workitems(string $projectId, ?WIQL $query = null, int $limit = 50): array - { - // Retrieve work items using WIQL + public function workitems( + string $projectId, + ?WIQL $query = null, + int $limit = 50, + int $offset = 0, + int $wiqlLimit = 10000 + ): LengthAwarePaginatedResponse { + // Retrieve all matching work item IDs via WIQL $results = $this->post("{$projectId}/_apis/wit/wiql", [ 'query' => [ - '$top' => $limit, + '$top' => $wiqlLimit, 'api-version' => $this->apiVersion, ], 'json' => [ @@ -51,24 +59,31 @@ public function workitems(string $projectId, ?WIQL $query = null, int $limit = 5 ], ]); - // Return an empty array when there are no results - if (empty($results['workItems'])) { - return []; - } + // Extract the IDs from the results + $ids = array_column($results['workItems'] ?? [], 'id'); - // Gather work item ID's - $ids = array_column($results['workItems'], 'id'); + // Slice the IDs for the requested page + $pageIds = array_slice($ids, $offset, $limit); - // Fetch work items by their ID's + if (empty($pageIds)) { + return new LengthAwarePaginatedResponse([], count($ids), $limit, $offset); + } + + // Fetch full work item details for this page only $response = $this->get("{$projectId}/_apis/wit/workitems/", [ 'query' => [ - 'ids' => implode(',', $ids), + 'ids' => implode(',', $pageIds), 'api-version' => $this->apiVersion, '$expand' => 'Links', ], ]); - return $this->fromDevOpsWorkItems($response['value']); + return new LengthAwarePaginatedResponse( + $this->fromDevOpsWorkItems($response['value']), + count($ids), + $limit, + $offset + ); } /** diff --git a/src/Responses/LengthAwarePaginatedResponse.php b/src/Responses/LengthAwarePaginatedResponse.php new file mode 100644 index 0000000..52899da --- /dev/null +++ b/src/Responses/LengthAwarePaginatedResponse.php @@ -0,0 +1,104 @@ +items = $items; + $this->total = $total; + $this->perPage = $perPage; + $this->offset = $offset; + } + + /** + * Get the items being paginated. + * + * @return array + */ + public function items(): array + { + return $this->items; + } + + /** + * Get the total number of items being paginated. + * + * @return int + */ + public function total(): int + { + return $this->total; + } + + /** + * Get the number of items shown per page. + * + * @return int + */ + public function perPage(): int + { + return $this->perPage; + } + + /** + * Get the current item offset. + * + * @return int + */ + public function offset(): int + { + return $this->offset; + } + + /** + * Determine the current page. + * + * @return int + */ + public function currentPage(): int + { + if ($this->offset === 0) { + return 1; + } + + return (int) floor($this->offset / $this->perPage) + 1; + } +} diff --git a/tests/WorkItemsTest.php b/tests/WorkItemsTest.php index 8c5441f..29482b5 100644 --- a/tests/WorkItemsTest.php +++ b/tests/WorkItemsTest.php @@ -14,6 +14,7 @@ use TestMonitor\DevOps\Exceptions\ValidationException; use TestMonitor\DevOps\Exceptions\FailedActionException; use TestMonitor\DevOps\Exceptions\UnauthorizedException; +use TestMonitor\DevOps\Responses\LengthAwarePaginatedResponse; class WorkItemsTest extends TestCase { @@ -141,7 +142,7 @@ public function it_should_return_a_list_of_work_items() $service->shouldReceive('request') ->once() - ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode(['workItems' => ['id' => $this->workItem['id']]]))); + ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode(['workItems' => [['id' => $this->workItem['id']]]]))); $service->shouldReceive('request') ->once() @@ -151,11 +152,11 @@ public function it_should_return_a_list_of_work_items() $workItems = $devops->workitems($this->project['id']); // Then - $this->assertIsArray($workItems); - $this->assertCount(1, $workItems); - $this->assertInstanceOf(WorkItem::class, $workItems[0]); - $this->assertEquals($this->workItem['id'], $workItems[0]->id); - $this->assertIsArray($workItems[0]->toArray()); + $this->assertInstanceOf(LengthAwarePaginatedResponse::class, $workItems); + $this->assertCount(1, $workItems->items()); + $this->assertInstanceOf(WorkItem::class, $workItems->items()[0]); + $this->assertEquals($this->workItem['id'], $workItems->items()[0]->id); + $this->assertIsArray($workItems->items()[0]->toArray()); } /** @test */ @@ -170,12 +171,12 @@ public function it_should_search_through_a_list_of_work_items_using_wiql() ->once() ->withArgs(function ($verb, $url, $options) { return isset($options['query']['$top'], $options['query']['api-version'], $options['json']) && - $options['query']['$top'] === 50 && + $options['query']['$top'] === 10000 && $options['json'] === [ 'query' => (new WIQL)->where(Field::STATE, Operator::EQUALS, 'New')->getQuery(), ]; }) - ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode(['workItems' => ['id' => $this->workItem['id']]]))); + ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode(['workItems' => [['id' => $this->workItem['id']]]]))); $service->shouldReceive('request') ->once() @@ -185,11 +186,38 @@ public function it_should_search_through_a_list_of_work_items_using_wiql() $workItems = $devops->workitems($this->project['id'], (new WIQL)->where(Field::STATE, Operator::EQUALS, 'New')); // Then - $this->assertIsArray($workItems); - $this->assertCount(1, $workItems); - $this->assertInstanceOf(WorkItem::class, $workItems[0]); - $this->assertEquals($this->workItem['id'], $workItems[0]->id); - $this->assertIsArray($workItems[0]->toArray()); + $this->assertInstanceOf(LengthAwarePaginatedResponse::class, $workItems); + $this->assertCount(1, $workItems->items()); + $this->assertInstanceOf(WorkItem::class, $workItems->items()[0]); + $this->assertEquals($this->workItem['id'], $workItems->items()[0]->id); + $this->assertIsArray($workItems->items()[0]->toArray()); + } + + /** @test */ + public function it_should_use_a_custom_wiql_limit_when_fetching_work_items() + { + // Given + $devops = new Client(['clientId' => 1, 'clientSecret' => 'secret', 'appId' => 1, 'redirectUrl' => 'none'], 'myorg', $this->token); + + $devops->setClient($service = Mockery::mock('\GuzzleHttp\Client')); + + $service->shouldReceive('request') + ->once() + ->withArgs(function ($verb, $url, $options) { + return isset($options['query']['$top']) && $options['query']['$top'] === 5000; + }) + ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode(['workItems' => [['id' => $this->workItem['id']]]]))); + + $service->shouldReceive('request') + ->once() + ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode(['value' => [$this->workItem]]))); + + // When + $workItems = $devops->workitems($this->project['id'], null, 50, 0, 5000); + + // Then + $this->assertInstanceOf(LengthAwarePaginatedResponse::class, $workItems); + $this->assertCount(1, $workItems->items()); } /** @test */ @@ -208,8 +236,8 @@ public function it_should_return_an_empty_work_item_list_when_there_are_no_resul $workItems = $devops->workitems($this->project['id'], (new WIQL)->where(Field::STATE, Operator::EQUALS, 'Closed')); // Then - $this->assertIsArray($workItems); - $this->assertCount(0, $workItems); + $this->assertInstanceOf(LengthAwarePaginatedResponse::class, $workItems); + $this->assertCount(0, $workItems->items()); } /** @test */ From 480e596b3df3a7454f71e36b3d85db07a59204ae Mon Sep 17 00:00:00 2001 From: Thijs Kok Date: Wed, 22 Apr 2026 09:41:22 +0200 Subject: [PATCH 2/8] StyleCI fix --- src/Actions/ManagesWorkItems.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Actions/ManagesWorkItems.php b/src/Actions/ManagesWorkItems.php index 28eb39c..1d68734 100644 --- a/src/Actions/ManagesWorkItems.php +++ b/src/Actions/ManagesWorkItems.php @@ -37,7 +37,6 @@ public function workitem(string $id, string $projectId): WorkItem * @param int $offset * @param int $wiqlLimit Keep this under 20.000 to avoid API errors. * - * * @throws \TestMonitor\DevOps\Exceptions\InvalidDataException * @return \TestMonitor\DevOps\Responses\LengthAwarePaginatedResponse */ From 9a02314d08e7b532cf0bc0089f18db87aa645346 Mon Sep 17 00:00:00 2001 From: Thijs Kok Date: Wed, 22 Apr 2026 09:50:06 +0200 Subject: [PATCH 3/8] StyleCI fix --- src/Actions/ManagesWorkItems.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Actions/ManagesWorkItems.php b/src/Actions/ManagesWorkItems.php index 1d68734..a026e2e 100644 --- a/src/Actions/ManagesWorkItems.php +++ b/src/Actions/ManagesWorkItems.php @@ -38,6 +38,7 @@ public function workitem(string $id, string $projectId): WorkItem * @param int $wiqlLimit Keep this under 20.000 to avoid API errors. * * @throws \TestMonitor\DevOps\Exceptions\InvalidDataException + * * @return \TestMonitor\DevOps\Responses\LengthAwarePaginatedResponse */ public function workitems( From f3c573e18b90ecae14297a048fe67535153fca62 Mon Sep 17 00:00:00 2001 From: Thijs Kok Date: Wed, 22 Apr 2026 10:20:32 +0200 Subject: [PATCH 4/8] Improved test coverage --- src/Actions/ManagesWorkItems.php | 4 +- .../LengthAwarePaginatedResponse.php | 2 +- tests/WorkItemsTest.php | 39 ++++++++++++++++++- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/Actions/ManagesWorkItems.php b/src/Actions/ManagesWorkItems.php index a026e2e..394767e 100644 --- a/src/Actions/ManagesWorkItems.php +++ b/src/Actions/ManagesWorkItems.php @@ -35,7 +35,7 @@ public function workitem(string $id, string $projectId): WorkItem * @param \TestMonitor\DevOps\Builders\WIQL\WIQL|null $query * @param int $limit * @param int $offset - * @param int $wiqlLimit Keep this under 20.000 to avoid API errors. + * @param int $wiqlLimit Keep this under 20,000 to avoid API errors. * * @throws \TestMonitor\DevOps\Exceptions\InvalidDataException * @@ -46,7 +46,7 @@ public function workitems( ?WIQL $query = null, int $limit = 50, int $offset = 0, - int $wiqlLimit = 10000 + int $wiqlLimit = 1000 ): LengthAwarePaginatedResponse { // Retrieve all matching work item IDs via WIQL $results = $this->post("{$projectId}/_apis/wit/wiql", [ diff --git a/src/Responses/LengthAwarePaginatedResponse.php b/src/Responses/LengthAwarePaginatedResponse.php index 52899da..8fbba66 100644 --- a/src/Responses/LengthAwarePaginatedResponse.php +++ b/src/Responses/LengthAwarePaginatedResponse.php @@ -95,7 +95,7 @@ public function offset(): int */ public function currentPage(): int { - if ($this->offset === 0) { + if ($this->offset === 0 || $this->perPage === 0) { return 1; } diff --git a/tests/WorkItemsTest.php b/tests/WorkItemsTest.php index 29482b5..6928f60 100644 --- a/tests/WorkItemsTest.php +++ b/tests/WorkItemsTest.php @@ -157,6 +157,10 @@ public function it_should_return_a_list_of_work_items() $this->assertInstanceOf(WorkItem::class, $workItems->items()[0]); $this->assertEquals($this->workItem['id'], $workItems->items()[0]->id); $this->assertIsArray($workItems->items()[0]->toArray()); + $this->assertEquals(1, $workItems->total()); + $this->assertEquals(50, $workItems->perPage()); + $this->assertEquals(0, $workItems->offset()); + $this->assertEquals(1, $workItems->currentPage()); } /** @test */ @@ -171,7 +175,7 @@ public function it_should_search_through_a_list_of_work_items_using_wiql() ->once() ->withArgs(function ($verb, $url, $options) { return isset($options['query']['$top'], $options['query']['api-version'], $options['json']) && - $options['query']['$top'] === 10000 && + $options['query']['$top'] === 1000 && $options['json'] === [ 'query' => (new WIQL)->where(Field::STATE, Operator::EQUALS, 'New')->getQuery(), ]; @@ -191,6 +195,39 @@ public function it_should_search_through_a_list_of_work_items_using_wiql() $this->assertInstanceOf(WorkItem::class, $workItems->items()[0]); $this->assertEquals($this->workItem['id'], $workItems->items()[0]->id); $this->assertIsArray($workItems->items()[0]->toArray()); + $this->assertEquals(1, $workItems->total()); + $this->assertEquals(50, $workItems->perPage()); + $this->assertEquals(0, $workItems->offset()); + $this->assertEquals(1, $workItems->currentPage()); + } + + /** @test */ + public function it_should_return_correct_pagination_metadata_for_a_work_item_list_with_a_given_limit_and_offset() + { + // Given + $devops = new Client(['clientId' => 1, 'clientSecret' => 'secret', 'appId' => 1, 'redirectUrl' => 'none'], 'myorg', $this->token); + + $devops->setClient($service = Mockery::mock('\GuzzleHttp\Client')); + + $ids = array_map(fn ($i) => ['id' => $i], range(1, 25)); + + $service->shouldReceive('request') + ->once() + ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode(['workItems' => $ids]))); + + $service->shouldReceive('request') + ->once() + ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode(['value' => [$this->workItem]]))); + + // When + $workItems = $devops->workitems($this->project['id'], null, 10, 20); + + // Then + $this->assertInstanceOf(LengthAwarePaginatedResponse::class, $workItems); + $this->assertEquals(25, $workItems->total()); + $this->assertEquals(10, $workItems->perPage()); + $this->assertEquals(20, $workItems->offset()); + $this->assertEquals(3, $workItems->currentPage()); } /** @test */ From 23a6ee9461d05c839d035020b5b19c7d250b376c Mon Sep 17 00:00:00 2001 From: Thijs Kok Date: Wed, 22 Apr 2026 10:41:55 +0200 Subject: [PATCH 5/8] Added more examples --- README.md | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5ddcf6f..ae0c7da 100644 --- a/README.md +++ b/README.md @@ -90,13 +90,39 @@ The new token will be valid again for the next hour. ## Examples -Get a list of Azure DevOps accounts: +Retrieve a list of Azure DevOps accounts: ```php $accounts = $devops->accounts(); ``` -Or creating a work item, for example (using a work item type 'Bug' and example project with id 12345): +Retrieve a paginated list of work items for a project: + +```php +$workItems = $devops->workitems('12345'); + +foreach ($workItems->items() as $workItem) { + echo $workItem->title; +} + +echo $workItems->total() . ' work items found'; +``` + +Use a WIQL query to filter results, and control pagination and the number of results fetched: + +```php +$query = (new \TestMonitor\DevOps\Builders\WIQL\WIQL) + ->where(\TestMonitor\DevOps\Builders\WIQL\Field::STATE, \TestMonitor\DevOps\Builders\WIQL\Operator::EQUALS, 'New'); + +$workItems = $devops->workitems( + projectId: '12345', + query: $query, + limit: 25, + offset: 50 +); +``` + +Create a work item using a work item type 'Bug' and example project with id 12345: ```php $workItem = $devops->createWorkItem(new \TestMonitor\DevOps\Resources\WorkItem([ From ab127869c1504e26d182c4b5b1b2804cf2fe1b86 Mon Sep 17 00:00:00 2001 From: Thijs Kok Date: Wed, 22 Apr 2026 11:22:51 +0200 Subject: [PATCH 6/8] Enhance WorkItemsTest for pagination --- tests/WorkItemsTest.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/WorkItemsTest.php b/tests/WorkItemsTest.php index 6928f60..a072145 100644 --- a/tests/WorkItemsTest.php +++ b/tests/WorkItemsTest.php @@ -210,6 +210,7 @@ public function it_should_return_correct_pagination_metadata_for_a_work_item_lis $devops->setClient($service = Mockery::mock('\GuzzleHttp\Client')); $ids = array_map(fn ($i) => ['id' => $i], range(1, 25)); + $pageWorkItems = array_map(fn ($i) => array_merge($this->workItem, ['id' => $i]), range(21, 25)); $service->shouldReceive('request') ->once() @@ -217,13 +218,17 @@ public function it_should_return_correct_pagination_metadata_for_a_work_item_lis $service->shouldReceive('request') ->once() - ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode(['value' => [$this->workItem]]))); + ->withArgs(function ($verb, $url, $options) { + return isset($options['query']['ids']) && $options['query']['ids'] === '21,22,23,24,25'; + }) + ->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode(['value' => $pageWorkItems]))); // When $workItems = $devops->workitems($this->project['id'], null, 10, 20); // Then $this->assertInstanceOf(LengthAwarePaginatedResponse::class, $workItems); + $this->assertCount(5, $workItems->items()); $this->assertEquals(25, $workItems->total()); $this->assertEquals(10, $workItems->perPage()); $this->assertEquals(20, $workItems->offset()); From b9edc4fe2c82ac7e6a644c414e887401de12bbbe Mon Sep 17 00:00:00 2001 From: Thijs Kok Date: Wed, 22 Apr 2026 11:22:59 +0200 Subject: [PATCH 7/8] Add note about total() method limit in README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ae0c7da..a10725e 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ foreach ($workItems->items() as $workItem) { echo $workItems->total() . ' work items found'; ``` +> **Note:** `total()` is capped by `$wiqlLimit` (default: 1000). Raise it if you expect more results. + Use a WIQL query to filter results, and control pagination and the number of results fetched: ```php From 2e8ffe0d5b045e227d6b8268d20d7e7fa2f9890e Mon Sep 17 00:00:00 2001 From: Thijs Kok Date: Wed, 22 Apr 2026 11:39:31 +0200 Subject: [PATCH 8/8] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a10725e..ea3581c 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,8 @@ $workItems = $devops->workitems( projectId: '12345', query: $query, limit: 25, - offset: 50 + offset: 50, + wiqlLimit: 5000 ); ```