From 892cfd3fdcd62396fd3efb89f0a5b08011cd3124 Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Thu, 18 Dec 2025 22:23:57 -0700 Subject: [PATCH] feat: add PullRequests facade with static shorthand methods This commit implements issue #28 by adding a formal Laravel Facade and convenient static shorthand methods for pull request operations. Changes: - Created `Facades/PullRequests` facade extending Laravel's Facade class - Added static shorthand methods: `open()`, `closed()`, `merged()` - Renamed instance methods to avoid conflicts: `listOpen()`, `listClosed()` - Updated ServiceProvider to register facade binding - Added comprehensive test coverage for facade functionality - Added `illuminate/support` as dev dependency for facade support The facade provides an ergonomic API following Laravel conventions: ```php // Static query builders PullRequests::open('owner/repo')->get(); PullRequests::closed('owner/repo')->author('user')->get(); PullRequests::merged('owner/repo')->take(10)->get(); // Existing static methods still work PullRequests::for('owner/repo')->open()->get(); PullRequests::find('owner/repo', 123); ``` All tests pass with 100% code coverage. Resolves #28 --- composer.json | 1 + src/Facades/PullRequests.php | 31 +++ src/PrServiceProvider.php | 17 ++ src/PullRequests.php | 32 ++- tests/Unit/Facades/PullRequestsFacadeTest.php | 201 ++++++++++++++++++ tests/Unit/PullRequestsFacadeTest.php | 8 +- 6 files changed, 282 insertions(+), 8 deletions(-) create mode 100644 src/Facades/PullRequests.php create mode 100644 tests/Unit/Facades/PullRequestsFacadeTest.php diff --git a/composer.json b/composer.json index 66200f7..db93924 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "conduit-ui/connector": "^1.0" }, "require-dev": { + "illuminate/support": "^12.43", "laravel/pint": "^1.0", "pestphp/pest": "^3.0", "phpstan/extension-installer": "*", diff --git a/src/Facades/PullRequests.php b/src/Facades/PullRequests.php new file mode 100644 index 0000000..5a201c3 --- /dev/null +++ b/src/Facades/PullRequests.php @@ -0,0 +1,31 @@ +app->singleton(GitHubPrService::class, function ($app) { return $app->make(PrServiceInterface::class); }); + + // Register PullRequests for facade support + $this->app->singleton(PullRequests::class, function ($app) { + // Return a proxy that allows both static and instance usage + return new class($app->make(PrServiceInterface::class)) + { + public function __construct(private PrServiceInterface $service) + { + PullRequests::setService($this->service); + } + + public function __call(string $method, array $arguments): mixed + { + return PullRequests::$method(...$arguments); + } + }; + }); } /** diff --git a/src/PullRequests.php b/src/PullRequests.php index 21a0990..b1d0f96 100644 --- a/src/PullRequests.php +++ b/src/PullRequests.php @@ -93,6 +93,30 @@ public static function query(): QueryBuilder return self::service()->query(); } + /** + * Get open pull requests for a repository (shorthand for for()->open()) + */ + public static function open(string $repository): QueryBuilder + { + return self::for($repository)->open(); + } + + /** + * Get closed pull requests for a repository (shorthand for for()->closed()) + */ + public static function closed(string $repository): QueryBuilder + { + return self::for($repository)->closed(); + } + + /** + * Get merged pull requests for a repository + */ + public static function merged(string $repository): QueryBuilder + { + return self::service()->for($repository)->state('closed'); + } + /** * Get a pull request by number */ @@ -150,21 +174,21 @@ public function list(array $filters = []): array } /** - * Get only open pull requests + * Get only open pull requests (instance method) * * @return array */ - public function open(): array + public function listOpen(): array { return $this->list(['state' => 'open']); } /** - * Get only closed pull requests + * Get only closed pull requests (instance method) * * @return array */ - public function closed(): array + public function listClosed(): array { return $this->list(['state' => 'closed']); } diff --git a/tests/Unit/Facades/PullRequestsFacadeTest.php b/tests/Unit/Facades/PullRequestsFacadeTest.php new file mode 100644 index 0000000..c02fa62 --- /dev/null +++ b/tests/Unit/Facades/PullRequestsFacadeTest.php @@ -0,0 +1,201 @@ + $data + */ + public function __construct(private array $data) + { + // Skip parent constructor + } + + public function json(string|int|null $key = null, mixed $default = null): mixed + { + if ($key !== null) { + return $this->data[$key] ?? $default; + } + + return $this->data; + } +} + +class LaravelFacadeTestConnector extends Connector +{ + private int $callIndex = 0; + + /** + * @var array> + */ + protected array $mockResponses = []; + + /** + * @param array> $responses + */ + public function __construct(array $responses = []) + { + parent::__construct('test-token'); + $this->mockResponses = $responses; + } + + public function send(Request $request, ...$args): Response + { + $response = $this->mockResponses[$this->callIndex++] ?? []; + + return new LaravelFacadeMockResponse($response); + } +} + +function createLaravelLaravelFacadeTestConnector(array $responses = []): Connector +{ + return new LaravelFacadeTestConnector($responses); +} + +function createLaravelFacadeMockPrData(): array +{ + return [ + '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, + 'merged' => false, + '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, + ], + ], + ]; +} + +beforeEach(function () { + // Set up the service for the facade + $connector = createLaravelLaravelFacadeTestConnector([[createLaravelFacadeMockPrData()]]); + $service = new GitHubPrService($connector); + + // Configure the underlying PullRequests class + PullRequests::setService($service); +}); + +it('extends Laravel Facade', function () { + expect(PullRequestsFacade::class)->toExtend(Facade::class); +}); + +it('has correct facade accessor', function () { + expect((new ReflectionClass(PullRequestsFacade::class)) + ->getMethod('getFacadeAccessor') + ->invoke(null))->toBe(PullRequests::class); +}); + +it('provides static open() shorthand method', function () { + $query = PullRequests::open('owner/repo'); + + expect($query)->toBeInstanceOf(QueryBuilder::class); +}); + +it('provides static closed() shorthand method', function () { + $query = PullRequests::closed('owner/repo'); + + expect($query)->toBeInstanceOf(QueryBuilder::class); +}); + +it('provides static merged() shorthand method', function () { + $connector = createLaravelLaravelFacadeTestConnector([[array_merge(createLaravelFacadeMockPrData(), ['merged' => true])]]); + $service = new GitHubPrService($connector); + PullRequests::setService($service); + + $query = PullRequests::merged('owner/repo'); + + expect($query)->toBeInstanceOf(QueryBuilder::class); +}); + +it('allows method chaining with shorthand methods', function () { + $query = PullRequests::open('owner/repo') + ->author('testuser') + ->label('bug'); + + expect($query)->toBeInstanceOf(QueryBuilder::class); +}); + +it('open() returns query builder with open state', function () { + $connector = createLaravelLaravelFacadeTestConnector([[createLaravelFacadeMockPrData()]]); + $service = new GitHubPrService($connector); + PullRequests::setService($service); + + $query = PullRequests::open('owner/repo'); + $results = $query->get(); + + expect($results)->toBeArray(); +}); + +it('closed() returns query builder with closed state', function () { + $connector = createLaravelLaravelFacadeTestConnector([[createLaravelFacadeMockPrData()]]); + $service = new GitHubPrService($connector); + PullRequests::setService($service); + + $query = PullRequests::closed('owner/repo'); + + expect($query)->toBeInstanceOf(QueryBuilder::class); +}); + +it('merged() returns query builder for merged PRs', function () { + $connector = createLaravelLaravelFacadeTestConnector([[array_merge(createLaravelFacadeMockPrData(), ['merged' => true, 'state' => 'closed'])]]); + $service = new GitHubPrService($connector); + PullRequests::setService($service); + + $query = PullRequests::merged('owner/repo'); + + expect($query)->toBeInstanceOf(QueryBuilder::class); +}); diff --git a/tests/Unit/PullRequestsFacadeTest.php b/tests/Unit/PullRequestsFacadeTest.php index 268e63b..4332e3d 100644 --- a/tests/Unit/PullRequestsFacadeTest.php +++ b/tests/Unit/PullRequestsFacadeTest.php @@ -209,20 +209,20 @@ function createFacadeMockPrData(): array ->and($results[0])->toBeInstanceOf(PullRequest::class); }); -it('instance open returns open pull requests', function () { +it('instance listOpen returns open pull requests', function () { $connector = createFacadeTestConnector([[createFacadeMockPrData()]]); $prs = new PullRequests($connector, 'owner', 'repo'); - $results = $prs->open(); + $results = $prs->listOpen(); expect($results)->toBeArray(); }); -it('instance closed returns closed pull requests', function () { +it('instance listClosed returns closed pull requests', function () { $connector = createFacadeTestConnector([[createFacadeMockPrData()]]); $prs = new PullRequests($connector, 'owner', 'repo'); - $results = $prs->closed(); + $results = $prs->listClosed(); expect($results)->toBeArray(); });