Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,42 @@ 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';
```
Comment thread
thijskok marked this conversation as resolved.

> **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
$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,
wiqlLimit: 5000
);
```

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([
Expand Down
47 changes: 31 additions & 16 deletions src/Actions/ManagesWorkItems.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -28,47 +29,61 @@ 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[]
* @return \TestMonitor\DevOps\Responses\LengthAwarePaginatedResponse
*/
Comment thread
thijskok marked this conversation as resolved.
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 = 1000
): LengthAwarePaginatedResponse {
Comment thread
thijskok marked this conversation as resolved.
// Retrieve all matching work item IDs via WIQL
$results = $this->post("{$projectId}/_apis/wit/wiql", [
'query' => [
'$top' => $limit,
'$top' => $wiqlLimit,
'api-version' => $this->apiVersion,
Comment thread
thijskok marked this conversation as resolved.
Comment thread
thijskok marked this conversation as resolved.
],
'json' => [
'query' => $query instanceof WIQL ? $query->getQuery() : (new WIQL)->getQuery(),
],
]);

// 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);
}
Comment thread
thijskok marked this conversation as resolved.

// 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
);
}

/**
Expand Down
104 changes: 104 additions & 0 deletions src/Responses/LengthAwarePaginatedResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

namespace TestMonitor\DevOps\Responses;

class LengthAwarePaginatedResponse
{
/**
* The items being paginated.
*
* @var array
*/
protected array $items;

/**
* The total number of items being paginated.
*
* @var int
*/
protected int $total;

/**
* The number of items shown per page.
*
* @var int
*/
protected int $perPage;

/**
* The current item offset.
*
* @var int
*/
protected int $offset;

/**
* Create a new paginated response instance.
*
* @param array $items
* @param int $total
* @param int $perPage
* @param int $offset
*/
public function __construct(array $items, int $total, int $perPage, int $offset = 0)
{
$this->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 || $this->perPage === 0) {
return 1;
}

return (int) floor($this->offset / $this->perPage) + 1;
}
Comment thread
thijskok marked this conversation as resolved.
Comment thread
thijskok marked this conversation as resolved.
}
100 changes: 85 additions & 15 deletions tests/WorkItemsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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()
Expand All @@ -151,11 +152,15 @@ 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());
$this->assertEquals(1, $workItems->total());
$this->assertEquals(50, $workItems->perPage());
$this->assertEquals(0, $workItems->offset());
$this->assertEquals(1, $workItems->currentPage());
}

/** @test */
Expand All @@ -170,12 +175,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'] === 1000 &&
$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()
Expand All @@ -185,11 +190,76 @@ 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());
$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));
$pageWorkItems = array_map(fn ($i) => array_merge($this->workItem, ['id' => $i]), range(21, 25));

$service->shouldReceive('request')
->once()
->andReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode(['workItems' => $ids])));

$service->shouldReceive('request')
->once()
->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());
$this->assertEquals(3, $workItems->currentPage());
}

/** @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 */
Expand All @@ -208,8 +278,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 */
Expand Down