Skip to content
Closed
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
23 changes: 23 additions & 0 deletions config/github-client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 53 additions & 4 deletions src/Auth/GitHubAppAuthentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -107,18 +109,65 @@ 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
{
if (! $this->installationId) {
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;
}

/**
Expand Down
37 changes: 33 additions & 4 deletions src/Connectors/GithubConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -30,15 +32,25 @@

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 {
Expand Down Expand Up @@ -73,7 +85,24 @@
*/
protected function defaultAuth(): ?Authenticator
{
// 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 <token>" format
$token = str_replace('Bearer ', '', $authHeader);

return new TokenAuthenticator($token);
}

// Fall back to simple token authentication
if (! $this->token || $this->token === '') {

Check failure on line 105 in src/Connectors/GithubConnector.php

View workflow job for this annotation

GitHub Actions / phpstan (8.4)

Strict comparison using === between non-falsy-string and '' will always evaluate to false.

Check failure on line 105 in src/Connectors/GithubConnector.php

View workflow job for this annotation

GitHub Actions / phpstan (8.3)

Strict comparison using === between non-falsy-string and '' will always evaluate to false.

Check failure on line 105 in src/Connectors/GithubConnector.php

View workflow job for this annotation

GitHub Actions / phpstan (8.2)

Strict comparison using === between non-falsy-string and '' will always evaluate to false.
return null;
}

Expand All @@ -85,7 +114,7 @@
*/
public function isAuthenticated(): bool
{
return ! empty($this->token);
return $this->authStrategy !== null || ! empty($this->token);
}

/**
Expand Down Expand Up @@ -137,15 +166,15 @@
return match ($status) {
401 => $this->handleAuthenticationError($response, $message),
403 => $this->handleForbiddenError($response, $message),
404 => new ResourceNotFoundException(

Check failure on line 169 in src/Connectors/GithubConnector.php

View workflow job for this annotation

GitHub Actions / phpstan (8.4)

Instantiated class JordanPartridge\GithubClient\Exceptions\ResourceNotFoundException not found.

Check failure on line 169 in src/Connectors/GithubConnector.php

View workflow job for this annotation

GitHub Actions / phpstan (8.3)

Instantiated class JordanPartridge\GithubClient\Exceptions\ResourceNotFoundException not found.

Check failure on line 169 in src/Connectors/GithubConnector.php

View workflow job for this annotation

GitHub Actions / phpstan (8.2)

Instantiated class JordanPartridge\GithubClient\Exceptions\ResourceNotFoundException not found.
message: $message ?: 'GitHub resource not found',
response: $response,
),
422 => $this->handleValidationError($response, $data),
429 => $this->handleRateLimitError($response, $message),
500, 502, 503, 504 => new NetworkException(

Check failure on line 175 in src/Connectors/GithubConnector.php

View workflow job for this annotation

GitHub Actions / phpstan (8.4)

Missing parameter $message (string) in call to JordanPartridge\GithubClient\Exceptions\NetworkException constructor.

Check failure on line 175 in src/Connectors/GithubConnector.php

View workflow job for this annotation

GitHub Actions / phpstan (8.3)

Missing parameter $message (string) in call to JordanPartridge\GithubClient\Exceptions\NetworkException constructor.

Check failure on line 175 in src/Connectors/GithubConnector.php

View workflow job for this annotation

GitHub Actions / phpstan (8.2)

Missing parameter $message (string) in call to JordanPartridge\GithubClient\Exceptions\NetworkException constructor.
operation: 'GitHub API request',
reason: "Server error ({$status}): {$message}",

Check failure on line 177 in src/Connectors/GithubConnector.php

View workflow job for this annotation

GitHub Actions / phpstan (8.4)

Unknown parameter $reason in call to JordanPartridge\GithubClient\Exceptions\NetworkException constructor.

Check failure on line 177 in src/Connectors/GithubConnector.php

View workflow job for this annotation

GitHub Actions / phpstan (8.3)

Unknown parameter $reason in call to JordanPartridge\GithubClient\Exceptions\NetworkException constructor.

Check failure on line 177 in src/Connectors/GithubConnector.php

View workflow job for this annotation

GitHub Actions / phpstan (8.2)

Unknown parameter $reason in call to JordanPartridge\GithubClient\Exceptions\NetworkException constructor.
previous: $senderException,
),
default => new ApiException(
Expand Down
50 changes: 50 additions & 0 deletions src/Data/Installations/InstallationData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace JordanPartridge\GithubClient\Data\Installations;

use Carbon\Carbon;

class InstallationData
{
public function __construct(
public int $id,
public string $account_login,
public string $account_type,
public ?string $target_type = null,
public ?array $permissions = null,
public ?array $events = null,
public ?Carbon $created_at = null,
public ?Carbon $updated_at = null,
public ?string $app_slug = null,
) {}

public static function fromArray(array $data): self
{
return new self(
id: $data['id'],
account_login: $data['account']['login'] ?? '',
account_type: $data['account']['type'] ?? '',
target_type: $data['target_type'] ?? null,
permissions: $data['permissions'] ?? null,
events: $data['events'] ?? null,
created_at: isset($data['created_at']) ? Carbon::parse($data['created_at']) : null,
updated_at: isset($data['updated_at']) ? Carbon::parse($data['updated_at']) : null,
app_slug: $data['app_slug'] ?? null,
);
}

public function toArray(): array
{
return array_filter([
'id' => $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);
}
}
45 changes: 45 additions & 0 deletions src/Data/Installations/InstallationTokenData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace JordanPartridge\GithubClient\Data\Installations;

use Carbon\Carbon;

class InstallationTokenData
{
public function __construct(
public string $token,
public Carbon $expires_at,
public ?array $permissions = null,
public ?string $repository_selection = null,
) {}

public static function fromArray(array $data): self
{
return new self(
token: $data['token'],
expires_at: Carbon::parse($data['expires_at']),
permissions: $data['permissions'] ?? null,
repository_selection: $data['repository_selection'] ?? null,
);
}

public function toArray(): array
{
return array_filter([
'token' => $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 Carbon::now()->diffInSeconds($this->expires_at, false);

Check failure on line 43 in src/Data/Installations/InstallationTokenData.php

View workflow job for this annotation

GitHub Actions / phpstan (8.4)

Method JordanPartridge\GithubClient\Data\Installations\InstallationTokenData::expiresIn() should return int but returns float.

Check failure on line 43 in src/Data/Installations/InstallationTokenData.php

View workflow job for this annotation

GitHub Actions / phpstan (8.3)

Method JordanPartridge\GithubClient\Data\Installations\InstallationTokenData::expiresIn() should return int but returns float.

Check failure on line 43 in src/Data/Installations/InstallationTokenData.php

View workflow job for this annotation

GitHub Actions / phpstan (8.2)

Method JordanPartridge\GithubClient\Data\Installations\InstallationTokenData::expiresIn() should return int but returns float.
}
Comment on lines +41 to +44
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix return type mismatch in expiresIn().

PHPStan reports that Carbon::diffInSeconds($this->expires_at, false) returns float|int, but the method signature declares int. This causes failures across PHP 8.2, 8.3, and 8.4.

Apply this diff to cast the result to int:

 public function expiresIn(): int
 {
-    return Carbon::now()->diffInSeconds($this->expires_at, false);
+    return (int) Carbon::now()->diffInSeconds($this->expires_at, false);
 }
🧰 Tools
🪛 GitHub Check: phpstan (8.2)

[failure] 43-43:
Method JordanPartridge\GithubClient\Data\Installations\InstallationTokenData::expiresIn() should return int but returns float.

🪛 GitHub Check: phpstan (8.3)

[failure] 43-43:
Method JordanPartridge\GithubClient\Data\Installations\InstallationTokenData::expiresIn() should return int but returns float.

🪛 GitHub Check: phpstan (8.4)

[failure] 43-43:
Method JordanPartridge\GithubClient\Data\Installations\InstallationTokenData::expiresIn() should return int but returns float.

🤖 Prompt for AI Agents
In src/Data/Installations/InstallationTokenData.php around lines 41 to 44, the
expiresIn() method returns Carbon::now()->diffInSeconds($this->expires_at,
false) which PHPStan reports as float|int while the method signature declares
int; cast the result to int before returning (e.g. wrap the diffInSeconds call
with (int) or use intval()) so the return type matches the declared int and
PHPStan errors are resolved.

}
89 changes: 89 additions & 0 deletions src/Github.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
}
Comment on lines +241 to +261
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add error handling for file operations.

Lines 252 and 257 use file_get_contents() without checking if the operation succeeds. If the file read fails, false will be returned and used as the private key, leading to cryptic authentication errors downstream.

Apply this diff to add proper error handling:

     // Try file path
     $path = config('github-client.github_app.private_key_path');
     if ($path && file_exists($path)) {
-        return file_get_contents($path);
+        $key = file_get_contents($path);
+        if ($key === false) {
+            throw new \RuntimeException("Failed to read private key from path: {$path}");
+        }
+        return $key;
     }

     // Try base_path for relative paths
     if ($path && file_exists(base_path($path))) {
-        return file_get_contents(base_path($path));
+        $key = file_get_contents(base_path($path));
+        if ($key === false) {
+            throw new \RuntimeException("Failed to read private key from path: " . base_path($path));
+        }
+        return $key;
     }

     return null;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
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)) {
$key = file_get_contents($path);
if ($key === false) {
throw new \RuntimeException("Failed to read private key from path: {$path}");
}
return $key;
}
// Try base_path for relative paths
if ($path && file_exists(base_path($path))) {
$key = file_get_contents(base_path($path));
if ($key === false) {
throw new \RuntimeException("Failed to read private key from path: " . base_path($path));
}
return $key;
}
return null;
}
🤖 Prompt for AI Agents
In src/Github.php around lines 241 to 261, the code reads private key files with
file_get_contents() but doesn't check for failure; if file_get_contents()
returns false the false value will be treated as the key. Update both
file_get_contents() calls to capture the result, check if the return === false,
and on failure either throw a clear exception (e.g. RuntimeException with path
and error) or log an error and return null; if successful, return the string
(optionally trim it). Ensure the file_exists checks remain and that no false
value is returned as the private key.

}
Loading
Loading