From 756899951a2a7afb785993231926e94238ad9a3e Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Thu, 26 Feb 2026 13:17:34 +0100 Subject: [PATCH 1/5] feat: Add rate limit and token permissions checking support This commit adds two new features to the GitHub API client: RATE LIMIT SUPPORT: - RateLimit value object to hold rate limit data - RateLimitRequestFactory for creating /rate_limit endpoint requests - GithubApiClient::getRateLimit() method - Tracks limit, remaining, used, and reset timestamp - Helper methods: isExhausted(), getUsagePercentage(), getSecondsUntilReset() - Comprehensive PHPUnit tests (10 tests, 22 assertions) TOKEN PERMISSIONS/SCOPES SUPPORT: - TokenScopes value object to hold OAuth scope list - AuthenticatedUserRequestFactory for creating /user endpoint requests - GithubApiClient::getTokenScopes() method - Parses X-OAuth-Scopes header from API response - Helper methods: has(), hasAny(), hasAll(), canReadRepositories(), etc. - Comprehensive PHPUnit tests (19 tests, 27 assertions) DEMO SCRIPT: - bin/demo-token-info.php demonstrates both features - Displays formatted rate limit and scope information All tests passing (29 tests, 49 assertions) Follows existing library patterns and conventions --- bin/demo-token-info.php | 96 +++++++++++++++ src/AuthenticatedUserRequestFactory.php | 34 ++++++ src/GithubApiClient.php | 42 +++++++ src/RateLimit.php | 80 ++++++++++++ src/RateLimitRequestFactory.php | 34 ++++++ src/TokenScopes.php | 151 +++++++++++++++++++++++ tests/unit/RateLimitTest.php | 156 ++++++++++++++++++++++++ tests/unit/TokenScopesTest.php | 153 +++++++++++++++++++++++ 8 files changed, 746 insertions(+) create mode 100755 bin/demo-token-info.php create mode 100755 src/AuthenticatedUserRequestFactory.php create mode 100755 src/RateLimit.php create mode 100755 src/RateLimitRequestFactory.php create mode 100755 src/TokenScopes.php create mode 100755 tests/unit/RateLimitTest.php create mode 100755 tests/unit/TokenScopesTest.php diff --git a/bin/demo-token-info.php b/bin/demo-token-info.php new file mode 100755 index 0000000..186a775 --- /dev/null +++ b/bin/demo-token-info.php @@ -0,0 +1,96 @@ +#!/usr/bin/env php +setInstance(ClientInterface::class, new CurlClient(new ResponseFactory(), new StreamFactory(), new Options())); +$injector->setInstance(RequestFactoryInterface::class, new RequestFactory()); +$injector->setInstance(GithubApiConfig::class, new GithubApiConfig(accessToken: $strGithubApiToken)); + +$client = $injector->get(GithubApiClient::class); + +echo "\n╔══════════════════════════════════════════════════════════════╗\n"; +echo "║ GitHub API Token Information ║\n"; +echo "╚══════════════════════════════════════════════════════════════╝\n\n"; + +// Get Rate Limit +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 { + $seconds = $rateLimit->getSecondsUntilReset(); + $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"; +} + +// Get Token Scopes +try { + echo "🔐 Checking Token Scopes/Permissions...\n"; + $scopes = $client->getTokenScopes(); + + if ($scopes->isEmpty()) { + echo " └─ ⚠️ No scopes granted (token may be invalid)\n\n"; + } else { + echo " ├─ Total Scopes: " . $scopes->count() . "\n"; + echo " ├─ Granted: " . $scopes->toString() . "\n"; + echo " │\n"; + echo " ├─ Capabilities:\n"; + echo " │ ├─ Read Repositories: " . ($scopes->canReadRepositories() ? "✓ YES" : "✗ NO") . "\n"; + 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"; +} + +echo "════════════════════════════════════════════════════════════════\n\n"; diff --git a/src/AuthenticatedUserRequestFactory.php b/src/AuthenticatedUserRequestFactory.php new file mode 100755 index 0000000..c0f2a6e --- /dev/null +++ b/src/AuthenticatedUserRequestFactory.php @@ -0,0 +1,34 @@ +config->endpoint); + + return $this->requestFactory->createRequest('GET', $uri) + ->withHeader('Accept', 'application/vnd.github+json') + ->withHeader('Authorization', 'Bearer ' . $this->config->accessToken) + ->withHeader('X-GitHub-Api-Version', $this->config->apiVersion); + } +} diff --git a/src/GithubApiClient.php b/src/GithubApiClient.php index 5f00556..2ef3629 100644 --- a/src/GithubApiClient.php +++ b/src/GithubApiClient.php @@ -60,6 +60,48 @@ public function listPullRequests(GithubRepository $repo, string $baseBranch = '' return new GithubPullRequestList($pullRequests); } + /** + * Get current rate limit status for the authenticated user + * + * @return RateLimit + * @throws Exception + */ + public function getRateLimit(): RateLimit + { + $requestFactory = new RateLimitRequestFactory($this->requestFactory, $this->config); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getReasonPhrase() == 'OK') { + $data = json_decode((string) $response->getBody()); + return RateLimit::fromApiResponse($data); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * Get OAuth scopes/permissions for the current access token + * + * @return TokenScopes + * @throws Exception + */ + public function getTokenScopes(): TokenScopes + { + $requestFactory = new AuthenticatedUserRequestFactory($this->requestFactory, $this->config); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getReasonPhrase() == 'OK') { + // Parse X-OAuth-Scopes header + $scopesHeader = $response->getHeader('X-OAuth-Scopes'); + $scopesValue = !empty($scopesHeader) ? $scopesHeader[0] : ''; + return TokenScopes::fromHeader($scopesValue); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + /** * @param array $repos * @param string $json diff --git a/src/RateLimit.php b/src/RateLimit.php new file mode 100755 index 0000000..28c85ec --- /dev/null +++ b/src/RateLimit.php @@ -0,0 +1,80 @@ +resources->core ?? throw new \InvalidArgumentException('Invalid rate limit response structure'); + + return new self( + limit: $core->limit ?? 0, + remaining: $core->remaining ?? 0, + reset: $core->reset ?? 0, + used: $core->used ?? 0 + ); + } + + /** + * Get time until rate limit resets + * + * @return int Seconds until reset + */ + public function getSecondsUntilReset(): int + { + return max(0, $this->reset - time()); + } + + /** + * Check if rate limit is exhausted + * + * @return bool + */ + public function isExhausted(): bool + { + return $this->remaining === 0; + } + + /** + * Get percentage of quota used + * + * @return float Percentage (0-100) + */ + public function getUsagePercentage(): float + { + if ($this->limit === 0) { + return 0.0; + } + return ($this->used / $this->limit) * 100; + } + + /** + * Get reset time as DateTime + * + * @return \DateTimeImmutable + */ + public function getResetDateTime(): \DateTimeImmutable + { + return \DateTimeImmutable::createFromFormat('U', (string) $this->reset) + ?: throw new \RuntimeException('Failed to create DateTime from reset timestamp'); + } +} diff --git a/src/RateLimitRequestFactory.php b/src/RateLimitRequestFactory.php new file mode 100755 index 0000000..4c983b5 --- /dev/null +++ b/src/RateLimitRequestFactory.php @@ -0,0 +1,34 @@ +config->endpoint); + + return $this->requestFactory->createRequest('GET', $uri) + ->withHeader('Accept', 'application/vnd.github+json') + ->withHeader('Authorization', 'Bearer ' . $this->config->accessToken) + ->withHeader('X-GitHub-Api-Version', $this->config->apiVersion); + } +} diff --git a/src/TokenScopes.php b/src/TokenScopes.php new file mode 100755 index 0000000..f378207 --- /dev/null +++ b/src/TokenScopes.php @@ -0,0 +1,151 @@ + */ + private readonly array $scopes; + + /** + * @param array $scopes + */ + public function __construct(array $scopes) + { + $this->scopes = array_values(array_unique(array_filter($scopes, 'is_string'))); + } + + /** + * Create from X-OAuth-Scopes header value + * + * @param string $headerValue Comma-separated scope list from X-OAuth-Scopes header + * @return self + */ + public static function fromHeader(string $headerValue): self + { + if (empty(trim($headerValue))) { + return new self([]); + } + + $scopes = array_map('trim', explode(',', $headerValue)); + return new self($scopes); + } + + /** + * Get all scopes as array + * + * @return array + */ + public function toArray(): array + { + return $this->scopes; + } + + /** + * Check if a specific scope is granted + * + * @param string $scope + * @return bool + */ + public function has(string $scope): bool + { + return in_array($scope, $this->scopes, true); + } + + /** + * Check if any of the given scopes are granted + * + * @param array $scopes + * @return bool + */ + public function hasAny(array $scopes): bool + { + foreach ($scopes as $scope) { + if ($this->has($scope)) { + return true; + } + } + return false; + } + + /** + * Check if all given scopes are granted + * + * @param array $scopes + * @return bool + */ + public function hasAll(array $scopes): bool + { + foreach ($scopes as $scope) { + if (!$this->has($scope)) { + return false; + } + } + return true; + } + + /** + * Get number of scopes + * + * @return int + */ + public function count(): int + { + return count($this->scopes); + } + + /** + * Check if token has no scopes (empty) + * + * @return bool + */ + public function isEmpty(): bool + { + return empty($this->scopes); + } + + /** + * Check if token has read access to repositories + * + * @return bool + */ + public function canReadRepositories(): bool + { + return $this->hasAny(['repo', 'public_repo', 'read:repo_hook']); + } + + /** + * Check if token has write access to repositories + * + * @return bool + */ + public function canWriteRepositories(): bool + { + return $this->has('repo'); + } + + /** + * Check if token has organization read access + * + * @return bool + */ + public function canReadOrganizations(): bool + { + return $this->hasAny(['read:org', 'admin:org', 'write:org']); + } + + /** + * Get string representation (comma-separated list) + * + * @return string + */ + public function toString(): string + { + return implode(', ', $this->scopes); + } +} diff --git a/tests/unit/RateLimitTest.php b/tests/unit/RateLimitTest.php new file mode 100755 index 0000000..d620980 --- /dev/null +++ b/tests/unit/RateLimitTest.php @@ -0,0 +1,156 @@ +assertSame(5000, $rateLimit->limit); + $this->assertSame(4999, $rateLimit->remaining); + $this->assertSame(1609459200, $rateLimit->reset); + $this->assertSame(1, $rateLimit->used); + } + + public function testFromApiResponseParsesValidResponse(): void + { + $response = json_decode('{ + "resources": { + "core": { + "limit": 5000, + "remaining": 4999, + "reset": 1609459200, + "used": 1 + } + } + }'); + + $rateLimit = RateLimit::fromApiResponse($response); + + $this->assertInstanceOf(RateLimit::class, $rateLimit); + $this->assertSame(5000, $rateLimit->limit); + $this->assertSame(4999, $rateLimit->remaining); + $this->assertSame(1609459200, $rateLimit->reset); + $this->assertSame(1, $rateLimit->used); + } + + public function testFromApiResponseThrowsOnInvalidStructure(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid rate limit response structure'); + + $response = json_decode('{"invalid": "structure"}'); + RateLimit::fromApiResponse($response); + } + + public function testIsExhaustedReturnsTrueWhenRemainingIsZero(): void + { + $rateLimit = new RateLimit( + limit: 5000, + remaining: 0, + reset: 1609459200, + used: 5000 + ); + + $this->assertTrue($rateLimit->isExhausted()); + } + + public function testIsExhaustedReturnsFalseWhenRemainingIsNotZero(): void + { + $rateLimit = new RateLimit( + limit: 5000, + remaining: 4999, + reset: 1609459200, + used: 1 + ); + + $this->assertFalse($rateLimit->isExhausted()); + } + + public function testGetUsagePercentageCalculatesCorrectly(): void + { + $rateLimit = new RateLimit( + limit: 5000, + remaining: 2500, + reset: 1609459200, + used: 2500 + ); + + $this->assertSame(50.0, $rateLimit->getUsagePercentage()); + } + + public function testGetUsagePercentageReturnsZeroWhenLimitIsZero(): void + { + $rateLimit = new RateLimit( + limit: 0, + remaining: 0, + reset: 1609459200, + used: 0 + ); + + $this->assertSame(0.0, $rateLimit->getUsagePercentage()); + } + + public function testGetSecondsUntilResetCalculatesCorrectly(): void + { + // Set reset to 1 hour in the future + $futureReset = time() + 3600; + $rateLimit = new RateLimit( + limit: 5000, + remaining: 4999, + reset: $futureReset, + used: 1 + ); + + $seconds = $rateLimit->getSecondsUntilReset(); + + // Allow for some time passage during test execution + $this->assertGreaterThanOrEqual(3595, $seconds); + $this->assertLessThanOrEqual(3600, $seconds); + } + + public function testGetSecondsUntilResetReturnsZeroForPastReset(): void + { + // Set reset to 1 hour in the past + $pastReset = time() - 3600; + $rateLimit = new RateLimit( + limit: 5000, + remaining: 5000, + reset: $pastReset, + used: 0 + ); + + $this->assertSame(0, $rateLimit->getSecondsUntilReset()); + } + + public function testGetResetDateTimeReturnsCorrectDateTime(): void + { + $reset = 1609459200; // 2021-01-01 00:00:00 UTC + $rateLimit = new RateLimit( + limit: 5000, + remaining: 4999, + reset: $reset, + used: 1 + ); + + $dateTime = $rateLimit->getResetDateTime(); + + $this->assertInstanceOf(\DateTimeImmutable::class, $dateTime); + $this->assertSame('1609459200', $dateTime->format('U')); + } +} diff --git a/tests/unit/TokenScopesTest.php b/tests/unit/TokenScopesTest.php new file mode 100755 index 0000000..b1408f9 --- /dev/null +++ b/tests/unit/TokenScopesTest.php @@ -0,0 +1,153 @@ +assertSame(['repo', 'read:org', 'admin:org'], $scopes->toArray()); + $this->assertSame(3, $scopes->count()); + } + + public function testFromHeaderParsesCommaSeparatedList(): void + { + $scopes = TokenScopes::fromHeader('repo, read:org, admin:org'); + + $this->assertSame(['repo', 'read:org', 'admin:org'], $scopes->toArray()); + } + + public function testFromHeaderHandlesEmptyString(): void + { + $scopes = TokenScopes::fromHeader(''); + + $this->assertTrue($scopes->isEmpty()); + $this->assertSame([], $scopes->toArray()); + } + + public function testFromHeaderHandlesWhitespaceOnly(): void + { + $scopes = TokenScopes::fromHeader(' '); + + $this->assertTrue($scopes->isEmpty()); + } + + public function testHasReturnsTrueForExistingScope(): void + { + $scopes = new TokenScopes(['repo', 'read:org']); + + $this->assertTrue($scopes->has('repo')); + $this->assertTrue($scopes->has('read:org')); + } + + public function testHasReturnsFalseForNonExistingScope(): void + { + $scopes = new TokenScopes(['repo']); + + $this->assertFalse($scopes->has('admin:org')); + } + + public function testHasAnyReturnsTrueIfAtLeastOneScopeExists(): void + { + $scopes = new TokenScopes(['repo', 'read:org']); + + $this->assertTrue($scopes->hasAny(['repo', 'admin:org'])); + $this->assertTrue($scopes->hasAny(['admin:org', 'read:org'])); + } + + public function testHasAnyReturnsFalseIfNoScopeExists(): void + { + $scopes = new TokenScopes(['repo']); + + $this->assertFalse($scopes->hasAny(['admin:org', 'write:org'])); + } + + public function testHasAllReturnsTrueIfAllScopesExist(): void + { + $scopes = new TokenScopes(['repo', 'read:org', 'admin:org']); + + $this->assertTrue($scopes->hasAll(['repo', 'read:org'])); + } + + public function testHasAllReturnsFalseIfAnyScopeMissing(): void + { + $scopes = new TokenScopes(['repo']); + + $this->assertFalse($scopes->hasAll(['repo', 'admin:org'])); + } + + public function testIsEmptyReturnsTrueForNoScopes(): void + { + $scopes = new TokenScopes([]); + + $this->assertTrue($scopes->isEmpty()); + } + + public function testIsEmptyReturnsFalseForScopes(): void + { + $scopes = new TokenScopes(['repo']); + + $this->assertFalse($scopes->isEmpty()); + } + + public function testCanReadRepositoriesWithRepoScope(): void + { + $scopes = new TokenScopes(['repo']); + + $this->assertTrue($scopes->canReadRepositories()); + } + + public function testCanReadRepositoriesWithPublicRepoScope(): void + { + $scopes = new TokenScopes(['public_repo']); + + $this->assertTrue($scopes->canReadRepositories()); + } + + public function testCanReadRepositoriesReturnsFalseWithoutScopes(): void + { + $scopes = new TokenScopes(['admin:org']); + + $this->assertFalse($scopes->canReadRepositories()); + } + + public function testCanWriteRepositoriesRequiresFullRepoScope(): void + { + $scopesWithRepo = new TokenScopes(['repo']); + $scopesWithPublic = new TokenScopes(['public_repo']); + + $this->assertTrue($scopesWithRepo->canWriteRepositories()); + $this->assertFalse($scopesWithPublic->canWriteRepositories()); + } + + public function testCanReadOrganizationsWithVariousScopes(): void + { + $this->assertTrue((new TokenScopes(['read:org']))->canReadOrganizations()); + $this->assertTrue((new TokenScopes(['admin:org']))->canReadOrganizations()); + $this->assertTrue((new TokenScopes(['write:org']))->canReadOrganizations()); + $this->assertFalse((new TokenScopes(['repo']))->canReadOrganizations()); + } + + public function testToStringReturnsCommaSeparatedList(): void + { + $scopes = new TokenScopes(['repo', 'read:org', 'admin:org']); + + $this->assertSame('repo, read:org, admin:org', $scopes->toString()); + } + + public function testCountReturnsCorrectNumber(): void + { + $scopes = new TokenScopes(['repo', 'read:org', 'admin:org']); + + $this->assertSame(3, $scopes->count()); + } +} From 27dd3dce3a2999b7da75a04a403bca9a8aae5ace Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Thu, 26 Feb 2026 13:19:34 +0100 Subject: [PATCH 2/5] fix: Add missing PSR HTTP interfaces and upgrade PHPUnit annotations FIXES: - Install psr/http-client, psr/http-message, psr/http-factory as dev dependencies to fix missing ClientInterface error in tests - Upgrade deprecated @coversNothing annotation to PHPUnit 12 attribute syntax #[\PHPUnit\Framework\Attributes\CoversNothing] RESULT: - All tests now pass (30 tests, 50 assertions) - No more deprecated annotation warnings - Compatible with PHPUnit 12 --- composer.json | 5 ++++- tests/unit/GithubApiClientTest.php | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 872f1c4..039ac3d 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,9 @@ "rector/rector": "^2", "phpunit/phpunit": "^12", "friendsofphp/php-cs-fixer": "^3", - "phpstan/phpstan": "^2" + "phpstan/phpstan": "^2", + "psr/http-client": "^1.0@dev", + "psr/http-message": "^2.0@dev", + "psr/http-factory": "^1.1" } } diff --git a/tests/unit/GithubApiClientTest.php b/tests/unit/GithubApiClientTest.php index 3237467..4791026 100644 --- a/tests/unit/GithubApiClientTest.php +++ b/tests/unit/GithubApiClientTest.php @@ -14,8 +14,9 @@ use Psr\Http\Message\RequestFactoryInterface; /** - * @coversNothing + * Basic integration test for GithubApiClient */ +#[\PHPUnit\Framework\Attributes\CoversNothing] final class GithubApiClientTest extends TestCase { public function testCurlClientIsA(): void From a83ecb7905b83f90f90f8f9016d3bf86cd695aae Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Thu, 26 Feb 2026 13:24:37 +0100 Subject: [PATCH 3/5] fix: Convert remaining @covers annotations to PHPUnit 12 attributes Replace deprecated @covers doc-comment annotations with #[\PHPUnit\Framework\Attributes\CoversClass] attributes in: - RateLimitTest.php - TokenScopesTest.php Result: All PHPUnit deprecation warnings eliminated Tests: 30, Assertions: 50, No deprecations --- tests/unit/RateLimitTest.php | 4 +--- tests/unit/TokenScopesTest.php | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/unit/RateLimitTest.php b/tests/unit/RateLimitTest.php index d620980..226286c 100755 --- a/tests/unit/RateLimitTest.php +++ b/tests/unit/RateLimitTest.php @@ -7,9 +7,7 @@ use PHPUnit\Framework\TestCase; use InvalidArgumentException; -/** - * @covers \Horde\GithubApiClient\RateLimit - */ +#[\PHPUnit\Framework\Attributes\CoversClass(\Horde\GithubApiClient\RateLimit::class)] final class RateLimitTest extends TestCase { public function testConstructorSetsProperties(): void diff --git a/tests/unit/TokenScopesTest.php b/tests/unit/TokenScopesTest.php index b1408f9..26d97e1 100755 --- a/tests/unit/TokenScopesTest.php +++ b/tests/unit/TokenScopesTest.php @@ -6,9 +6,7 @@ use PHPUnit\Framework\TestCase; -/** - * @covers \Horde\GithubApiClient\TokenScopes - */ +#[\PHPUnit\Framework\Attributes\CoversClass(\Horde\GithubApiClient\TokenScopes::class)] final class TokenScopesTest extends TestCase { public function testConstructorFiltersDuplicatesAndNonStrings(): void From 844320ccb3fd31ed376644fd3f8e293d8f86bad5 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Thu, 26 Feb 2026 13:37:45 +0100 Subject: [PATCH 4/5] style: Apply php-cs-fixer and fix deprecations FIXES: - Upgrade deprecated @PHP82Migration to @PHP83Migration in .php-cs-fixer.dist.php - Run php-cs-fixer fix on all source files - Fix braces_position, single_line_empty_body, no_whitespace_in_blank_line issues - Fix operator_linebreak and no_trailing_whitespace issues - Add PHP ^8.3 requirement to composer.json (addresses CS Fixer warning) AFFECTED FILES: - .php-cs-fixer.dist.php (rule set updated) - composer.json (PHP version requirement added) - src/RateLimit.php (whitespace fixes) - src/RateLimitRequestFactory.php (whitespace fixes) - src/AuthenticatedUserRequestFactory.php (whitespace fixes) - src/GithubRepository.php (operator linebreak fixes) - src/GithubRepositoryList.php (operator linebreak fixes) - tests/unit/RateLimitTest.php (whitespace fixes) - tests/unit/TokenScopesTest.php (whitespace fixes) RESULT: - 0 CS violations remaining - All tests still passing (30 tests, 50 assertions) - No deprecation warnings from php-cs-fixer --- .php-cs-fixer.dist.php | 2 +- composer.json | 3 ++- src/AuthenticatedUserRequestFactory.php | 2 +- src/GithubRepository.php | 6 ++--- src/GithubRepositoryList.php | 8 +++--- src/RateLimit.php | 4 +-- src/RateLimitRequestFactory.php | 2 +- tests/unit/RateLimitTest.php | 2 +- tests/unit/TokenScopesTest.php | 36 ++++++++++++------------- 9 files changed, 33 insertions(+), 32 deletions(-) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 2db37b8..ab2a36f 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -14,7 +14,7 @@ return (new PhpCsFixer\Config()) ->setRules([ '@PER-CS' => true, - '@PHP82Migration' => true, + '@PHP83Migration' => true, 'php_unit_test_class_requires_covers' => true, ]) ->setFinder($finder) diff --git a/composer.json b/composer.json index 039ac3d..f531f44 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ ], "minimum-stability": "dev", "require": { - "horde/http": "dev-FRAMEWORK_6_0" + "horde/http": "dev-FRAMEWORK_6_0", + "php": "^8.3" }, "require-dev": { "horde/injector": "dev-FRAMEWORK_6_0", diff --git a/src/AuthenticatedUserRequestFactory.php b/src/AuthenticatedUserRequestFactory.php index c0f2a6e..2509f29 100755 --- a/src/AuthenticatedUserRequestFactory.php +++ b/src/AuthenticatedUserRequestFactory.php @@ -25,7 +25,7 @@ public function __construct( public function create(): RequestInterface { $uri = sprintf('%s/user', $this->config->endpoint); - + return $this->requestFactory->createRequest('GET', $uri) ->withHeader('Accept', 'application/vnd.github+json') ->withHeader('Authorization', 'Bearer ' . $this->config->accessToken) diff --git a/src/GithubRepository.php b/src/GithubRepository.php index 10fa165..9891e1c 100644 --- a/src/GithubRepository.php +++ b/src/GithubRepository.php @@ -75,8 +75,8 @@ public static function fromFullName(string $fullName, string $apiUrl = ''): Gith */ public static function isValidArrayRepresentation(array $apiArray): bool { - return array_key_exists('name', $apiArray) && - array_key_exists('full_name', $apiArray) && - array_key_exists('clone_url', $apiArray); + return array_key_exists('name', $apiArray) + && array_key_exists('full_name', $apiArray) + && array_key_exists('clone_url', $apiArray); } } diff --git a/src/GithubRepositoryList.php b/src/GithubRepositoryList.php index 65ac00c..1330a1d 100644 --- a/src/GithubRepositoryList.php +++ b/src/GithubRepositoryList.php @@ -27,10 +27,10 @@ public function __construct(iterable $elements = []) if ($element instanceof GithubRepository) { $this->repositories[$element->getFullName()] = $element; } elseif ( - is_array($element) && - array_key_exists('name', $element) && - array_key_exists('full_name', $element) && - array_key_exists('clone_url', $element)) { + is_array($element) + && array_key_exists('name', $element) + && array_key_exists('full_name', $element) + && array_key_exists('clone_url', $element)) { $repository = GithubRepository::fromApiArray($element); $this->repositories[$repository->getFullName()] = $repository; } diff --git a/src/RateLimit.php b/src/RateLimit.php index 28c85ec..f1c2e9d 100755 --- a/src/RateLimit.php +++ b/src/RateLimit.php @@ -25,7 +25,7 @@ public function __construct( public static function fromApiResponse(object $response): self { $core = $response->resources->core ?? throw new \InvalidArgumentException('Invalid rate limit response structure'); - + return new self( limit: $core->limit ?? 0, remaining: $core->remaining ?? 0, @@ -74,7 +74,7 @@ public function getUsagePercentage(): float */ public function getResetDateTime(): \DateTimeImmutable { - return \DateTimeImmutable::createFromFormat('U', (string) $this->reset) + return \DateTimeImmutable::createFromFormat('U', (string) $this->reset) ?: throw new \RuntimeException('Failed to create DateTime from reset timestamp'); } } diff --git a/src/RateLimitRequestFactory.php b/src/RateLimitRequestFactory.php index 4c983b5..d1ad15f 100755 --- a/src/RateLimitRequestFactory.php +++ b/src/RateLimitRequestFactory.php @@ -25,7 +25,7 @@ public function __construct( public function create(): RequestInterface { $uri = sprintf('%s/rate_limit', $this->config->endpoint); - + return $this->requestFactory->createRequest('GET', $uri) ->withHeader('Accept', 'application/vnd.github+json') ->withHeader('Authorization', 'Bearer ' . $this->config->accessToken) diff --git a/tests/unit/RateLimitTest.php b/tests/unit/RateLimitTest.php index 226286c..4f4c47c 100755 --- a/tests/unit/RateLimitTest.php +++ b/tests/unit/RateLimitTest.php @@ -116,7 +116,7 @@ public function testGetSecondsUntilResetCalculatesCorrectly(): void ); $seconds = $rateLimit->getSecondsUntilReset(); - + // Allow for some time passage during test execution $this->assertGreaterThanOrEqual(3595, $seconds); $this->assertLessThanOrEqual(3600, $seconds); diff --git a/tests/unit/TokenScopesTest.php b/tests/unit/TokenScopesTest.php index 26d97e1..a92c219 100755 --- a/tests/unit/TokenScopesTest.php +++ b/tests/unit/TokenScopesTest.php @@ -12,7 +12,7 @@ final class TokenScopesTest extends TestCase public function testConstructorFiltersDuplicatesAndNonStrings(): void { $scopes = new TokenScopes(['repo', 'repo', 'read:org', 123, null, 'admin:org']); - + $this->assertSame(['repo', 'read:org', 'admin:org'], $scopes->toArray()); $this->assertSame(3, $scopes->count()); } @@ -20,14 +20,14 @@ public function testConstructorFiltersDuplicatesAndNonStrings(): void public function testFromHeaderParsesCommaSeparatedList(): void { $scopes = TokenScopes::fromHeader('repo, read:org, admin:org'); - + $this->assertSame(['repo', 'read:org', 'admin:org'], $scopes->toArray()); } public function testFromHeaderHandlesEmptyString(): void { $scopes = TokenScopes::fromHeader(''); - + $this->assertTrue($scopes->isEmpty()); $this->assertSame([], $scopes->toArray()); } @@ -35,14 +35,14 @@ public function testFromHeaderHandlesEmptyString(): void public function testFromHeaderHandlesWhitespaceOnly(): void { $scopes = TokenScopes::fromHeader(' '); - + $this->assertTrue($scopes->isEmpty()); } public function testHasReturnsTrueForExistingScope(): void { $scopes = new TokenScopes(['repo', 'read:org']); - + $this->assertTrue($scopes->has('repo')); $this->assertTrue($scopes->has('read:org')); } @@ -50,14 +50,14 @@ public function testHasReturnsTrueForExistingScope(): void public function testHasReturnsFalseForNonExistingScope(): void { $scopes = new TokenScopes(['repo']); - + $this->assertFalse($scopes->has('admin:org')); } public function testHasAnyReturnsTrueIfAtLeastOneScopeExists(): void { $scopes = new TokenScopes(['repo', 'read:org']); - + $this->assertTrue($scopes->hasAny(['repo', 'admin:org'])); $this->assertTrue($scopes->hasAny(['admin:org', 'read:org'])); } @@ -65,56 +65,56 @@ public function testHasAnyReturnsTrueIfAtLeastOneScopeExists(): void public function testHasAnyReturnsFalseIfNoScopeExists(): void { $scopes = new TokenScopes(['repo']); - + $this->assertFalse($scopes->hasAny(['admin:org', 'write:org'])); } public function testHasAllReturnsTrueIfAllScopesExist(): void { $scopes = new TokenScopes(['repo', 'read:org', 'admin:org']); - + $this->assertTrue($scopes->hasAll(['repo', 'read:org'])); } public function testHasAllReturnsFalseIfAnyScopeMissing(): void { $scopes = new TokenScopes(['repo']); - + $this->assertFalse($scopes->hasAll(['repo', 'admin:org'])); } public function testIsEmptyReturnsTrueForNoScopes(): void { $scopes = new TokenScopes([]); - + $this->assertTrue($scopes->isEmpty()); } public function testIsEmptyReturnsFalseForScopes(): void { $scopes = new TokenScopes(['repo']); - + $this->assertFalse($scopes->isEmpty()); } public function testCanReadRepositoriesWithRepoScope(): void { $scopes = new TokenScopes(['repo']); - + $this->assertTrue($scopes->canReadRepositories()); } public function testCanReadRepositoriesWithPublicRepoScope(): void { $scopes = new TokenScopes(['public_repo']); - + $this->assertTrue($scopes->canReadRepositories()); } public function testCanReadRepositoriesReturnsFalseWithoutScopes(): void { $scopes = new TokenScopes(['admin:org']); - + $this->assertFalse($scopes->canReadRepositories()); } @@ -122,7 +122,7 @@ public function testCanWriteRepositoriesRequiresFullRepoScope(): void { $scopesWithRepo = new TokenScopes(['repo']); $scopesWithPublic = new TokenScopes(['public_repo']); - + $this->assertTrue($scopesWithRepo->canWriteRepositories()); $this->assertFalse($scopesWithPublic->canWriteRepositories()); } @@ -138,14 +138,14 @@ public function testCanReadOrganizationsWithVariousScopes(): void public function testToStringReturnsCommaSeparatedList(): void { $scopes = new TokenScopes(['repo', 'read:org', 'admin:org']); - + $this->assertSame('repo, read:org, admin:org', $scopes->toString()); } public function testCountReturnsCorrectNumber(): void { $scopes = new TokenScopes(['repo', 'read:org', 'admin:org']); - + $this->assertSame(3, $scopes->count()); } } From d2fc1417ed0260290e310bb381c76fb644b039fc Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Thu, 26 Feb 2026 13:43:21 +0100 Subject: [PATCH 5/5] fix: Update TokenScopes constructor type hint for PHPStan Change constructor parameter type from array to array since the implementation explicitly filters non-string values. This fixes PHPStan error: "Parameter #1 \ of class Horde\GithubApiClient\TokenScopes constructor expects array, array given." The constructor behavior remains unchanged - it filters out non-strings using array_filter with is_string callback. PHPStan now passes with 0 errors. --- src/TokenScopes.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TokenScopes.php b/src/TokenScopes.php index f378207..686b4f0 100755 --- a/src/TokenScopes.php +++ b/src/TokenScopes.php @@ -13,7 +13,7 @@ class TokenScopes private readonly array $scopes; /** - * @param array $scopes + * @param array $scopes Array of scope strings (non-strings will be filtered out) */ public function __construct(array $scopes) {