diff --git a/src/Services/BatchOperations.php b/src/Services/BatchOperations.php new file mode 100644 index 00000000..07b22c6a --- /dev/null +++ b/src/Services/BatchOperations.php @@ -0,0 +1,66 @@ +> + */ + public function batch(IssueCollection $issues, callable $operation, ?callable $progress = null): Collection + { + $results = new Collection; + $total = $issues->count(); + $current = 0; + + foreach ($issues as $issue) { + $current++; + + try { + $result = $operation($issue); + $results->push([ + 'issue' => $issue->number, + 'success' => true, + 'result' => $result, + ]); + } catch (Exception $e) { + $results->push([ + 'issue' => $issue->number, + 'success' => false, + 'error' => $e->getMessage(), + ]); + } + + if ($progress !== null) { + $progress($current, $total, $issue); + } + } + + return $results; + } + + /** + * Get a new IssueQuery instance. + */ + protected function query(): IssueQuery + { + return new IssueQuery($this->connector, $this->owner, $this->repo); + } +} diff --git a/src/Services/IssueQuery.php b/src/Services/IssueQuery.php index a9f90a04..aaddb478 100644 --- a/src/Services/IssueQuery.php +++ b/src/Services/IssueQuery.php @@ -7,8 +7,8 @@ use ConduitUi\GitHubConnector\Connector; use ConduitUI\Issue\Data\Issue; use ConduitUI\Issue\Requests\Issues\ListIssuesRequest; +use ConduitUI\Issue\Support\IssueCollection; use DateTime; -use Illuminate\Support\Collection; class IssueQuery { @@ -190,10 +190,8 @@ public function page(int $page): self /** * Execute the query and get all issues. - * - * @return \Illuminate\Support\Collection */ - public function get(): Collection + public function get(): IssueCollection { // Remove client-side filters before sending request $apiFilters = $this->filters; @@ -222,7 +220,7 @@ public function get(): Collection $collection = $collection->filter(fn (Issue $issue): bool => $issue->updatedAt <= $updatedBefore); } - return $collection; + return new IssueCollection($collection); } /** diff --git a/src/Support/IssueCollection.php b/src/Support/IssueCollection.php new file mode 100644 index 00000000..5721694e --- /dev/null +++ b/src/Support/IssueCollection.php @@ -0,0 +1,183 @@ + + */ +class IssueCollection extends Collection +{ + /** + * Filter issues by label. + */ + public function withLabel(string $label): self + { + return $this->filter(function (Issue $issue) use ($label): bool { + foreach ($issue->labels as $issueLabel) { + if ($issueLabel->name === $label) { + return true; + } + } + + return false; + }); + } + + /** + * Filter issues without label. + */ + public function withoutLabel(string $label): self + { + return $this->reject(function (Issue $issue) use ($label): bool { + foreach ($issue->labels as $issueLabel) { + if ($issueLabel->name === $label) { + return true; + } + } + + return false; + }); + } + + /** + * Filter open issues. + */ + public function open(): self + { + return $this->filter(fn (Issue $issue): bool => $issue->isOpen()); + } + + /** + * Filter closed issues. + */ + public function closed(): self + { + return $this->filter(fn (Issue $issue): bool => $issue->isClosed()); + } + + /** + * Filter assigned issues. + */ + public function assigned(): self + { + return $this->filter(fn (Issue $issue): bool => count($issue->assignees) > 0); + } + + /** + * Filter unassigned issues. + */ + public function unassigned(): self + { + return $this->filter(fn (Issue $issue): bool => count($issue->assignees) === 0); + } + + /** + * Group by label. + * + * @return Collection + */ + public function groupByLabel(): Collection + { + $grouped = new Collection; + + foreach ($this as $issue) { + foreach ($issue->labels as $label) { + if (! $grouped->has($label->name)) { + $grouped->put($label->name, new static); + } + $grouped->get($label->name)->push($issue); + } + } + + return $grouped; + } + + /** + * Group by state. + * + * @return Collection + */ + public function groupByState(): Collection + { + return $this->groupBy('state')->map(fn ($items) => new static($items)); + } + + /** + * Group by assignee. + * + * @return Collection + */ + public function groupByAssignee(): Collection + { + $grouped = new Collection; + + foreach ($this as $issue) { + if (count($issue->assignees) === 0) { + if (! $grouped->has('unassigned')) { + $grouped->put('unassigned', new static); + } + $grouped->get('unassigned')->push($issue); + } else { + foreach ($issue->assignees as $assignee) { + if (! $grouped->has($assignee->login)) { + $grouped->put($assignee->login, new static); + } + $grouped->get($assignee->login)->push($issue); + } + } + } + + return $grouped; + } + + /** + * Get statistics. + * + * @return array + */ + public function statistics(): array + { + $labels = collect(); + $assignees = collect(); + + foreach ($this as $issue) { + foreach ($issue->labels as $label) { + $labels->push($label->name); + } + + foreach ($issue->assignees as $assignee) { + $assignees->push($assignee->login); + } + } + + return [ + 'total' => $this->count(), + 'open' => $this->open()->count(), + 'closed' => $this->closed()->count(), + 'assigned' => $this->assigned()->count(), + 'unassigned' => $this->unassigned()->count(), + 'labels' => $labels->unique()->sort()->values()->all(), + 'assignees' => $assignees->unique()->sort()->values()->all(), + ]; + } + + /** + * Check if an issue has a specific label. + */ + protected function hasLabel(Issue $issue, string $label): bool + { + foreach ($issue->labels as $issueLabel) { + if ($issueLabel->name === $label) { + return true; + } + } + + return false; + } +} diff --git a/tests/Unit/Services/BatchOperationsTest.php b/tests/Unit/Services/BatchOperationsTest.php new file mode 100644 index 00000000..8560fb24 --- /dev/null +++ b/tests/Unit/Services/BatchOperationsTest.php @@ -0,0 +1,162 @@ +connector = new Connector('fake-token'); + $this->batchOps = new BatchOperations($this->connector, 'owner', 'repo'); + + $this->issues = new IssueCollection([ + Issue::fromArray(fullIssueResponse(['number' => 1, 'state' => 'open'])), + Issue::fromArray(fullIssueResponse(['number' => 2, 'state' => 'open'])), + Issue::fromArray(fullIssueResponse(['number' => 3, 'state' => 'open'])), + ]); + }); + + describe('Batch Processing', function () { + it('processes all issues in collection', function () { + $results = $this->batchOps->batch( + $this->issues, + fn ($issue) => $issue->number * 2 + ); + + expect($results)->toHaveCount(3) + ->and($results->get(0)['success'])->toBeTrue() + ->and($results->get(0)['result'])->toBe(2) + ->and($results->get(1)['result'])->toBe(4) + ->and($results->get(2)['result'])->toBe(6); + }); + + it('tracks issue numbers in results', function () { + $results = $this->batchOps->batch( + $this->issues, + fn ($issue) => 'processed' + ); + + expect($results->get(0)['issue'])->toBe(1) + ->and($results->get(1)['issue'])->toBe(2) + ->and($results->get(2)['issue'])->toBe(3); + }); + + it('handles exceptions gracefully', function () { + $results = $this->batchOps->batch( + $this->issues, + function ($issue) { + if ($issue->number === 2) { + throw new Exception('Processing failed'); + } + + return 'success'; + } + ); + + expect($results)->toHaveCount(3) + ->and($results->get(0)['success'])->toBeTrue() + ->and($results->get(1)['success'])->toBeFalse() + ->and($results->get(1)['error'])->toBe('Processing failed') + ->and($results->get(2)['success'])->toBeTrue(); + }); + + it('continues processing after exceptions', function () { + $results = $this->batchOps->batch( + $this->issues, + function ($issue) { + if ($issue->number === 1) { + throw new Exception('First failed'); + } + + return 'processed'; + } + ); + + expect($results)->toHaveCount(3) + ->and($results->get(0)['success'])->toBeFalse() + ->and($results->get(1)['success'])->toBeTrue() + ->and($results->get(2)['success'])->toBeTrue(); + }); + }); + + describe('Progress Tracking', function () { + it('calls progress callback with current count', function () { + $progressCalls = []; + + $this->batchOps->batch( + $this->issues, + fn ($issue) => 'processed', + function ($current, $total, $issue) use (&$progressCalls) { + $progressCalls[] = ['current' => $current, 'total' => $total, 'issue' => $issue->number]; + } + ); + + expect($progressCalls)->toHaveCount(3) + ->and($progressCalls[0]['current'])->toBe(1) + ->and($progressCalls[0]['total'])->toBe(3) + ->and($progressCalls[0]['issue'])->toBe(1) + ->and($progressCalls[1]['current'])->toBe(2) + ->and($progressCalls[2]['current'])->toBe(3); + }); + + it('works without progress callback', function () { + $results = $this->batchOps->batch( + $this->issues, + fn ($issue) => 'processed' + ); + + expect($results)->toHaveCount(3); + }); + }); + + describe('Empty Collections', function () { + it('handles empty collection', function () { + $emptyCollection = new IssueCollection([]); + + $results = $this->batchOps->batch( + $emptyCollection, + fn ($issue) => 'processed' + ); + + expect($results)->toHaveCount(0); + }); + + it('does not call progress callback for empty collection', function () { + $progressCalled = false; + + $this->batchOps->batch( + new IssueCollection([]), + fn ($issue) => 'processed', + function () use (&$progressCalled) { + $progressCalled = true; + } + ); + + expect($progressCalled)->toBeFalse(); + }); + }); + + describe('Complex Operations', function () { + it('handles complex operations with multiple steps', function () { + $results = $this->batchOps->batch( + $this->issues, + function ($issue) { + $result = [ + 'number' => $issue->number, + 'processed' => true, + 'timestamp' => time(), + ]; + + return $result; + } + ); + + expect($results)->toHaveCount(3) + ->and($results->get(0)['result']['processed'])->toBeTrue() + ->and($results->get(0)['result']['number'])->toBe(1); + }); + }); +}); diff --git a/tests/Unit/Services/IssueQueryTest.php b/tests/Unit/Services/IssueQueryTest.php index 30d7cc3c..d97a4b2d 100644 --- a/tests/Unit/Services/IssueQueryTest.php +++ b/tests/Unit/Services/IssueQueryTest.php @@ -5,6 +5,7 @@ use ConduitUi\GitHubConnector\Connector; use ConduitUI\Issue\Data\Issue; use ConduitUI\Issue\Services\IssueQuery; +use ConduitUI\Issue\Support\IssueCollection; use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; @@ -244,7 +245,7 @@ $issues = $this->query->get(); - expect($issues)->toBeInstanceOf(\Illuminate\Support\Collection::class) + expect($issues)->toBeInstanceOf(IssueCollection::class) ->and($issues)->toHaveCount(2); }); diff --git a/tests/Unit/Support/IssueCollectionTest.php b/tests/Unit/Support/IssueCollectionTest.php new file mode 100644 index 00000000..2686bc24 --- /dev/null +++ b/tests/Unit/Support/IssueCollectionTest.php @@ -0,0 +1,721 @@ +user = new User( + id: 1, + login: 'testuser', + avatarUrl: 'https://example.com/avatar.png', + htmlUrl: 'https://github.com/testuser', + type: 'User' + ); + + $this->bugLabel = new Label( + id: 1, + name: 'bug', + color: 'ff0000', + description: 'Bug report' + ); + + $this->featureLabel = new Label( + id: 2, + name: 'feature', + color: '00ff00', + description: 'Feature request' + ); + + $this->assignee1 = new User( + id: 2, + login: 'dev1', + avatarUrl: 'https://example.com/dev1.png', + htmlUrl: 'https://github.com/dev1', + type: 'User' + ); + + $this->assignee2 = new User( + id: 3, + login: 'dev2', + avatarUrl: 'https://example.com/dev2.png', + htmlUrl: 'https://github.com/dev2', + type: 'User' + ); + }); + + describe('Label Filtering', function () { + it('filters issues with a specific label', function () { + $issues = new IssueCollection([ + new Issue( + id: 1, + number: 1, + title: 'Bug Issue', + body: 'Description', + state: 'open', + locked: false, + assignees: [], + labels: [$this->bugLabel], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/1', + user: $this->user + ), + new Issue( + id: 2, + number: 2, + title: 'Feature Issue', + body: 'Description', + state: 'open', + locked: false, + assignees: [], + labels: [$this->featureLabel], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/2', + user: $this->user + ), + ]); + + $bugs = $issues->withLabel('bug'); + + expect($bugs)->toHaveCount(1) + ->and($bugs->first()->number)->toBe(1); + }); + + it('filters issues without a specific label', function () { + $issues = new IssueCollection([ + new Issue( + id: 1, + number: 1, + title: 'Bug Issue', + body: 'Description', + state: 'open', + locked: false, + assignees: [], + labels: [$this->bugLabel], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/1', + user: $this->user + ), + new Issue( + id: 2, + number: 2, + title: 'Feature Issue', + body: 'Description', + state: 'open', + locked: false, + assignees: [], + labels: [$this->featureLabel], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/2', + user: $this->user + ), + ]); + + $notBugs = $issues->withoutLabel('bug'); + + expect($notBugs)->toHaveCount(1) + ->and($notBugs->first()->number)->toBe(2); + }); + + it('handles issues with no labels', function () { + $issues = new IssueCollection([ + new Issue( + id: 1, + number: 1, + title: 'No Labels', + body: 'Description', + state: 'open', + locked: false, + assignees: [], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/1', + user: $this->user + ), + ]); + + expect($issues->withLabel('bug'))->toHaveCount(0) + ->and($issues->withoutLabel('bug'))->toHaveCount(1); + }); + }); + + describe('State Filtering', function () { + it('filters open issues', function () { + $issues = new IssueCollection([ + new Issue( + id: 1, + number: 1, + title: 'Open Issue', + body: 'Description', + state: 'open', + locked: false, + assignees: [], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/1', + user: $this->user + ), + new Issue( + id: 2, + number: 2, + title: 'Closed Issue', + body: 'Description', + state: 'closed', + locked: false, + assignees: [], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-02'), + closedAt: new DateTime('2024-01-02'), + htmlUrl: 'https://github.com/owner/repo/issues/2', + user: $this->user + ), + ]); + + $openIssues = $issues->open(); + + expect($openIssues)->toHaveCount(1) + ->and($openIssues->first()->state)->toBe('open'); + }); + + it('filters closed issues', function () { + $issues = new IssueCollection([ + new Issue( + id: 1, + number: 1, + title: 'Open Issue', + body: 'Description', + state: 'open', + locked: false, + assignees: [], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/1', + user: $this->user + ), + new Issue( + id: 2, + number: 2, + title: 'Closed Issue', + body: 'Description', + state: 'closed', + locked: false, + assignees: [], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-02'), + closedAt: new DateTime('2024-01-02'), + htmlUrl: 'https://github.com/owner/repo/issues/2', + user: $this->user + ), + ]); + + $closedIssues = $issues->closed(); + + expect($closedIssues)->toHaveCount(1) + ->and($closedIssues->first()->state)->toBe('closed'); + }); + }); + + describe('Assignee Filtering', function () { + it('filters assigned issues', function () { + $issues = new IssueCollection([ + new Issue( + id: 1, + number: 1, + title: 'Assigned Issue', + body: 'Description', + state: 'open', + locked: false, + assignees: [$this->assignee1], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/1', + user: $this->user, + assignee: $this->assignee1 + ), + new Issue( + id: 2, + number: 2, + title: 'Unassigned Issue', + body: 'Description', + state: 'open', + locked: false, + assignees: [], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/2', + user: $this->user + ), + ]); + + $assigned = $issues->assigned(); + + expect($assigned)->toHaveCount(1) + ->and($assigned->first()->assignees)->toHaveCount(1); + }); + + it('filters unassigned issues', function () { + $issues = new IssueCollection([ + new Issue( + id: 1, + number: 1, + title: 'Assigned Issue', + body: 'Description', + state: 'open', + locked: false, + assignees: [$this->assignee1], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/1', + user: $this->user, + assignee: $this->assignee1 + ), + new Issue( + id: 2, + number: 2, + title: 'Unassigned Issue', + body: 'Description', + state: 'open', + locked: false, + assignees: [], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/2', + user: $this->user + ), + ]); + + $unassigned = $issues->unassigned(); + + expect($unassigned)->toHaveCount(1) + ->and($unassigned->first()->assignees)->toHaveCount(0); + }); + }); + + describe('Grouping', function () { + it('groups by label', function () { + $issues = new IssueCollection([ + new Issue( + id: 1, + number: 1, + title: 'Bug Issue', + body: 'Description', + state: 'open', + locked: false, + assignees: [], + labels: [$this->bugLabel], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/1', + user: $this->user + ), + new Issue( + id: 2, + number: 2, + title: 'Feature Issue', + body: 'Description', + state: 'open', + locked: false, + assignees: [], + labels: [$this->featureLabel], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/2', + user: $this->user + ), + new Issue( + id: 3, + number: 3, + title: 'Another Bug', + body: 'Description', + state: 'open', + locked: false, + assignees: [], + labels: [$this->bugLabel], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/3', + user: $this->user + ), + ]); + + $grouped = $issues->groupByLabel(); + + expect($grouped)->toHaveCount(2) + ->and($grouped->get('bug'))->toHaveCount(2) + ->and($grouped->get('feature'))->toHaveCount(1); + }); + + it('groups by state', function () { + $issues = new IssueCollection([ + new Issue( + id: 1, + number: 1, + title: 'Open Issue 1', + body: 'Description', + state: 'open', + locked: false, + assignees: [], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/1', + user: $this->user + ), + new Issue( + id: 2, + number: 2, + title: 'Closed Issue', + body: 'Description', + state: 'closed', + locked: false, + assignees: [], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-02'), + closedAt: new DateTime('2024-01-02'), + htmlUrl: 'https://github.com/owner/repo/issues/2', + user: $this->user + ), + new Issue( + id: 3, + number: 3, + title: 'Open Issue 2', + body: 'Description', + state: 'open', + locked: false, + assignees: [], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/3', + user: $this->user + ), + ]); + + $grouped = $issues->groupByState(); + + expect($grouped)->toHaveCount(2) + ->and($grouped->get('open'))->toHaveCount(2) + ->and($grouped->get('closed'))->toHaveCount(1); + }); + + it('groups by assignee', function () { + $issues = new IssueCollection([ + new Issue( + id: 1, + number: 1, + title: 'Issue 1', + body: 'Description', + state: 'open', + locked: false, + assignees: [$this->assignee1], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/1', + user: $this->user, + assignee: $this->assignee1 + ), + new Issue( + id: 2, + number: 2, + title: 'Issue 2', + body: 'Description', + state: 'open', + locked: false, + assignees: [$this->assignee2], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/2', + user: $this->user, + assignee: $this->assignee2 + ), + new Issue( + id: 3, + number: 3, + title: 'Issue 3', + body: 'Description', + state: 'open', + locked: false, + assignees: [], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/3', + user: $this->user + ), + ]); + + $grouped = $issues->groupByAssignee(); + + expect($grouped)->toHaveCount(3) + ->and($grouped->get('dev1'))->toHaveCount(1) + ->and($grouped->get('dev2'))->toHaveCount(1) + ->and($grouped->get('unassigned'))->toHaveCount(1); + }); + + it('handles multiple assignees per issue', function () { + $issues = new IssueCollection([ + new Issue( + id: 1, + number: 1, + title: 'Multi-assigned Issue', + body: 'Description', + state: 'open', + locked: false, + assignees: [$this->assignee1, $this->assignee2], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/1', + user: $this->user, + assignee: $this->assignee1 + ), + ]); + + $grouped = $issues->groupByAssignee(); + + expect($grouped)->toHaveCount(2) + ->and($grouped->get('dev1'))->toHaveCount(1) + ->and($grouped->get('dev2'))->toHaveCount(1); + }); + }); + + describe('Statistics', function () { + it('calculates statistics', function () { + $issues = new IssueCollection([ + new Issue( + id: 1, + number: 1, + title: 'Open Bug', + body: 'Description', + state: 'open', + locked: false, + assignees: [$this->assignee1], + labels: [$this->bugLabel], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/1', + user: $this->user, + assignee: $this->assignee1 + ), + new Issue( + id: 2, + number: 2, + title: 'Closed Feature', + body: 'Description', + state: 'closed', + locked: false, + assignees: [$this->assignee2], + labels: [$this->featureLabel], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-02'), + closedAt: new DateTime('2024-01-02'), + htmlUrl: 'https://github.com/owner/repo/issues/2', + user: $this->user, + assignee: $this->assignee2, + closedBy: $this->assignee2 + ), + new Issue( + id: 3, + number: 3, + title: 'Unassigned', + body: 'Description', + state: 'open', + locked: false, + assignees: [], + labels: [], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/3', + user: $this->user + ), + ]); + + $stats = $issues->statistics(); + + expect($stats['total'])->toBe(3) + ->and($stats['open'])->toBe(2) + ->and($stats['closed'])->toBe(1) + ->and($stats['assigned'])->toBe(2) + ->and($stats['unassigned'])->toBe(1) + ->and($stats['labels'])->toBe(['bug', 'feature']) + ->and($stats['assignees'])->toBe(['dev1', 'dev2']); + }); + + it('handles empty collection', function () { + $issues = new IssueCollection([]); + + $stats = $issues->statistics(); + + expect($stats['total'])->toBe(0) + ->and($stats['open'])->toBe(0) + ->and($stats['closed'])->toBe(0) + ->and($stats['assigned'])->toBe(0) + ->and($stats['unassigned'])->toBe(0) + ->and($stats['labels'])->toBe([]) + ->and($stats['assignees'])->toBe([]); + }); + }); + + describe('Method Chaining', function () { + it('chains multiple filters', function () { + $issues = new IssueCollection([ + new Issue( + id: 1, + number: 1, + title: 'Open Bug Assigned', + body: 'Description', + state: 'open', + locked: false, + assignees: [$this->assignee1], + labels: [$this->bugLabel], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/1', + user: $this->user, + assignee: $this->assignee1 + ), + new Issue( + id: 2, + number: 2, + title: 'Open Bug Unassigned', + body: 'Description', + state: 'open', + locked: false, + assignees: [], + labels: [$this->bugLabel], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-01'), + closedAt: null, + htmlUrl: 'https://github.com/owner/repo/issues/2', + user: $this->user + ), + new Issue( + id: 3, + number: 3, + title: 'Closed Bug Assigned', + body: 'Description', + state: 'closed', + locked: false, + assignees: [$this->assignee2], + labels: [$this->bugLabel], + milestone: null, + comments: 0, + createdAt: new DateTime('2024-01-01'), + updatedAt: new DateTime('2024-01-02'), + closedAt: new DateTime('2024-01-02'), + htmlUrl: 'https://github.com/owner/repo/issues/3', + user: $this->user, + assignee: $this->assignee2, + closedBy: $this->assignee2 + ), + ]); + + $filtered = $issues + ->open() + ->withLabel('bug') + ->unassigned(); + + expect($filtered)->toHaveCount(1) + ->and($filtered->first()->number)->toBe(2); + }); + }); +});