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/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/composer.json b/composer.json index 872f1c4..f531f44 100644 --- a/composer.json +++ b/composer.json @@ -15,13 +15,17 @@ ], "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", "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/src/AuthenticatedUserRequestFactory.php b/src/AuthenticatedUserRequestFactory.php new file mode 100755 index 0000000..2509f29 --- /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/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 new file mode 100755 index 0000000..f1c2e9d --- /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..d1ad15f --- /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..686b4f0 --- /dev/null +++ b/src/TokenScopes.php @@ -0,0 +1,151 @@ + */ + private readonly array $scopes; + + /** + * @param array $scopes Array of scope strings (non-strings will be filtered out) + */ + 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/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 diff --git a/tests/unit/RateLimitTest.php b/tests/unit/RateLimitTest.php new file mode 100755 index 0000000..4f4c47c --- /dev/null +++ b/tests/unit/RateLimitTest.php @@ -0,0 +1,154 @@ +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..a92c219 --- /dev/null +++ b/tests/unit/TokenScopesTest.php @@ -0,0 +1,151 @@ +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()); + } +}