diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 56a1b6b..e47345f 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -12,7 +12,7 @@ on: jobs: test: runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 8 strategy: fail-fast: false matrix: @@ -45,6 +45,17 @@ jobs: tools: composer:v2 coverage: none + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-php-${{ matrix.php }}-laravel-${{ matrix.laravel }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-php-${{ matrix.php }}-laravel-${{ matrix.laravel }}-composer- + - name: Install dependencies run: | composer remove --dev --no-update larastan/larastan phpstan/phpstan-deprecation-rules phpstan/phpstan-phpunit phpstan/extension-installer diff --git a/config/github-client.php b/config/github-client.php index f08e346..9c193f7 100644 --- a/config/github-client.php +++ b/config/github-client.php @@ -65,6 +65,29 @@ ], ], + /* + |-------------------------------------------------------------------------- + | GitHub App Configuration + |-------------------------------------------------------------------------- + | + | Configuration for GitHub App authentication. GitHub Apps provide + | enhanced security and granular permissions compared to OAuth apps. + */ + 'github_app' => [ + // App ID from your GitHub App settings + 'app_id' => env('GITHUB_APP_ID'), + + // Installation ID (optional, can be set per request) + 'installation_id' => env('GITHUB_APP_INSTALLATION_ID'), + + // Private key for signing JWT tokens + // Can be the key contents directly or a path to the key file + 'private_key' => env('GITHUB_APP_PRIVATE_KEY'), + + // Path to private key file (alternative to direct key) + 'private_key_path' => env('GITHUB_APP_PRIVATE_KEY_PATH'), + ], + /* |-------------------------------------------------------------------------- | Rate Limiting diff --git a/src/Auth/GitHubAppAuthentication.php b/src/Auth/GitHubAppAuthentication.php index 85f9c6f..3762cb1 100644 --- a/src/Auth/GitHubAppAuthentication.php +++ b/src/Auth/GitHubAppAuthentication.php @@ -16,6 +16,8 @@ class GitHubAppAuthentication implements AuthenticationStrategy private ?DateTimeImmutable $installationTokenExpiry = null; + private ?object $connector = null; + public function __construct( private readonly string $appId, private readonly string $privateKey, @@ -107,6 +109,10 @@ private function hasValidInstallationToken(): bool /** * Refresh the installation token. + * + * Note: This method requires the connector to be set via setConnector() + * before calling refresh(). The connector is typically injected when + * used with GithubConnector. */ private function refreshInstallationToken(): void { @@ -114,11 +120,54 @@ private function refreshInstallationToken(): void throw AuthenticationException::githubAppAuthFailed('Installation ID required for installation token'); } - // This would typically make an API call to GitHub to get an installation token - // For now, we'll throw an exception indicating this needs to be implemented - throw AuthenticationException::githubAppAuthFailed( - 'Installation token refresh not yet implemented. Use GitHub client to fetch installation tokens.', + // Import the required classes + $createTokenRequest = new \JordanPartridge\GithubClient\Requests\Installations\CreateAccessToken( + (int) $this->installationId, ); + + // Make the API call to get the installation token + // This will use JWT authentication (app-level) to get the installation token + try { + $response = $this->makeApiRequest($createTokenRequest); + + if (! $response->successful()) { + throw AuthenticationException::githubAppAuthFailed( + 'Failed to refresh installation token: ' . ($response->json('message') ?? 'Unknown error'), + ); + } + + $data = $response->json(); + $this->installationToken = $data['token']; + $this->installationTokenExpiry = new DateTimeImmutable($data['expires_at']); + } catch (\Exception $e) { + throw AuthenticationException::githubAppAuthFailed( + 'Failed to refresh installation token: ' . $e->getMessage(), + ); + } + } + + /** + * Make an API request (to be implemented by connector integration). + * + * @throws AuthenticationException + */ + private function makeApiRequest(object $request): mixed + { + if (! isset($this->connector)) { + throw AuthenticationException::githubAppAuthFailed( + 'Connector not set. Cannot refresh installation token without connector.', + ); + } + + return $this->connector->send($request); + } + + /** + * Set the connector for making API requests. + */ + public function setConnector(object $connector): void + { + $this->connector = $connector; } /** diff --git a/src/Auth/TokenResolver.php b/src/Auth/TokenResolver.php index cac0d26..339e863 100644 --- a/src/Auth/TokenResolver.php +++ b/src/Auth/TokenResolver.php @@ -22,27 +22,27 @@ class TokenResolver */ public static function resolve(): ?string { - // 1. Check GitHub CLI first (if available) - if ($token = self::getGitHubCliToken()) { - self::$lastSource = 'GitHub CLI'; - - return $token; - } - - // 2. Check environment variables + // 1. Check environment variables first (fast, no external process) if ($token = self::getEnvironmentToken()) { self::$lastSource = env('GITHUB_TOKEN') ? 'GITHUB_TOKEN' : 'GH_TOKEN'; return $token; } - // 3. Check Laravel config + // 2. Check Laravel config if ($token = self::getConfigToken()) { self::$lastSource = 'config'; return $token; } + // 3. Check GitHub CLI last (slower, spawns external process) + if ($token = self::getGitHubCliToken()) { + self::$lastSource = 'GitHub CLI'; + + return $token; + } + // 4. Return null - authentication is optional for public repos self::$lastSource = null; @@ -55,14 +55,14 @@ public static function resolve(): ?string private static function getGitHubCliToken(): ?string { try { - // Check if gh CLI is available - $result = Process::run('which gh'); + // Check if gh CLI is available (with timeout to prevent hanging in CI) + $result = Process::timeout(2)->run('which gh'); if (! $result->successful()) { return null; } - // Get token from gh CLI - $result = Process::run('gh auth token'); + // Get token from gh CLI (with timeout to prevent hanging if not authenticated) + $result = Process::timeout(3)->run('gh auth token'); if ($result->successful()) { $token = trim($result->output()); if (! empty($token)) { @@ -70,7 +70,7 @@ private static function getGitHubCliToken(): ?string } } } catch (\Exception) { - // gh CLI not available or not authenticated + // gh CLI not available, not authenticated, or timed out } return null; @@ -124,10 +124,7 @@ public static function hasAuthentication(): bool */ public static function getAuthenticationStatus(): string { - if ($token = self::getGitHubCliToken()) { - return 'Authenticated via GitHub CLI'; - } - + // Check in same order as resolve() for consistency if ($token = self::getEnvironmentToken()) { $source = env('GITHUB_TOKEN') ? 'GITHUB_TOKEN' : 'GH_TOKEN'; @@ -138,6 +135,10 @@ public static function getAuthenticationStatus(): string return 'Authenticated via config file'; } + if ($token = self::getGitHubCliToken()) { + return 'Authenticated via GitHub CLI'; + } + return 'No authentication (public access only)'; } diff --git a/src/Connectors/GithubConnector.php b/src/Connectors/GithubConnector.php index 85681a0..fc5971a 100644 --- a/src/Connectors/GithubConnector.php +++ b/src/Connectors/GithubConnector.php @@ -2,6 +2,8 @@ namespace JordanPartridge\GithubClient\Connectors; +use JordanPartridge\GithubClient\Auth\AuthenticationStrategy; +use JordanPartridge\GithubClient\Auth\GitHubAppAuthentication; use JordanPartridge\GithubClient\Auth\TokenResolver; use JordanPartridge\GithubClient\Exceptions\ApiException; use JordanPartridge\GithubClient\Exceptions\AuthenticationException; @@ -30,15 +32,25 @@ class GithubConnector extends Connector protected ?string $token; protected ?string $tokenSource; + protected ?AuthenticationStrategy $authStrategy = null; /** * Create a new GitHub connector. * - * @param string|null $token Optional GitHub token. If null, will attempt to resolve from multiple sources. + * @param string|AuthenticationStrategy|null $token Token, auth strategy, or null to auto-resolve */ - public function __construct(?string $token = null) + public function __construct(string|AuthenticationStrategy|null $token = null) { - if ($token !== null) { + if ($token instanceof AuthenticationStrategy) { + $this->authStrategy = $token; + $this->token = null; + $this->tokenSource = $token->getType(); + + // Inject connector into auth strategy if it's a GitHub App + if ($token instanceof GitHubAppAuthentication) { + $token->setConnector($this); + } + } elseif ($token !== null) { $this->token = $token; $this->tokenSource = 'explicit'; } else { @@ -73,7 +85,24 @@ protected function defaultHeaders(): array */ protected function defaultAuth(): ?Authenticator { - if (! $this->token || $this->token === '') { + // Use auth strategy if set + if ($this->authStrategy) { + // Check if token needs refresh + if ($this->authStrategy->needsRefresh()) { + $this->authStrategy->refresh(); + } + + // Get the authorization header value + $authHeader = $this->authStrategy->getAuthorizationHeader(); + + // Extract token from "Bearer " format + $token = str_replace('Bearer ', '', $authHeader); + + return new TokenAuthenticator($token); + } + + // Fall back to simple token authentication + if (! $this->token) { return null; } @@ -85,7 +114,7 @@ protected function defaultAuth(): ?Authenticator */ public function isAuthenticated(): bool { - return ! empty($this->token); + return $this->authStrategy !== null || ! empty($this->token); } /** @@ -145,7 +174,7 @@ public function getRequestException(Response $response, ?\Throwable $senderExcep 429 => $this->handleRateLimitError($response, $message), 500, 502, 503, 504 => new NetworkException( operation: 'GitHub API request', - reason: "Server error ({$status}): {$message}", + message: "Server error ({$status}): {$message}", previous: $senderException, ), default => new ApiException( diff --git a/src/Data/Installations/InstallationData.php b/src/Data/Installations/InstallationData.php new file mode 100644 index 0000000..cabd4ca --- /dev/null +++ b/src/Data/Installations/InstallationData.php @@ -0,0 +1,50 @@ + $this->id, + 'account_login' => $this->account_login, + 'account_type' => $this->account_type, + 'target_type' => $this->target_type, + 'permissions' => $this->permissions, + 'events' => $this->events, + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + 'app_slug' => $this->app_slug, + ], fn ($value) => $value !== null); + } +} diff --git a/src/Data/Installations/InstallationTokenData.php b/src/Data/Installations/InstallationTokenData.php new file mode 100644 index 0000000..cd8c156 --- /dev/null +++ b/src/Data/Installations/InstallationTokenData.php @@ -0,0 +1,45 @@ + $this->token, + 'expires_at' => $this->expires_at->toISOString(), + 'permissions' => $this->permissions, + 'repository_selection' => $this->repository_selection, + ], fn ($value) => $value !== null); + } + + public function isExpired(): bool + { + return Carbon::now()->greaterThanOrEqualTo($this->expires_at); + } + + public function expiresIn(): int + { + return (int) Carbon::now()->diffInSeconds($this->expires_at, false); + } +} diff --git a/src/Exceptions/ResourceNotFoundException.php b/src/Exceptions/ResourceNotFoundException.php new file mode 100644 index 0000000..a3f41bd --- /dev/null +++ b/src/Exceptions/ResourceNotFoundException.php @@ -0,0 +1,27 @@ +json(); + $message = $data['message'] ?? "{$resourceType} not found"; + + return new self($message, $response); + } +} diff --git a/src/Github.php b/src/Github.php index e976adc..326f897 100644 --- a/src/Github.php +++ b/src/Github.php @@ -8,10 +8,12 @@ use JordanPartridge\GithubClient\Exceptions\ApiException; use JordanPartridge\GithubClient\Exceptions\NetworkException; use JordanPartridge\GithubClient\Requests\RateLimit\Get; +use JordanPartridge\GithubClient\Auth\GitHubAppAuthentication; use JordanPartridge\GithubClient\Resources\ActionsResource; use JordanPartridge\GithubClient\Resources\CommentsResource; use JordanPartridge\GithubClient\Resources\CommitResource; use JordanPartridge\GithubClient\Resources\FileResource; +use JordanPartridge\GithubClient\Resources\InstallationsResource; use JordanPartridge\GithubClient\Resources\IssuesResource; use JordanPartridge\GithubClient\Resources\PullRequestResource; use JordanPartridge\GithubClient\Resources\ReleasesResource; @@ -72,6 +74,11 @@ public function releases(): ReleasesResource return new ReleasesResource($this); } + public function installations(): InstallationsResource + { + return new InstallationsResource($this); + } + /** * Get the current rate limit status for all resources. * @@ -170,4 +177,86 @@ public function deleteRepo(string $fullName): Response return $this->repos()->delete($repo); } + + /** + * Create a new GitHub client authenticated as a GitHub App installation. + * + * This creates a new instance configured to act on behalf of a specific + * installation, using installation tokens instead of JWT tokens. + * + * @param int $installationId The installation ID to authenticate as + * + * @return self A new Github instance authenticated for the installation + */ + public static function forInstallation(int $installationId): self + { + // Get GitHub App config + $appId = config('github-client.github_app.app_id'); + $privateKey = self::resolvePrivateKey(); + + if (! $appId || ! $privateKey) { + throw new \RuntimeException( + 'GitHub App not configured. Set GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY or GITHUB_APP_PRIVATE_KEY_PATH', + ); + } + + $auth = new GitHubAppAuthentication( + appId: $appId, + privateKey: $privateKey, + installationId: (string) $installationId, + ); + + $connector = new GithubConnector($auth); + + return new self($connector); + } + + /** + * Create a new GitHub client with custom GitHub App credentials. + * + * This allows using GitHub App authentication without relying on config files. + * + * @param string $appId The GitHub App ID + * @param string $privateKey The private key (PEM format or base64 encoded) + * @param int|null $installationId Optional installation ID + * + * @return self A new Github instance with GitHub App authentication + */ + public static function withApp(string $appId, string $privateKey, ?int $installationId = null): self + { + $auth = new GitHubAppAuthentication( + appId: $appId, + privateKey: $privateKey, + installationId: $installationId ? (string) $installationId : null, + ); + + $connector = new GithubConnector($auth); + + return new self($connector); + } + + /** + * Resolve the private key from config. + */ + private static function resolvePrivateKey(): ?string + { + // Try direct key first + $key = config('github-client.github_app.private_key'); + if ($key) { + return $key; + } + + // Try file path + $path = config('github-client.github_app.private_key_path'); + if ($path && file_exists($path)) { + return file_get_contents($path); + } + + // Try base_path for relative paths + if ($path && file_exists(base_path($path))) { + return file_get_contents(base_path($path)); + } + + return null; + } } diff --git a/src/Requests/Installations/CreateAccessToken.php b/src/Requests/Installations/CreateAccessToken.php new file mode 100644 index 0000000..b9402ea --- /dev/null +++ b/src/Requests/Installations/CreateAccessToken.php @@ -0,0 +1,46 @@ + $this->repositories, + 'permissions' => $this->permissions, + ], fn ($value) => $value !== null); + } + + public function createDtoFromResponse(Response $response): InstallationTokenData + { + return InstallationTokenData::fromArray($response->json()); + } + + public function resolveEndpoint(): string + { + return "/app/installations/{$this->installationId}/access_tokens"; + } +} diff --git a/src/Requests/Installations/GetInstallation.php b/src/Requests/Installations/GetInstallation.php new file mode 100644 index 0000000..02eea9d --- /dev/null +++ b/src/Requests/Installations/GetInstallation.php @@ -0,0 +1,27 @@ +json()); + } + + public function resolveEndpoint(): string + { + return "/app/installations/{$this->installationId}"; + } +} diff --git a/src/Requests/Installations/ListInstallations.php b/src/Requests/Installations/ListInstallations.php new file mode 100644 index 0000000..9c66676 --- /dev/null +++ b/src/Requests/Installations/ListInstallations.php @@ -0,0 +1,48 @@ +per_page !== null && ($this->per_page < 1 || $this->per_page > 100)) { + throw new InvalidArgumentException('Per page must be between 1 and 100'); + } + } + + protected function defaultQuery(): array + { + return array_filter([ + 'per_page' => $this->per_page, + 'page' => $this->page, + ], fn ($value) => $value !== null); + } + + public function createDtoFromResponse(Response $response): mixed + { + return array_map( + fn ($installation) => InstallationData::fromArray($installation), + $response->json(), + ); + } + + public function resolveEndpoint(): string + { + return '/app/installations'; + } +} diff --git a/src/Resources/InstallationsResource.php b/src/Resources/InstallationsResource.php new file mode 100644 index 0000000..165b71f --- /dev/null +++ b/src/Resources/InstallationsResource.php @@ -0,0 +1,122 @@ + Array of installation data objects + * + * @link https://docs.github.com/en/rest/apps/installations#list-installations-for-the-authenticated-app + */ + public function list(?int $per_page = null, ?int $page = null): array + { + $response = $this->connector()->send(new ListInstallations($per_page, $page)); + + return $response->dto(); + } + + /** + * Get details about a specific installation. + * + * Requires GitHub App authentication (JWT token). + * + * @param int $installationId The installation ID + * + * @return InstallationData The installation data + * + * @link https://docs.github.com/en/rest/apps/installations#get-an-installation-for-the-authenticated-app + */ + public function get(int $installationId): InstallationData + { + $response = $this->connector()->send(new GetInstallation($installationId)); + + return $response->dto(); + } + + /** + * Create an installation access token. + * + * Generates a new access token that can be used to make authenticated + * requests on behalf of the installation. Tokens expire after 1 hour. + * + * Requires GitHub App authentication (JWT token). + * + * @param int $installationId The installation ID + * @param array|null $repositories Optional array of repository names to limit access + * @param array|null $permissions Optional permissions to request + * + * @return InstallationTokenData The installation access token data + * + * @link https://docs.github.com/en/rest/apps/installations#create-an-installation-access-token-for-an-app + */ + public function createAccessToken( + int $installationId, + ?array $repositories = null, + ?array $permissions = null, + ): InstallationTokenData { + $response = $this->connector()->send( + new CreateAccessToken($installationId, $repositories, $permissions), + ); + + return $response->dto(); + } + + /** + * List all installations with automatic pagination. + * + * This method automatically fetches all installations across multiple pages. + * + * @param int|null $per_page Number of results per page (max 100, default 100) + * + * @return array Array of all installation data objects + */ + public function listAll(?int $per_page = 100): array + { + $page = 1; + $allInstallations = []; + $maxPages = 100; + + do { + if ($page > $maxPages) { + throw new \RuntimeException("Maximum page limit ($maxPages) exceeded during pagination"); + } + + $response = $this->connector()->send(new ListInstallations($per_page, $page)); + $installations = $response->dto(); + + if (! empty($installations)) { + $allInstallations = array_merge($allInstallations, $installations); + } + + $linkHeader = $response->header('Link'); + $hasNextPage = $linkHeader && str_contains($linkHeader, 'rel="next"'); + + $page++; + } while ($hasNextPage && ! empty($installations)); + + return $allInstallations; + } +} diff --git a/tests/AuthenticationTest.php b/tests/AuthenticationTest.php index 25b763d..97dfece 100644 --- a/tests/AuthenticationTest.php +++ b/tests/AuthenticationTest.php @@ -183,23 +183,22 @@ expect(true)->toBeTrue(); })->skip('Mock client exception handling needs investigation'); - it('checks GitHub CLI token first if available', function () { - // This test verifies priority - if gh CLI is authenticated, - // that token should be used even if env vars exist - - // Note: This is more of an integration test - // In a real scenario, we'd mock the Process facade + it('provides authentication status from available sources', function () { + // This test verifies that TokenResolver returns a valid authentication status + // Priority order: env vars -> config -> GitHub CLI -> none + // (env vars and config are checked first for speed, CLI last due to process overhead) $status = TokenResolver::getAuthenticationStatus(); - // If GitHub CLI is available and authenticated, it should be mentioned - if (str_contains($status, 'GitHub CLI')) { - expect($status)->toContain('GitHub CLI'); - } else { - // Otherwise, it should use env var or config - expect($status)->toContain('environment variable') - ->or->toContain('config') - ->or->toContain('No authentication'); + // Status should indicate one of the valid authentication sources + $validSources = ['environment variable', 'config', 'GitHub CLI', 'No authentication']; + $hasValidSource = false; + foreach ($validSources as $source) { + if (str_contains($status, $source)) { + $hasValidSource = true; + break; + } } + expect($hasValidSource)->toBeTrue(); }); }); diff --git a/tests/Pest.php b/tests/Pest.php index 0245386..dfb019d 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,14 @@ in(__DIR__); +// Set token early to prevent CLI calls during bootstrap +putenv('GITHUB_TOKEN=test-token-for-testing'); + +uses(TestCase::class) + ->beforeEach(function () { + // Fake all Process calls - no external CLI in tests + Process::fake(); + }) + ->in(__DIR__);