Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
96 changes: 96 additions & 0 deletions bin/demo-token-info.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env php
<?php

/**
* Demo script showing rate limit and token scope checking
*/
declare(strict_types=1);

namespace Horde\GithubApiClient;

require_once dirname(__DIR__) . '/vendor/autoload.php';

use Horde\Injector\Injector;
use Horde\Injector\TopLevel;
use Horde\Http\Client\Options;
use Horde\Http\Client\Curl as CurlClient;
use Horde\Http\StreamFactory;
use Horde\Http\RequestFactory;
use Horde\Http\ResponseFactory;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;

// Check for GitHub token
$strGithubApiToken = (string) getenv('GITHUB_TOKEN');
if (empty($strGithubApiToken)) {
echo "\nERROR: No GITHUB_TOKEN environment variable set.\n";
echo "Export your GitHub personal access token:\n";
echo " export GITHUB_TOKEN=ghp_your_token_here\n\n";
exit(1);
}

// Bootstrap the injector
$injector = new Injector(new TopLevel());
$injector->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";
8 changes: 6 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
34 changes: 34 additions & 0 deletions src/AuthenticatedUserRequestFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Horde\GithubApiClient;

use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;

/**
* Factory for creating authenticated user requests
*/
class AuthenticatedUserRequestFactory
{
public function __construct(
private readonly RequestFactoryInterface $requestFactory,
private readonly GithubApiConfig $config
) {}

/**
* Create a request to get authenticated user information
*
* @return RequestInterface
*/
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)
->withHeader('X-GitHub-Api-Version', $this->config->apiVersion);
}
}
42 changes: 42 additions & 0 deletions src/GithubApiClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@
if ($response->getReasonPhrase() == 'OK') {
$pullRequestData = json_decode((string) $response->getBody());
$prFactory = new GithubPullRequestFactory();
foreach ($pullRequestData as $pr) {

Check failure on line 54 in src/GithubApiClient.php

View workflow job for this annotation

GitHub Actions / CI

Argument of an invalid type mixed supplied for foreach, only iterables are supported.
$pullRequests[] = $prFactory->createFromApiResponse($pr);

Check failure on line 55 in src/GithubApiClient.php

View workflow job for this annotation

GitHub Actions / CI

Parameter #1 $apiResponse of method Horde\GithubApiClient\GithubPullRequestFactory::createFromApiResponse() expects stdClass, mixed given.
}
} else {
throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase());
Expand All @@ -60,6 +60,48 @@
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);

Check failure on line 77 in src/GithubApiClient.php

View workflow job for this annotation

GitHub Actions / CI

Parameter #1 $response of static method Horde\GithubApiClient\RateLimit::fromApiResponse() expects object, mixed given.
} 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<mixed> $repos
* @param string $json
Expand All @@ -76,6 +118,6 @@
throw new OutOfBoundsException('List does contain non-list element');
}
}
return $repos;

Check failure on line 121 in src/GithubApiClient.php

View workflow job for this annotation

GitHub Actions / CI

Method Horde\GithubApiClient\GithubApiClient::parseJsonAndMerge() should return array<array<int|string|Stringable|null>> but returns array<mixed>.
}
}
6 changes: 3 additions & 3 deletions src/GithubRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
8 changes: 4 additions & 4 deletions src/GithubRepositoryList.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
80 changes: 80 additions & 0 deletions src/RateLimit.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace Horde\GithubApiClient;

/**
* Represents GitHub API rate limit information
*/
class RateLimit
{
public function __construct(
public readonly int $limit,
public readonly int $remaining,
public readonly int $reset,
public readonly int $used
) {}

/**
* Create from API response
*
* @param object $response Decoded JSON response from /rate_limit endpoint
* @return self
*/
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,
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');
}
}
34 changes: 34 additions & 0 deletions src/RateLimitRequestFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Horde\GithubApiClient;

use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;

/**
* Factory for creating rate limit check requests
*/
class RateLimitRequestFactory
{
public function __construct(
private readonly RequestFactoryInterface $requestFactory,
private readonly GithubApiConfig $config
) {}

/**
* Create a request to check rate limit status
*
* @return RequestInterface
*/
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)
->withHeader('X-GitHub-Api-Version', $this->config->apiVersion);
}
}
Loading
Loading