diff --git a/.horde.yml b/.horde.yml index 5e1a6b1..4ece410 100644 --- a/.horde.yml +++ b/.horde.yml @@ -36,3 +36,7 @@ dependencies: phpstan/phpstan: ^2 nocommands: - bin/demo-client.php + +quality: + phpstan: + level: 5 diff --git a/bin/demo-client.php b/bin/demo-client.php index 0c9bc75..f10a6a6 100755 --- a/bin/demo-client.php +++ b/bin/demo-client.php @@ -153,9 +153,9 @@ title: 'Demo PR - API Client Test', head: $headBranch, base: $baseBranch, - body: "This is a demo pull request created by the GitHub API Client.\n\n" . - "Created at: " . date('Y-m-d H:i:s') . "\n" . - "This PR can be safely closed.", + body: "This is a demo pull request created by the GitHub API Client.\n\n" + . "Created at: " . date('Y-m-d H:i:s') . "\n" + . "This PR can be safely closed.", draft: (getenv('PR_DRAFT') === '1'), maintainerCanModify: true ); diff --git a/bin/demo-token-info.php b/bin/demo-token-info.php index 186a775..7a7f1a6 100755 --- a/bin/demo-token-info.php +++ b/bin/demo-token-info.php @@ -45,12 +45,12 @@ try { echo "📊 Checking Rate Limit...\n"; $rateLimit = $client->getRateLimit(); - + echo " ├─ Limit: " . number_format($rateLimit->limit) . " requests/hour\n"; echo " ├─ Used: " . number_format($rateLimit->used) . " requests\n"; echo " ├─ Remaining: " . number_format($rateLimit->remaining) . " requests\n"; echo " ├─ Usage: " . number_format($rateLimit->getUsagePercentage(), 1) . "%\n"; - + if ($rateLimit->isExhausted()) { echo " └─ ⚠️ EXHAUSTED - Resets at " . $rateLimit->getResetDateTime()->format('Y-m-d H:i:s T') . "\n"; } else { @@ -58,7 +58,7 @@ $minutes = floor($seconds / 60); echo " └─ ✓ Resets in " . $minutes . " minutes (" . $rateLimit->getResetDateTime()->format('Y-m-d H:i:s T') . ")\n"; } - + echo "\n"; } catch (\Exception $e) { echo " └─ ✗ Error: " . $e->getMessage() . "\n\n"; @@ -68,7 +68,7 @@ try { echo "🔐 Checking Token Scopes/Permissions...\n"; $scopes = $client->getTokenScopes(); - + if ($scopes->isEmpty()) { echo " └─ ⚠️ No scopes granted (token may be invalid)\n\n"; } else { @@ -80,14 +80,14 @@ echo " │ ├─ Write Repositories: " . ($scopes->canWriteRepositories() ? "✓ YES" : "✗ NO") . "\n"; echo " │ └─ Read Organizations: " . ($scopes->canReadOrganizations() ? "✓ YES" : "✗ NO") . "\n"; echo " │\n"; - + // List all individual scopes echo " └─ Individual Scopes:\n"; foreach ($scopes->toArray() as $scope) { echo " • " . $scope . "\n"; } } - + echo "\n"; } catch (\Exception $e) { echo " └─ ✗ Error: " . $e->getMessage() . "\n\n"; diff --git a/create-pr.php b/create-pr.php deleted file mode 100755 index 8051c59..0000000 --- a/create-pr.php +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env php -setInstance(ClientInterface::class, new CurlClient(new ResponseFactory(), new StreamFactory(), new Options())); -$injector->setInstance(RequestFactoryInterface::class, new RequestFactory()); -$injector->setInstance(StreamFactoryInterface::class, new StreamFactory()); -$injector->setInstance(GithubApiConfig::class, new GithubApiConfig(accessToken: $githubToken)); - -$client = $injector->get(GithubApiClient::class); - -// Repository details -$repo = GithubRepository::fromFullName('horde/githubapiclient'); - -// PR details -$title = 'feat: add comprehensive pull request management API'; - -$body = <<<'MARKDOWN' -## Summary - -This PR adds comprehensive pull request management capabilities to the GitHub API Client, transforming it from a basic client into a full-featured PR automation tool. - -## Features Added - -### Pull Request Operations -- ✅ Create pull requests (including draft PRs and from forks) -- ✅ List pull requests with filters (base branch, head ref, state) -- ✅ Get detailed pull request information -- ✅ Update pull requests (title, body, base branch, state) -- ✅ Merge pull requests (merge, squash, rebase methods) -- ✅ Close pull requests -- ✅ Reopen closed pull requests - -### Comment Management -- ✅ List all comments on pull requests -- ✅ Create comments -- ✅ Update comments -- ✅ Delete comments - -### Review Management -- ✅ List pull request reviews -- ✅ Request reviewers (users and teams) -- ✅ Support for all review states (APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED) - -### Status Checks & CI/CD -- ✅ Get combined commit status -- ✅ List GitHub Actions check runs -- ✅ Monitor pipeline status and conclusions - -### Label Management -- ✅ List labels on issues/pull requests -- ✅ Add labels -- ✅ Set (replace) all labels -- ✅ Remove labels - -## Technical Implementation - -### Architecture -- **Request Factory Pattern**: Each API endpoint has a dedicated request factory -- **Value Objects**: Immutable domain objects with static factory methods -- **DTOs**: Clean data transfer objects for complex parameters -- **Typed Collections**: All collections implement `Iterator` and `Countable` -- **PHP 8.2+ Features**: Named parameters, readonly properties, strict types -- **PSR Compliant**: PSR-7, PSR-17, PSR-18 - -### Code Quality -- **71 unit tests** with **249 assertions** - all passing ✅ -- **52 files changed**: 5,217 insertions, 7 deletions -- **PER-1 coding standards** throughout -- **Conventional Commits** for all commits - -## Documentation - -### Added Documentation Files -- **README.md**: Comprehensive usage guide with examples for all features -- **doc/API.md**: Complete API reference (574 lines) -- **doc/MIGRATION.md**: Upgrade guide with backwards compatibility notes (305 lines) -- **bin/demo-client.php**: Enhanced with 10 working examples - -## Breaking Changes - -**None** - This is a backwards-compatible addition. Existing code continues to work unchanged. - -**Optional Enhancement**: Add `StreamFactoryInterface` parameter to constructor to enable write operations: -```php -$client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); -``` - -## Example Usage - -### Create a Pull Request -```php -use Horde\GithubApiClient\CreatePullRequestParams; - -$params = new CreatePullRequestParams( - title: 'Add new feature', - head: 'feature-branch', - base: 'main', - body: 'This PR adds...' -); -$pr = $client->createPullRequest($repo, $params); -``` - -### Merge a Pull Request -```php -use Horde\GithubApiClient\MergePullRequestParams; - -$params = new MergePullRequestParams( - commitTitle: 'feat: add feature', - mergeMethod: 'squash' -); -$result = $client->mergePullRequest($repo, 123, $params); -``` - -### Manage Comments and Reviews -```php -// Add a comment -$comment = $client->createPullRequestComment($repo, 123, 'LGTM!'); - -// Request reviewers -$client->requestReviewers($repo, 123, ['reviewer1', 'reviewer2']); - -// Check CI status -$status = $client->getCombinedStatus($repo, 'main'); -echo "Status: {$status->state}\n"; -``` - -## Test Plan - -- [x] All 71 unit tests passing -- [x] Demo client tested with real GitHub API -- [x] Documentation reviewed and examples verified -- [x] Backwards compatibility verified -- [x] Code follows PER-1 and Conventional Commits standards - -## Commits - -This PR includes 10 well-structured commits: -1. Enhanced pull request API with user and label support -2. Pull request update capability -3. Pull request comment management -4. Pull request review management -5. Commit status and check runs support -6. Label management support -7. Pull request merge and close support -8. Comprehensive documentation and examples -9. Create and reopen pull request functionality -10. Factory method and constructor documentation improvements - -## Checklist - -- [x] Code follows project coding standards (PER-1) -- [x] Unit tests added and passing (71 tests, 249 assertions) -- [x] Documentation updated (README, API reference, migration guide) -- [x] Backwards compatible (existing code unaffected) -- [x] Conventional Commits used for all commits -- [x] Demo client includes working examples -- [x] No breaking changes introduced - -## Related Issues - -Closes #[issue number if applicable] -MARKDOWN; - -echo "Creating pull request...\n"; -echo "Repository: horde/githubapiclient\n"; -echo "Head: feat/enhanced-pull-request-api\n"; -echo "Base: FRAMEWORK_6_0\n\n"; - -try { - $params = new CreatePullRequestParams( - title: $title, - head: 'feat/enhanced-pull-request-api', - base: 'FRAMEWORK_6_0', - body: $body, - draft: false, - maintainerCanModify: true - ); - - $pr = $client->createPullRequest($repo, $params); - - echo "✅ Pull request created successfully!\n\n"; - echo "PR #{$pr->number}: {$pr->title}\n"; - echo "URL: {$pr->htmlUrl}\n"; - echo "State: {$pr->state}\n"; - echo "Author: {$pr->author->login}\n"; - echo "\nYou can view the PR at: {$pr->htmlUrl}\n"; - -} catch (\Exception $e) { - echo "❌ Error creating pull request:\n"; - echo $e->getMessage() . "\n"; - exit(1); -} diff --git a/src/CreateInstallationAccessTokenParams.php b/src/CreateInstallationAccessTokenParams.php new file mode 100644 index 0000000..c572ced --- /dev/null +++ b/src/CreateInstallationAccessTokenParams.php @@ -0,0 +1,49 @@ + $repositories List of repository names to grant access to (optional) + * @param array $permissions Permissions to grant (optional) + */ + public function __construct( + public readonly array $repositories = [], + public readonly array $permissions = [] + ) {} + + /** + * Convert to array for API request + * + * @return array + */ + public function toArray(): array + { + $data = []; + + if ($this->repositories !== []) { + $data['repositories'] = $this->repositories; + } + + if ($this->permissions !== []) { + $data['permissions'] = $this->permissions; + } + + return $data; + } +} diff --git a/src/CreateInstallationAccessTokenRequestFactory.php b/src/CreateInstallationAccessTokenRequestFactory.php new file mode 100644 index 0000000..8bb4a2a --- /dev/null +++ b/src/CreateInstallationAccessTokenRequestFactory.php @@ -0,0 +1,58 @@ +config->endpoint, + $this->installationId + ); + + $jsonBody = json_encode($this->params->toArray(), JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + + $request = $this->requestFactory->createRequest('POST', $url); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->config->jwt); + $request = $request->withHeader('Accept', 'application/vnd.github+json'); + $request = $request->withHeader('X-GitHub-Api-Version', $this->config->apiVersion); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/src/GetAuthenticatedAppRequestFactory.php b/src/GetAuthenticatedAppRequestFactory.php new file mode 100644 index 0000000..4a200d7 --- /dev/null +++ b/src/GetAuthenticatedAppRequestFactory.php @@ -0,0 +1,45 @@ +config->endpoint); + + $request = $this->requestFactory->createRequest('GET', $url); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->config->jwt); + $request = $request->withHeader('Accept', 'application/vnd.github+json'); + $request = $request->withHeader('X-GitHub-Api-Version', $this->config->apiVersion); + + return $request; + } +} diff --git a/src/GithubApiClient.php b/src/GithubApiClient.php index cd57672..e4c5914 100644 --- a/src/GithubApiClient.php +++ b/src/GithubApiClient.php @@ -844,6 +844,90 @@ public function getCurrentUser(): GithubUser } } + /** + * Create an installation access token for GitHub App + * + * @param int $installationId The installation ID + * @param CreateInstallationAccessTokenParams $params Optional parameters + * @return InstallationAccessToken + * @throws Exception + */ + public function createInstallationAccessToken( + int $installationId, + CreateInstallationAccessTokenParams $params = new CreateInstallationAccessTokenParams() + ): InstallationAccessToken { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for createInstallationAccessToken'); + } + + $requestFactory = new CreateInstallationAccessTokenRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $installationId, + $params + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 201) { + $data = json_decode((string) $response->getBody()); + return InstallationAccessToken::fromApiResponse($data); + } else { + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * List all installations of the authenticated GitHub App + * + * @return GithubInstallationList + * @throws Exception + */ + public function listInstallations(): GithubInstallationList + { + $requestFactory = new ListInstallationsRequestFactory( + $this->requestFactory, + $this->config + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + $installations = []; + foreach ($data as $installationData) { + $installations[] = GithubInstallation::fromApiResponse($installationData); + } + return new GithubInstallationList($installations); + } else { + throw new Exception($this->parseErrorResponse($response)); + } + } + + /** + * Get the authenticated GitHub App + * + * @return GithubApp + * @throws Exception + */ + public function getAuthenticatedApp(): GithubApp + { + $requestFactory = new GetAuthenticatedAppRequestFactory( + $this->requestFactory, + $this->config + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + return GithubApp::fromApiResponse($data); + } else { + throw new Exception($this->parseErrorResponse($response)); + } + } + /** * @param array $repos * @param string $json diff --git a/src/GithubApiConfig.php b/src/GithubApiConfig.php index 45dfcc6..94331bf 100644 --- a/src/GithubApiConfig.php +++ b/src/GithubApiConfig.php @@ -13,6 +13,8 @@ public function __construct( public readonly string $endpoint = 'https://api.github.com', // Default to no access token public readonly string $accessToken = '', - public readonly string $apiVersion = '2022-11-28' + public readonly string $apiVersion = '2022-11-28', + // Default to no JWT (for GitHub App authentication) + public readonly string $jwt = '' ) {} } diff --git a/src/GithubApp.php b/src/GithubApp.php new file mode 100644 index 0000000..0b48d08 --- /dev/null +++ b/src/GithubApp.php @@ -0,0 +1,64 @@ +slug; + } + + /** + * Create from GitHub API response + * + * @param object $data The API response data + * @return self + */ + public static function fromApiResponse(object $data): self + { + $owner = isset($data->owner) ? GithubUser::fromApiResponse($data->owner) : new GithubUser('', 0, '', '', ''); + + return new self( + id: $data->id ?? 0, + slug: $data->slug ?? '', + name: $data->name ?? '', + owner: $owner, + createdAt: $data->created_at ?? '', + updatedAt: $data->updated_at ?? '' + ); + } +} diff --git a/src/GithubInstallation.php b/src/GithubInstallation.php new file mode 100644 index 0000000..93d8485 --- /dev/null +++ b/src/GithubInstallation.php @@ -0,0 +1,61 @@ +id; + } + + /** + * Create from GitHub API response + * + * @param object $data The API response data + * @return self + */ + public static function fromApiResponse(object $data): self + { + $account = isset($data->account) ? GithubUser::fromApiResponse($data->account) : new GithubUser('', 0, '', '', ''); + + return new self( + id: $data->id ?? 0, + account: $account, + repositorySelection: $data->repository_selection ?? '', + createdAt: $data->created_at ?? '', + updatedAt: $data->updated_at ?? '' + ); + } +} diff --git a/src/GithubInstallationList.php b/src/GithubInstallationList.php new file mode 100644 index 0000000..e1aaba7 --- /dev/null +++ b/src/GithubInstallationList.php @@ -0,0 +1,72 @@ + $installations + */ + public function __construct( + private array $installations = [] + ) {} + + public function current(): GithubInstallation + { + return $this->installations[$this->position]; + } + + public function key(): int + { + return $this->position; + } + + public function next(): void + { + ++$this->position; + } + + public function rewind(): void + { + $this->position = 0; + } + + public function valid(): bool + { + return isset($this->installations[$this->position]); + } + + public function count(): int + { + return count($this->installations); + } + + /** + * Get all installations as array + * + * @return array + */ + public function toArray(): array + { + return $this->installations; + } +} diff --git a/src/GithubPullRequestList.php b/src/GithubPullRequestList.php index a012c6e..5019f6f 100644 --- a/src/GithubPullRequestList.php +++ b/src/GithubPullRequestList.php @@ -5,13 +5,14 @@ namespace Horde\GithubApiClient; use ArrayIterator; +use Countable; use IteratorAggregate; use Traversable; use OutOfBoundsException; use Stringable; /** @implements \IteratorAggregate */ -class GithubPullRequestList implements IteratorAggregate +class GithubPullRequestList implements IteratorAggregate, Countable { /** * @var GithubPullRequest[] @@ -35,4 +36,9 @@ public function getIterator(): ArrayIterator { return new ArrayIterator($this->prs); } + + public function count(): int + { + return count($this->prs); + } } diff --git a/src/InstallationAccessToken.php b/src/InstallationAccessToken.php new file mode 100644 index 0000000..751adf9 --- /dev/null +++ b/src/InstallationAccessToken.php @@ -0,0 +1,49 @@ + $permissions Granted permissions + * @param string $repositorySelection Repository selection type (all/selected) + */ + public function __construct( + public readonly string $token, + public readonly string $expiresAt, + public readonly array $permissions, + public readonly string $repositorySelection + ) {} + + /** + * Create from GitHub API response + * + * @param object $data The API response data + * @return self + */ + public static function fromApiResponse(object $data): self + { + return new self( + token: $data->token ?? '', + expiresAt: $data->expires_at ?? '', + permissions: (array) ($data->permissions ?? []), + repositorySelection: $data->repository_selection ?? '' + ); + } +} diff --git a/src/ListInstallationsRequestFactory.php b/src/ListInstallationsRequestFactory.php new file mode 100644 index 0000000..302974a --- /dev/null +++ b/src/ListInstallationsRequestFactory.php @@ -0,0 +1,45 @@ +config->endpoint); + + $request = $this->requestFactory->createRequest('GET', $url); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->config->jwt); + $request = $request->withHeader('Accept', 'application/vnd.github+json'); + $request = $request->withHeader('X-GitHub-Api-Version', $this->config->apiVersion); + + return $request; + } +} diff --git a/test/unit/CreateInstallationAccessTokenParamsTest.php b/test/unit/CreateInstallationAccessTokenParamsTest.php new file mode 100644 index 0000000..7212a37 --- /dev/null +++ b/test/unit/CreateInstallationAccessTokenParamsTest.php @@ -0,0 +1,116 @@ +assertSame([], $params->repositories); + $this->assertSame([], $params->permissions); + } + + public function testConstructorWithRepositoriesOnly(): void + { + $params = new CreateInstallationAccessTokenParams( + repositories: ['repo1', 'repo2'] + ); + + $this->assertSame(['repo1', 'repo2'], $params->repositories); + $this->assertSame([], $params->permissions); + } + + public function testConstructorWithPermissionsOnly(): void + { + $params = new CreateInstallationAccessTokenParams( + permissions: ['contents' => 'read', 'issues' => 'write'] + ); + + $this->assertSame([], $params->repositories); + $this->assertSame(['contents' => 'read', 'issues' => 'write'], $params->permissions); + } + + public function testConstructorWithBothRepositoriesAndPermissions(): void + { + $params = new CreateInstallationAccessTokenParams( + repositories: ['my-repo'], + permissions: ['pull_requests' => 'write', 'metadata' => 'read'] + ); + + $this->assertSame(['my-repo'], $params->repositories); + $this->assertSame(['pull_requests' => 'write', 'metadata' => 'read'], $params->permissions); + } + + public function testToArrayExcludesEmptyArrays(): void + { + $params = new CreateInstallationAccessTokenParams(); + + $array = $params->toArray(); + + $this->assertSame([], $array); + $this->assertArrayNotHasKey('repositories', $array); + $this->assertArrayNotHasKey('permissions', $array); + } + + public function testToArrayIncludesNonEmptyRepositories(): void + { + $params = new CreateInstallationAccessTokenParams( + repositories: ['repo1', 'repo2', 'repo3'] + ); + + $array = $params->toArray(); + + $this->assertArrayHasKey('repositories', $array); + $this->assertSame(['repo1', 'repo2', 'repo3'], $array['repositories']); + $this->assertArrayNotHasKey('permissions', $array); + } + + public function testToArrayIncludesNonEmptyPermissions(): void + { + $params = new CreateInstallationAccessTokenParams( + permissions: ['contents' => 'write', 'issues' => 'read'] + ); + + $array = $params->toArray(); + + $this->assertArrayNotHasKey('repositories', $array); + $this->assertArrayHasKey('permissions', $array); + $this->assertSame(['contents' => 'write', 'issues' => 'read'], $array['permissions']); + } + + public function testToArrayIncludesBothWhenNonEmpty(): void + { + $params = new CreateInstallationAccessTokenParams( + repositories: ['test-repo'], + permissions: ['metadata' => 'read'] + ); + + $array = $params->toArray(); + + $this->assertSame([ + 'repositories' => ['test-repo'], + 'permissions' => ['metadata' => 'read'], + ], $array); + } +} diff --git a/test/unit/CreatePullRequestParamsTest.php b/test/unit/CreatePullRequestParamsTest.php index da0f751..cbcc472 100644 --- a/test/unit/CreatePullRequestParamsTest.php +++ b/test/unit/CreatePullRequestParamsTest.php @@ -62,7 +62,7 @@ public function testToArrayWithRequiredParametersOnly(): void 'title' => 'Test PR', 'head' => 'test', 'base' => 'main', - 'maintainer_can_modify' => true + 'maintainer_can_modify' => true, ], $array); } @@ -85,7 +85,7 @@ public function testToArrayWithAllParameters(): void 'base' => 'main', 'maintainer_can_modify' => false, 'body' => 'Test description', - 'draft' => true + 'draft' => true, ], $array); } diff --git a/test/unit/GithubApiClientAppAuthTest.php b/test/unit/GithubApiClientAppAuthTest.php new file mode 100644 index 0000000..78c6486 --- /dev/null +++ b/test/unit/GithubApiClientAppAuthTest.php @@ -0,0 +1,437 @@ +createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + $bodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($bodyStream); + + $responseBody = json_encode([ + 'token' => 'ghs_16C7e42F292c6912E7710c838347Ae178B4a', + 'expires_at' => '2026-03-02T13:00:00Z', + 'permissions' => ['contents' => 'read', 'metadata' => 'read'], + 'repository_selection' => 'all', + ]); + + $stream->method('__toString')->willReturn($responseBody); + $response->method('getStatusCode')->willReturn(201); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $token = $client->createInstallationAccessToken(12345); + + $this->assertInstanceOf(InstallationAccessToken::class, $token); + $this->assertSame('ghs_16C7e42F292c6912E7710c838347Ae178B4a', $token->token); + $this->assertSame('2026-03-02T13:00:00Z', $token->expiresAt); + $this->assertSame('all', $token->repositorySelection); + } + + public function testCreateInstallationAccessTokenWithOptionalParameters(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + $bodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($bodyStream); + + $responseBody = json_encode([ + 'token' => 'ghs_specific_repos', + 'expires_at' => '2026-03-02T14:00:00Z', + 'permissions' => ['contents' => 'write', 'issues' => 'write'], + 'repository_selection' => 'selected', + ]); + + $stream->method('__toString')->willReturn($responseBody); + $response->method('getStatusCode')->willReturn(201); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $params = new CreateInstallationAccessTokenParams( + repositories: ['repo1', 'repo2'], + permissions: ['contents' => 'write', 'issues' => 'write'] + ); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $token = $client->createInstallationAccessToken(67890, $params); + + $this->assertInstanceOf(InstallationAccessToken::class, $token); + $this->assertSame('ghs_specific_repos', $token->token); + $this->assertSame('selected', $token->repositorySelection); + } + + public function testCreateInstallationAccessTokenRequiresStreamFactory(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, null); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('StreamFactory is required for createInstallationAccessToken'); + + $client->createInstallationAccessToken(12345); + } + + public function testCreateInstallationAccessTokenThrowsOn401Unauthorized(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'invalid-jwt'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + $bodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($bodyStream); + + $errorBody = json_encode(['message' => 'Bad credentials']); + $stream->method('__toString')->willReturn($errorBody); + $response->method('getStatusCode')->willReturn(401); + $response->method('getReasonPhrase')->willReturn('Unauthorized'); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('401 Unauthorized'); + + $client->createInstallationAccessToken(12345); + } + + public function testCreateInstallationAccessTokenThrowsOn403Forbidden(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + $bodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($bodyStream); + + $errorBody = json_encode(['message' => 'Forbidden']); + $stream->method('__toString')->willReturn($errorBody); + $response->method('getStatusCode')->willReturn(403); + $response->method('getReasonPhrase')->willReturn('Forbidden'); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('403 Forbidden'); + + $client->createInstallationAccessToken(12345); + } + + public function testCreateInstallationAccessTokenThrowsOn404NotFound(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + $bodyStream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($bodyStream); + + $errorBody = json_encode(['message' => 'Not Found']); + $stream->method('__toString')->willReturn($errorBody); + $response->method('getStatusCode')->willReturn(404); + $response->method('getReasonPhrase')->willReturn('Not Found'); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('404 Not Found'); + + $client->createInstallationAccessToken(99999); + } + + // Tests for listInstallations + + public function testListInstallationsSuccess(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $responseBody = json_encode([ + [ + 'id' => 111, + 'account' => ['login' => 'org1', 'id' => 1, 'avatar_url' => '', 'html_url' => '', 'type' => 'Organization'], + 'repository_selection' => 'all', + 'created_at' => '2026-01-01T00:00:00Z', + 'updated_at' => '2026-01-01T00:00:00Z', + ], + [ + 'id' => 222, + 'account' => ['login' => 'org2', 'id' => 2, 'avatar_url' => '', 'html_url' => '', 'type' => 'Organization'], + 'repository_selection' => 'selected', + 'created_at' => '2026-01-02T00:00:00Z', + 'updated_at' => '2026-01-02T00:00:00Z', + ], + ]); + + $stream->method('__toString')->willReturn($responseBody); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $installations = $client->listInstallations(); + + $this->assertInstanceOf(GithubInstallationList::class, $installations); + $this->assertCount(2, $installations); + + $installationArray = $installations->toArray(); + $this->assertSame(111, $installationArray[0]->id); + $this->assertSame('org1', $installationArray[0]->account->login); + $this->assertSame(222, $installationArray[1]->id); + $this->assertSame('org2', $installationArray[1]->account->login); + } + + public function testListInstallationsEmptyArray(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $stream->method('__toString')->willReturn('[]'); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $installations = $client->listInstallations(); + + $this->assertInstanceOf(GithubInstallationList::class, $installations); + $this->assertCount(0, $installations); + } + + public function testListInstallationsMultipleInstallations(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $installationsData = []; + for ($i = 1; $i <= 5; $i++) { + $installationsData[] = [ + 'id' => $i * 100, + 'account' => ['login' => "org{$i}", 'id' => $i, 'avatar_url' => '', 'html_url' => '', 'type' => 'Organization'], + 'repository_selection' => 'all', + 'created_at' => '2026-01-01T00:00:00Z', + 'updated_at' => '2026-01-01T00:00:00Z', + ]; + } + + $stream->method('__toString')->willReturn(json_encode($installationsData)); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $installations = $client->listInstallations(); + + $this->assertCount(5, $installations); + } + + public function testListInstallationsErrorHandling(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'invalid-jwt'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $stream->method('__toString')->willReturn(json_encode(['message' => 'Unauthorized'])); + $response->method('getStatusCode')->willReturn(401); + $response->method('getReasonPhrase')->willReturn('Unauthorized'); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('401 Unauthorized'); + + $client->listInstallations(); + } + + // Tests for getAuthenticatedApp + + public function testGetAuthenticatedAppSuccess(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'test-jwt-token'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $responseBody = json_encode([ + 'id' => 123456, + 'slug' => 'my-github-app', + 'name' => 'My GitHub App', + 'owner' => [ + 'login' => 'app-owner', + 'id' => 789, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/789', + 'html_url' => 'https://github.com/app-owner', + 'type' => 'User', + ], + 'created_at' => '2025-01-01T00:00:00Z', + 'updated_at' => '2026-03-01T00:00:00Z', + ]); + + $stream->method('__toString')->willReturn($responseBody); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $app = $client->getAuthenticatedApp(); + + $this->assertInstanceOf(GithubApp::class, $app); + $this->assertSame(123456, $app->id); + $this->assertSame('my-github-app', $app->slug); + $this->assertSame('My GitHub App', $app->name); + $this->assertSame('app-owner', $app->owner->login); + } + + public function testGetAuthenticatedAppErrorHandling(): void + { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $config = new GithubApiConfig(jwt: 'invalid-jwt'); + + $request = $this->createMock(RequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + $stream = $this->createMock(StreamInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + $stream->method('__toString')->willReturn(json_encode(['message' => 'Bad credentials'])); + $response->method('getStatusCode')->willReturn(401); + $response->method('getReasonPhrase')->willReturn('Unauthorized'); + $response->method('getBody')->willReturn($stream); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('401 Unauthorized'); + + $client->getAuthenticatedApp(); + } +} diff --git a/test/unit/GithubApiClientErrorHandlingTest.php b/test/unit/GithubApiClientErrorHandlingTest.php index 68ecb9f..91e63b7 100644 --- a/test/unit/GithubApiClientErrorHandlingTest.php +++ b/test/unit/GithubApiClientErrorHandlingTest.php @@ -195,7 +195,7 @@ public function testCreateReviewThrowsDetailedErrorOn422(): void $errorBody = json_encode([ 'message' => 'Unprocessable Entity', 'errors' => ['Review Can not approve your own pull request'], - 'documentation_url' => 'https://docs.github.com/rest/pulls/reviews#create-a-review-for-a-pull-request' + 'documentation_url' => 'https://docs.github.com/rest/pulls/reviews#create-a-review-for-a-pull-request', ]); $errorStream = $this->createMock(StreamInterface::class); $errorStream->method('__toString')->willReturn($errorBody); diff --git a/test/unit/GithubApiConfigTest.php b/test/unit/GithubApiConfigTest.php new file mode 100644 index 0000000..06f468a --- /dev/null +++ b/test/unit/GithubApiConfigTest.php @@ -0,0 +1,95 @@ +assertSame('https://api.github.com', $config->endpoint); + $this->assertSame('', $config->accessToken); + $this->assertSame('2022-11-28', $config->apiVersion); + $this->assertSame('', $config->jwt); + } + + public function testWithAccessTokenOnly(): void + { + $config = new GithubApiConfig(accessToken: 'ghp_test123'); + + $this->assertSame('https://api.github.com', $config->endpoint); + $this->assertSame('ghp_test123', $config->accessToken); + $this->assertSame('2022-11-28', $config->apiVersion); + $this->assertSame('', $config->jwt); + } + + public function testWithJwtOnly(): void + { + $config = new GithubApiConfig(jwt: 'eyJhbGc.eyJpc3M.signature'); + + $this->assertSame('https://api.github.com', $config->endpoint); + $this->assertSame('', $config->accessToken); + $this->assertSame('2022-11-28', $config->apiVersion); + $this->assertSame('eyJhbGc.eyJpc3M.signature', $config->jwt); + } + + public function testWithBothAccessTokenAndJwt(): void + { + $config = new GithubApiConfig( + accessToken: 'ghp_test123', + jwt: 'eyJhbGc.eyJpc3M.signature' + ); + + $this->assertSame('https://api.github.com', $config->endpoint); + $this->assertSame('ghp_test123', $config->accessToken); + $this->assertSame('2022-11-28', $config->apiVersion); + $this->assertSame('eyJhbGc.eyJpc3M.signature', $config->jwt); + } + + public function testWithCustomEndpointAndApiVersion(): void + { + $config = new GithubApiConfig( + endpoint: 'https://github.example.com/api/v3', + accessToken: 'token123', + apiVersion: '2023-01-01', + jwt: 'jwt123' + ); + + $this->assertSame('https://github.example.com/api/v3', $config->endpoint); + $this->assertSame('token123', $config->accessToken); + $this->assertSame('2023-01-01', $config->apiVersion); + $this->assertSame('jwt123', $config->jwt); + } + + public function testPropertiesAreReadonly(): void + { + $config = new GithubApiConfig(); + + $reflection = new \ReflectionClass($config); + $properties = $reflection->getProperties(); + + foreach ($properties as $property) { + $this->assertTrue($property->isReadOnly(), "Property {$property->getName()} should be readonly"); + } + } +} diff --git a/test/unit/GithubAppTest.php b/test/unit/GithubAppTest.php new file mode 100644 index 0000000..1618001 --- /dev/null +++ b/test/unit/GithubAppTest.php @@ -0,0 +1,138 @@ +assertSame(456, $app->id); + $this->assertSame('my-awesome-app', $app->slug); + $this->assertSame('My Awesome App', $app->name); + $this->assertSame($owner, $app->owner); + $this->assertSame('2025-01-15T10:00:00Z', $app->createdAt); + $this->assertSame('2026-02-20T15:30:00Z', $app->updatedAt); + } + + public function testFromApiResponseWithCompleteData(): void + { + $data = json_decode(json_encode([ + 'id' => 789, + 'slug' => 'ci-bot', + 'name' => 'CI Bot', + 'owner' => [ + 'login' => 'org-name', + 'id' => 999, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/999', + 'html_url' => 'https://github.com/org-name', + 'type' => 'Organization', + ], + 'created_at' => '2024-06-01T00:00:00Z', + 'updated_at' => '2026-03-01T12:00:00Z', + ])); + + $app = GithubApp::fromApiResponse($data); + + $this->assertSame(789, $app->id); + $this->assertSame('ci-bot', $app->slug); + $this->assertSame('CI Bot', $app->name); + $this->assertSame('org-name', $app->owner->login); + $this->assertSame(999, $app->owner->id); + $this->assertSame('Organization', $app->owner->type); + $this->assertSame('2024-06-01T00:00:00Z', $app->createdAt); + $this->assertSame('2026-03-01T12:00:00Z', $app->updatedAt); + } + + public function testFromApiResponseWithMinimalData(): void + { + $data = json_decode(json_encode([ + 'id' => 1, + ])); + + $app = GithubApp::fromApiResponse($data); + + $this->assertSame(1, $app->id); + $this->assertSame('', $app->slug); + $this->assertSame('', $app->name); + $this->assertSame('', $app->owner->login); + $this->assertSame(0, $app->owner->id); + $this->assertSame('', $app->createdAt); + $this->assertSame('', $app->updatedAt); + } + + public function testToStringReturnsSlug(): void + { + $owner = new GithubUser('test', 1, '', '', ''); + $app = new GithubApp( + id: 100, + slug: 'test-app-slug', + name: 'Test App', + owner: $owner, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z' + ); + + $this->assertSame('test-app-slug', (string) $app); + $this->assertSame('test-app-slug', $app->__toString()); + } + + public function testOwnerGithubUserParsing(): void + { + $data = json_decode(json_encode([ + 'id' => 222, + 'slug' => 'automation-app', + 'name' => 'Automation App', + 'owner' => [ + 'login' => 'bot-user', + 'id' => 333, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/333', + 'html_url' => 'https://github.com/bot-user', + 'type' => 'Bot', + ], + 'created_at' => '2025-12-01T00:00:00Z', + 'updated_at' => '2026-01-15T00:00:00Z', + ])); + + $app = GithubApp::fromApiResponse($data); + + $this->assertInstanceOf(GithubUser::class, $app->owner); + $this->assertSame('bot-user', $app->owner->login); + $this->assertSame(333, $app->owner->id); + $this->assertSame('Bot', $app->owner->type); + } +} diff --git a/test/unit/GithubCheckRunTest.php b/test/unit/GithubCheckRunTest.php index d34cb11..302639f 100644 --- a/test/unit/GithubCheckRunTest.php +++ b/test/unit/GithubCheckRunTest.php @@ -66,7 +66,7 @@ public function testFromApiResponseWithCompleteData(): void 'html_url' => 'https://github.com/org/repo/runs/999888', 'details_url' => 'https://github.com/org/repo/runs/999888/details', 'started_at' => '2026-02-26T11:00:00Z', - 'completed_at' => null + 'completed_at' => null, ]; $checkRun = GithubCheckRun::fromApiResponse($data); diff --git a/test/unit/GithubCommentTest.php b/test/unit/GithubCommentTest.php index bb81858..aada5b6 100644 --- a/test/unit/GithubCommentTest.php +++ b/test/unit/GithubCommentTest.php @@ -74,12 +74,12 @@ public function testFromApiResponseWithCompleteData(): void 'id' => 777, 'avatar_url' => 'https://avatar.example.com/api.png', 'html_url' => 'https://github.com/apiuser', - 'type' => 'User' + 'type' => 'User', ], 'created_at' => '2026-01-15T08:30:00Z', 'updated_at' => '2026-01-15T09:45:00Z', 'html_url' => 'https://github.com/org/repo/pull/5#issuecomment-999888', - 'url' => 'https://api.github.com/repos/org/repo/issues/comments/999888' + 'url' => 'https://api.github.com/repos/org/repo/issues/comments/999888', ]; $comment = GithubComment::fromApiResponse($data); @@ -96,7 +96,7 @@ public function testFromApiResponseWithCompleteData(): void public function testFromApiResponseWithMinimalData(): void { $data = (object) [ - 'user' => (object) [] + 'user' => (object) [], ]; $comment = GithubComment::fromApiResponse($data); diff --git a/test/unit/GithubInstallationListTest.php b/test/unit/GithubInstallationListTest.php new file mode 100644 index 0000000..6e58df4 --- /dev/null +++ b/test/unit/GithubInstallationListTest.php @@ -0,0 +1,135 @@ +assertCount(0, $list); + $this->assertSame([], $list->toArray()); + } + + public function testIterationWithForeach(): void + { + $account1 = new GithubUser('user1', 1, '', '', ''); + $account2 = new GithubUser('user2', 2, '', '', ''); + + $installation1 = new GithubInstallation(100, $account1, 'all', '2026-01-01', '2026-01-01'); + $installation2 = new GithubInstallation(200, $account2, 'selected', '2026-01-02', '2026-01-02'); + + $list = new GithubInstallationList([$installation1, $installation2]); + + $iterations = 0; + $ids = []; + + foreach ($list as $installation) { + $iterations++; + $ids[] = $installation->id; + } + + $this->assertSame(2, $iterations); + $this->assertSame([100, 200], $ids); + } + + public function testCountMethod(): void + { + $account = new GithubUser('test', 1, '', '', ''); + + $installation1 = new GithubInstallation(1, $account, 'all', '', ''); + $installation2 = new GithubInstallation(2, $account, 'all', '', ''); + $installation3 = new GithubInstallation(3, $account, 'all', '', ''); + + $list = new GithubInstallationList([$installation1, $installation2, $installation3]); + + $this->assertCount(3, $list); + $this->assertSame(3, $list->count()); + } + + public function testToArrayMethod(): void + { + $account = new GithubUser('test', 1, '', '', ''); + + $installation1 = new GithubInstallation(111, $account, 'all', '', ''); + $installation2 = new GithubInstallation(222, $account, 'selected', '', ''); + + $list = new GithubInstallationList([$installation1, $installation2]); + $array = $list->toArray(); + + $this->assertIsArray($array); + $this->assertCount(2, $array); + $this->assertSame($installation1, $array[0]); + $this->assertSame($installation2, $array[1]); + } + + public function testMultipleInstallations(): void + { + $installations = []; + for ($i = 1; $i <= 5; $i++) { + $account = new GithubUser("user{$i}", $i, '', '', ''); + $installations[] = new GithubInstallation($i * 100, $account, 'all', '', ''); + } + + $list = new GithubInstallationList($installations); + + $this->assertCount(5, $list); + + $index = 0; + foreach ($list as $key => $installation) { + $this->assertSame($index, $key); + $this->assertSame(($index + 1) * 100, $installation->id); + $index++; + } + } + + public function testIteratorPositionTracking(): void + { + $account = new GithubUser('test', 1, '', '', ''); + $installation1 = new GithubInstallation(1, $account, 'all', '', ''); + $installation2 = new GithubInstallation(2, $account, 'all', '', ''); + + $list = new GithubInstallationList([$installation1, $installation2]); + + // First iteration + $list->rewind(); + $this->assertTrue($list->valid()); + $this->assertSame(0, $list->key()); + $this->assertSame($installation1, $list->current()); + + // Move to next + $list->next(); + $this->assertTrue($list->valid()); + $this->assertSame(1, $list->key()); + $this->assertSame($installation2, $list->current()); + + // Move past end + $list->next(); + $this->assertFalse($list->valid()); + + // Rewind and iterate again + $list->rewind(); + $this->assertTrue($list->valid()); + $this->assertSame(0, $list->key()); + $this->assertSame($installation1, $list->current()); + } +} diff --git a/test/unit/GithubInstallationTest.php b/test/unit/GithubInstallationTest.php new file mode 100644 index 0000000..973cc7c --- /dev/null +++ b/test/unit/GithubInstallationTest.php @@ -0,0 +1,131 @@ +assertSame(12345, $installation->id); + $this->assertSame($account, $installation->account); + $this->assertSame('all', $installation->repositorySelection); + $this->assertSame('2026-01-01T10:00:00Z', $installation->createdAt); + $this->assertSame('2026-03-02T12:00:00Z', $installation->updatedAt); + } + + public function testFromApiResponseWithCompleteData(): void + { + $data = json_decode(json_encode([ + 'id' => 67890, + 'account' => [ + 'login' => 'my-org', + 'id' => 999, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/999', + 'html_url' => 'https://github.com/my-org', + 'type' => 'Organization', + ], + 'repository_selection' => 'selected', + 'created_at' => '2025-06-15T08:30:00Z', + 'updated_at' => '2026-02-20T14:45:00Z', + ])); + + $installation = GithubInstallation::fromApiResponse($data); + + $this->assertSame(67890, $installation->id); + $this->assertSame('my-org', $installation->account->login); + $this->assertSame(999, $installation->account->id); + $this->assertSame('Organization', $installation->account->type); + $this->assertSame('selected', $installation->repositorySelection); + $this->assertSame('2025-06-15T08:30:00Z', $installation->createdAt); + $this->assertSame('2026-02-20T14:45:00Z', $installation->updatedAt); + } + + public function testFromApiResponseWithMinimalData(): void + { + $data = json_decode(json_encode([ + 'id' => 111, + ])); + + $installation = GithubInstallation::fromApiResponse($data); + + $this->assertSame(111, $installation->id); + $this->assertSame('', $installation->account->login); + $this->assertSame(0, $installation->account->id); + $this->assertSame('', $installation->repositorySelection); + $this->assertSame('', $installation->createdAt); + $this->assertSame('', $installation->updatedAt); + } + + public function testToStringReturnsStringOfId(): void + { + $account = new GithubUser('test', 1, '', '', ''); + $installation = new GithubInstallation( + id: 54321, + account: $account, + repositorySelection: 'all', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z' + ); + + $this->assertSame('54321', (string) $installation); + $this->assertSame('54321', $installation->__toString()); + } + + public function testAccountGithubUserParsing(): void + { + $data = json_decode(json_encode([ + 'id' => 123, + 'account' => [ + 'login' => 'bot-account', + 'id' => 777, + 'avatar_url' => 'https://avatars.githubusercontent.com/u/777', + 'html_url' => 'https://github.com/bot-account', + 'type' => 'Bot', + ], + 'repository_selection' => 'all', + 'created_at' => '2026-01-01T00:00:00Z', + 'updated_at' => '2026-02-01T00:00:00Z', + ])); + + $installation = GithubInstallation::fromApiResponse($data); + + $this->assertInstanceOf(GithubUser::class, $installation->account); + $this->assertSame('bot-account', $installation->account->login); + $this->assertSame(777, $installation->account->id); + $this->assertSame('Bot', $installation->account->type); + } +} diff --git a/test/unit/GithubLabelTest.php b/test/unit/GithubLabelTest.php index f990227..634d436 100644 --- a/test/unit/GithubLabelTest.php +++ b/test/unit/GithubLabelTest.php @@ -53,7 +53,7 @@ public function testFromApiResponseWithCompleteData(): void $data = (object) [ 'name' => 'documentation', 'color' => '0075ca', - 'description' => 'Improvements or additions to documentation' + 'description' => 'Improvements or additions to documentation', ]; $label = GithubLabel::fromApiResponse($data); @@ -67,7 +67,7 @@ public function testFromApiResponseWithoutDescription(): void { $data = (object) [ 'name' => 'wontfix', - 'color' => 'ffffff' + 'color' => 'ffffff', ]; $label = GithubLabel::fromApiResponse($data); diff --git a/test/unit/GithubRepositoryTest.php b/test/unit/GithubRepositoryTest.php index 027287d..385e129 100644 --- a/test/unit/GithubRepositoryTest.php +++ b/test/unit/GithubRepositoryTest.php @@ -70,7 +70,7 @@ public function testFromApiArrayParsesOwnerFromFullName(): void 'name' => 'components', 'full_name' => 'horde/components', 'description' => 'Component management tool', - 'clone_url' => 'https://github.com/horde/components.git' + 'clone_url' => 'https://github.com/horde/components.git', ]; $repo = GithubRepository::fromApiArray($data); diff --git a/test/unit/GithubReviewTest.php b/test/unit/GithubReviewTest.php index d3f48d9..3bbf4e6 100644 --- a/test/unit/GithubReviewTest.php +++ b/test/unit/GithubReviewTest.php @@ -75,13 +75,13 @@ public function testFromApiResponseWithCompleteData(): void 'id' => 333, 'avatar_url' => 'https://avatar.example.com/apireviewer.png', 'html_url' => 'https://github.com/apireviewer', - 'type' => 'User' + 'type' => 'User', ], 'body' => 'API review comment', 'state' => 'CHANGES_REQUESTED', 'html_url' => 'https://github.com/org/repo/pull/5#pullrequestreview-111222', 'submitted_at' => '2026-02-15T14:30:00Z', - 'commit_id' => 'def789ghi012' + 'commit_id' => 'def789ghi012', ]; $review = GithubReview::fromApiResponse($data); @@ -98,7 +98,7 @@ public function testFromApiResponseWithCompleteData(): void public function testFromApiResponseWithMinimalData(): void { $data = (object) [ - 'user' => (object) [] + 'user' => (object) [], ]; $review = GithubReview::fromApiResponse($data); diff --git a/test/unit/GithubStatusTest.php b/test/unit/GithubStatusTest.php index f0ecec2..ad9ec00 100644 --- a/test/unit/GithubStatusTest.php +++ b/test/unit/GithubStatusTest.php @@ -56,7 +56,7 @@ public function testCommitStatusFromApiResponse(): void 'description' => 'Running integration tests', 'target_url' => 'https://ci.example.com/build/789', 'created_at' => '2026-02-26T11:00:00Z', - 'updated_at' => '2026-02-26T11:01:00Z' + 'updated_at' => '2026-02-26T11:01:00Z', ]; $status = GithubCommitStatus::fromApiResponse($data); @@ -128,7 +128,7 @@ public function testCombinedStatusFromApiResponse(): void 'description' => 'Test 1 passed', 'target_url' => 'https://ci.example.com/1', 'created_at' => '2026-02-26T12:00:00Z', - 'updated_at' => '2026-02-26T12:01:00Z' + 'updated_at' => '2026-02-26T12:01:00Z', ], (object) [ 'state' => 'success', @@ -136,9 +136,9 @@ public function testCombinedStatusFromApiResponse(): void 'description' => 'Test 2 passed', 'target_url' => 'https://ci.example.com/2', 'created_at' => '2026-02-26T12:00:00Z', - 'updated_at' => '2026-02-26T12:02:00Z' - ] - ] + 'updated_at' => '2026-02-26T12:02:00Z', + ], + ], ]; $combined = GithubCombinedStatus::fromApiResponse($data); diff --git a/test/unit/GithubUserTest.php b/test/unit/GithubUserTest.php index cfaaef2..5872d3a 100644 --- a/test/unit/GithubUserTest.php +++ b/test/unit/GithubUserTest.php @@ -61,7 +61,7 @@ public function testFromApiResponseWithCompleteData(): void 'id' => 99999, 'avatar_url' => 'https://avatar.example.com/apiuser.png', 'html_url' => 'https://github.com/apiuser', - 'type' => 'Bot' + 'type' => 'Bot', ]; $user = GithubUser::fromApiResponse($data); @@ -90,7 +90,7 @@ public function testFromApiResponseWithPartialData(): void { $data = (object) [ 'login' => 'partial', - 'id' => 54321 + 'id' => 54321, ]; $user = GithubUser::fromApiResponse($data); diff --git a/test/unit/InstallationAccessTokenTest.php b/test/unit/InstallationAccessTokenTest.php new file mode 100644 index 0000000..8d31ada --- /dev/null +++ b/test/unit/InstallationAccessTokenTest.php @@ -0,0 +1,138 @@ + 'read', 'issues' => 'write'], + repositorySelection: 'selected' + ); + + $this->assertSame('ghs_test123', $token->token); + $this->assertSame('2026-03-02T12:00:00Z', $token->expiresAt); + $this->assertSame(['contents' => 'read', 'issues' => 'write'], $token->permissions); + $this->assertSame('selected', $token->repositorySelection); + } + + public function testFromApiResponseWithCompleteData(): void + { + $data = json_decode(json_encode([ + 'token' => 'ghs_16C7e42F292c6912E7710c838347Ae178B4a', + 'expires_at' => '2026-03-02T13:00:00Z', + 'permissions' => [ + 'contents' => 'write', + 'issues' => 'read', + 'metadata' => 'read', + ], + 'repository_selection' => 'all', + ])); + + $token = InstallationAccessToken::fromApiResponse($data); + + $this->assertSame('ghs_16C7e42F292c6912E7710c838347Ae178B4a', $token->token); + $this->assertSame('2026-03-02T13:00:00Z', $token->expiresAt); + $this->assertSame([ + 'contents' => 'write', + 'issues' => 'read', + 'metadata' => 'read', + ], $token->permissions); + $this->assertSame('all', $token->repositorySelection); + } + + public function testFromApiResponseWithMinimalData(): void + { + $data = json_decode(json_encode([ + 'token' => 'ghs_minimal', + 'expires_at' => '2026-03-02T14:00:00Z', + ])); + + $token = InstallationAccessToken::fromApiResponse($data); + + $this->assertSame('ghs_minimal', $token->token); + $this->assertSame('2026-03-02T14:00:00Z', $token->expiresAt); + $this->assertSame([], $token->permissions); + $this->assertSame('', $token->repositorySelection); + } + + public function testPermissionsArrayHandling(): void + { + $data = json_decode(json_encode([ + 'token' => 'ghs_test', + 'expires_at' => '2026-03-02T15:00:00Z', + 'permissions' => [ + 'pull_requests' => 'write', + 'checks' => 'read', + ], + 'repository_selection' => 'selected', + ])); + + $token = InstallationAccessToken::fromApiResponse($data); + + $this->assertIsArray($token->permissions); + $this->assertCount(2, $token->permissions); + $this->assertArrayHasKey('pull_requests', $token->permissions); + $this->assertArrayHasKey('checks', $token->permissions); + } + + public function testRepositorySelectionValues(): void + { + // Test 'all' selection + $dataAll = json_decode(json_encode([ + 'token' => 'ghs_all', + 'expires_at' => '2026-03-02T16:00:00Z', + 'repository_selection' => 'all', + ])); + + $tokenAll = InstallationAccessToken::fromApiResponse($dataAll); + $this->assertSame('all', $tokenAll->repositorySelection); + + // Test 'selected' selection + $dataSelected = json_decode(json_encode([ + 'token' => 'ghs_selected', + 'expires_at' => '2026-03-02T17:00:00Z', + 'repository_selection' => 'selected', + ])); + + $tokenSelected = InstallationAccessToken::fromApiResponse($dataSelected); + $this->assertSame('selected', $tokenSelected->repositorySelection); + } + + public function testDoesNotImplementStringable(): void + { + $token = new InstallationAccessToken( + token: 'ghs_secret', + expiresAt: '2026-03-02T18:00:00Z', + permissions: [], + repositorySelection: 'all' + ); + + // Verify token doesn't implement Stringable (for security) + $reflection = new \ReflectionClass($token); + $interfaces = $reflection->getInterfaceNames(); + + $this->assertNotContains('Stringable', $interfaces); + } +} diff --git a/test/unit/MergeTest.php b/test/unit/MergeTest.php index db68797..db43bc7 100644 --- a/test/unit/MergeTest.php +++ b/test/unit/MergeTest.php @@ -62,7 +62,7 @@ public function testMergePullRequestParamsToArrayWithAllFields(): void 'commit_title' => 'Test title', 'commit_message' => 'Test message', 'merge_method' => 'squash', - 'sha' => 'abc123' + 'sha' => 'abc123', ], $array); } @@ -133,7 +133,7 @@ public function testMergeResultFromApiResponseWhenMerged(): void $data = (object) [ 'sha' => 'ghi789jkl012', 'merged' => true, - 'message' => 'Pull Request successfully merged' + 'message' => 'Pull Request successfully merged', ]; $result = MergeResult::fromApiResponse($data); @@ -148,7 +148,7 @@ public function testMergeResultFromApiResponseWhenNotMerged(): void $data = (object) [ 'sha' => '', 'merged' => false, - 'message' => 'Merge conflict' + 'message' => 'Merge conflict', ]; $result = MergeResult::fromApiResponse($data);