From a4598220dabaa5866e550635dd12bfbec0fa58b6 Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Fri, 19 Dec 2025 22:56:05 -0700 Subject: [PATCH] feat: implement PullRequestQuery builder with fluent API Enhanced QueryBuilder with comprehensive filtering and querying capabilities: - Added label filtering: whereLabel(), whereLabels() (match any), whereAllLabels() (match all) - Added execution methods: exists(), pluck() - Added state filter aliases: whereOpen(), whereClosed(), whereState(), whereAuthor() - Added ordering shortcuts: orderByCreated(), orderByUpdated(), orderByPopularity(), orderByLongRunning() - Added pagination alias: perPage() - Added repository alias: repo() - Implemented client-side filtering for whereAllLabels() All methods support fluent chaining and maintain existing QueryBuilder patterns. 100% test coverage with 20 new tests covering all enhanced features. --- src/QueryBuilder.php | 127 +++++++++ tests/Unit/QueryBuilderEnhancedTest.php | 337 ++++++++++++++++++++++++ 2 files changed, 464 insertions(+) create mode 100644 tests/Unit/QueryBuilderEnhancedTest.php diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 960c99a..1268d1b 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -75,6 +75,38 @@ public function label(string $label): self return $this; } + /** + * Alias for label() + */ + public function whereLabel(string $label): self + { + return $this->label($label); + } + + /** + * Filter by multiple labels (match any) + * + * @param array $labels + */ + public function whereLabels(array $labels): self + { + $this->filters['labels'] = implode(',', $labels); + + return $this; + } + + /** + * Filter by multiple labels (match all) - client-side filter + * + * @param array $labels + */ + public function whereAllLabels(array $labels): self + { + $this->filters['_all_labels'] = $labels; + + return $this; + } + public function whereMerged(): self { // Note: GitHub API doesn't have a direct 'merged' filter @@ -192,6 +224,21 @@ public function get(): array $results = array_filter($results, fn (PullRequest $pr) => $pr->data->isDraft()); } + if (isset($clientSideFilters['_all_labels'])) { + $requiredLabels = $clientSideFilters['_all_labels']; + $results = array_filter($results, function (PullRequest $pr) use ($requiredLabels): bool { + $prLabels = array_map(fn ($label) => $label->name ?? '', $pr->data->labels ?? []); // @phpstan-ignore-line + + foreach ($requiredLabels as $required) { + if (! in_array($required, $prLabels, true)) { + return false; + } + } + + return true; + }); + } + return array_values($results); } @@ -206,4 +253,84 @@ public function count(): int { return count($this->get()); } + + public function exists(): bool + { + return $this->count() > 0; + } + + /** + * Pluck a single column from results + * + * @return array + */ + public function pluck(string $key): array + { + return array_map( + fn (PullRequest $pr) => $pr->data->{$key} ?? null, // @phpstan-ignore-line + $this->get() + ); + } + + /** + * Alias methods for state filters + */ + public function whereOpen(): self + { + return $this->open(); + } + + public function whereClosed(): self + { + return $this->closed(); + } + + public function whereState(string $state): self + { + return $this->state($state); + } + + public function whereAuthor(string $author): self + { + return $this->author($author); + } + + /** + * Ordering shortcuts + */ + public function orderByCreated(string $direction = 'desc'): self + { + return $this->orderBy('created', $direction); + } + + public function orderByUpdated(string $direction = 'desc'): self + { + return $this->orderBy('updated', $direction); + } + + public function orderByPopularity(): self + { + return $this->orderBy('popularity', 'desc'); + } + + public function orderByLongRunning(): self + { + return $this->orderBy('long-running', 'asc'); + } + + /** + * Pagination shortcuts + */ + public function perPage(int $count): self + { + return $this->take($count); + } + + /** + * Alias for repository() + */ + public function repo(string $repository): self + { + return $this->repository($repository); + } } diff --git a/tests/Unit/QueryBuilderEnhancedTest.php b/tests/Unit/QueryBuilderEnhancedTest.php new file mode 100644 index 0000000..c1736e4 --- /dev/null +++ b/tests/Unit/QueryBuilderEnhancedTest.php @@ -0,0 +1,337 @@ +> $data + */ + public function __construct(private array $data) + { + // Skip parent constructor + } + + public function json(string|int|null $key = null, mixed $default = null): mixed + { + return $this->data; + } +} + +class EnhancedQueryBuilderTestConnector extends Connector +{ + /** + * @var array> + */ + protected array $mockResponse = []; + + /** + * @param array> $response + */ + public function __construct(array $response = []) + { + parent::__construct('test-token'); + $this->mockResponse = $response; + } + + public function send(Request $request, ...$args): Response + { + return new EnhancedQueryBuilderMockResponse($this->mockResponse); + } +} + +function createEnhancedConnector(array $response = []): Connector +{ + return new EnhancedQueryBuilderTestConnector($response); +} + +function createEnhancedMockPrData(array $overrides = []): array +{ + $defaults = [ + 'number' => 1, + 'title' => 'Test PR', + 'body' => 'Test body', + 'state' => 'open', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'html_url' => 'https://github.com/owner/repo/pull/1', + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2025-01-01T00:00:00Z', + 'draft' => false, + 'labels' => [], + 'head' => [ + 'ref' => 'feature', + 'sha' => 'abc123', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'repo' => [ + 'id' => 1, + 'name' => 'repo', + 'full_name' => 'owner/repo', + 'html_url' => 'https://github.com/owner/repo', + 'private' => false, + ], + ], + 'base' => [ + 'ref' => 'main', + 'sha' => 'def456', + 'user' => [ + 'id' => 1, + 'login' => 'testuser', + 'avatar_url' => 'https://example.com/avatar.jpg', + 'html_url' => 'https://github.com/testuser', + 'type' => 'User', + ], + 'repo' => [ + 'id' => 1, + 'name' => 'repo', + 'full_name' => 'owner/repo', + 'html_url' => 'https://github.com/owner/repo', + 'private' => false, + ], + ], + ]; + + return array_merge($defaults, $overrides); +} + +describe('QueryBuilder enhanced features', function (): void { + it('can use repo() alias for repository()', function () { + $connector = createEnhancedConnector([createEnhancedMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repo('owner/repo'); + + expect($result)->toBeInstanceOf(QueryBuilder::class); + }); + + it('can use whereLabel() alias', function () { + $connector = createEnhancedConnector([createEnhancedMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->whereLabel('bug'); + + expect($result)->toBeInstanceOf(QueryBuilder::class); + }); + + it('can filter by multiple labels (match any)', function () { + $connector = createEnhancedConnector([createEnhancedMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->whereLabels(['bug', 'feature']); + + expect($result)->toBeInstanceOf(QueryBuilder::class); + }); + + it('can filter by all labels (match all)', function () { + $prWithLabels = createEnhancedMockPrData([ + 'labels' => [ + ['id' => 1, 'name' => 'bug', 'color' => 'red', 'description' => null], + ['id' => 2, 'name' => 'priority', 'color' => 'yellow', 'description' => null], + ], + ]); + + $connector = createEnhancedConnector([$prWithLabels]); + $builder = new QueryBuilder($connector); + + $results = $builder->repository('owner/repo')->whereAllLabels(['bug', 'priority'])->get(); + + expect($results)->toBeArray() + ->and($results)->toHaveCount(1); + }); + + it('filters out PRs that do not have all required labels', function () { + $prWithSomeLabels = createEnhancedMockPrData([ + 'number' => 1, + 'labels' => [ + ['id' => 1, 'name' => 'bug', 'color' => 'red', 'description' => null], + ], + ]); + + $prWithAllLabels = createEnhancedMockPrData([ + 'number' => 2, + 'labels' => [ + ['id' => 1, 'name' => 'bug', 'color' => 'red', 'description' => null], + ['id' => 2, 'name' => 'priority', 'color' => 'yellow', 'description' => null], + ], + ]); + + $connector = createEnhancedConnector([$prWithSomeLabels, $prWithAllLabels]); + $builder = new QueryBuilder($connector); + + $results = $builder->repository('owner/repo')->whereAllLabels(['bug', 'priority'])->get(); + + expect($results)->toBeArray() + ->and($results)->toHaveCount(1) + ->and($results[0]->data->number)->toBe(2); + }); + + it('can check if results exist', function () { + $connector = createEnhancedConnector([createEnhancedMockPrData()]); + $builder = new QueryBuilder($connector); + + $exists = $builder->repository('owner/repo')->exists(); + + expect($exists)->toBeTrue(); + }); + + it('returns false when no results exist', function () { + $connector = createEnhancedConnector([]); + $builder = new QueryBuilder($connector); + + $exists = $builder->repository('owner/repo')->exists(); + + expect($exists)->toBeFalse(); + }); + + it('can pluck a specific field from results', function () { + $pr1 = createEnhancedMockPrData(['number' => 1, 'title' => 'First PR']); + $pr2 = createEnhancedMockPrData(['number' => 2, 'title' => 'Second PR']); + + $connector = createEnhancedConnector([$pr1, $pr2]); + $builder = new QueryBuilder($connector); + + $titles = $builder->repository('owner/repo')->pluck('title'); + + expect($titles)->toBeArray() + ->and($titles)->toHaveCount(2) + ->and($titles[0])->toBe('First PR') + ->and($titles[1])->toBe('Second PR'); + }); + + it('can use whereOpen() alias', function () { + $connector = createEnhancedConnector([createEnhancedMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->whereOpen(); + + expect($result)->toBeInstanceOf(QueryBuilder::class); + }); + + it('can use whereClosed() alias', function () { + $connector = createEnhancedConnector([createEnhancedMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->whereClosed(); + + expect($result)->toBeInstanceOf(QueryBuilder::class); + }); + + it('can use whereState() alias', function () { + $connector = createEnhancedConnector([createEnhancedMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->whereState('all'); + + expect($result)->toBeInstanceOf(QueryBuilder::class); + }); + + it('can use whereAuthor() alias', function () { + $connector = createEnhancedConnector([createEnhancedMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->whereAuthor('testuser'); + + expect($result)->toBeInstanceOf(QueryBuilder::class); + }); + + it('can order by created date', function () { + $connector = createEnhancedConnector([createEnhancedMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->orderByCreated('asc'); + + expect($result)->toBeInstanceOf(QueryBuilder::class); + }); + + it('can order by updated date', function () { + $connector = createEnhancedConnector([createEnhancedMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->orderByUpdated('desc'); + + expect($result)->toBeInstanceOf(QueryBuilder::class); + }); + + it('can order by popularity', function () { + $connector = createEnhancedConnector([createEnhancedMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->orderByPopularity(); + + expect($result)->toBeInstanceOf(QueryBuilder::class); + }); + + it('can order by long running', function () { + $connector = createEnhancedConnector([createEnhancedMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->orderByLongRunning(); + + expect($result)->toBeInstanceOf(QueryBuilder::class); + }); + + it('can use perPage() alias for take()', function () { + $connector = createEnhancedConnector([createEnhancedMockPrData()]); + $builder = new QueryBuilder($connector); + + $result = $builder->repository('owner/repo')->perPage(50); + + expect($result)->toBeInstanceOf(QueryBuilder::class); + }); + + it('can chain complex query with new methods', function () { + $connector = createEnhancedConnector([createEnhancedMockPrData()]); + $builder = new QueryBuilder($connector); + + $results = $builder + ->repo('owner/repo') + ->whereOpen() + ->whereAuthor('testuser') + ->whereLabels(['bug', 'priority']) + ->orderByUpdated('desc') + ->perPage(25) + ->get(); + + expect($results)->toBeArray(); + }); + + it('returns empty array when plucking from no results', function () { + $connector = createEnhancedConnector([]); + $builder = new QueryBuilder($connector); + + $titles = $builder->repository('owner/repo')->pluck('title'); + + expect($titles)->toBeArray() + ->and($titles)->toHaveCount(0); + }); + + it('handles empty labels array in whereAllLabels', function () { + $prWithNoLabels = createEnhancedMockPrData([ + 'labels' => [], + ]); + + $connector = createEnhancedConnector([$prWithNoLabels]); + $builder = new QueryBuilder($connector); + + $results = $builder->repository('owner/repo')->whereAllLabels(['bug'])->get(); + + expect($results)->toBeArray() + ->and($results)->toHaveCount(0); + }); +});