diff --git a/.gitattributes b/.gitattributes index c09f81e..bb31b49 100644 --- a/.gitattributes +++ b/.gitattributes @@ -18,3 +18,7 @@ /UPGRADING.md export-ignore /phpstan.neon.dist export-ignore /phpstan-baseline.neon export-ignore +/phpstan-bootstrap.php export-ignore +/pint.json export-ignore +/.phpactor.json export-ignore +/.claude export-ignore diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml index fc0afd7..7ef5c88 100644 --- a/.github/workflows/update-changelog.yml +++ b/.github/workflows/update-changelog.yml @@ -14,19 +14,49 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: - ref: main + ref: master + + # v3.1.0 is already written in CHANGELOG.md. Skip when this release's + # name or tag already has a heading, so the release does not add it again. + - name: Check for an existing changelog entry + id: changelog + env: + RELEASE_NAME: ${{ github.event.release.name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + heading_exists() { + version="$1" + [ -n "$version" ] || return 1 + awk -v version="$version" ' + index($0, "## " version) == 1 { + rest = substr($0, length("## " version) + 1) + if (rest == "" || substr(rest, 1, 1) ~ /[[:space:]]/) { + found = 1 + } + } + END { exit found ? 0 : 1 } + ' CHANGELOG.md + } + + if heading_exists "$RELEASE_NAME" || heading_exists "$RELEASE_TAG"; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi - name: Update Changelog + if: steps.changelog.outputs.exists != 'true' uses: stefanzweifel/changelog-updater-action@v1 with: latest-version: ${{ github.event.release.name }} release-notes: ${{ github.event.release.body }} - - name: CommitDTO updated CHANGELOG + - name: Commit updated CHANGELOG + if: steps.changelog.outputs.exists != 'true' uses: stefanzweifel/git-auto-commit-action@v7 with: - branch: main + branch: master commit_message: Update CHANGELOG file_pattern: CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 432f364..2f57ef7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `github-client` will be documented in this file. ## Unreleased +## v3.1.0 - 2026-10-21 + +* GitHub App authentication: `Github::forInstallation()`, `Github::withApp()`, and the `installations()` resource +* Laravel 13 support +* Requires `saloonphp/saloon` ^4.3 and `firebase/php-jwt` ^7.0 +* Connector status and error help recognize strategy-based authentication, including GitHub App auth * Documentation matches the public methods and return types * Removed the unused skeleton migration, factory, and view * OAuth throws `AuthenticationException` when the client id, secret, or redirect URL is missing, and `getAuthorizationUrl()` accepts a state value diff --git a/README.md b/README.md index 5d7da89..b0f56e4 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ composer require jordanpartridge/github-client [![Downloads](https://img.shields.io/packagist/dt/jordanpartridge/github-client.svg?style=flat-square)](https://packagist.org/packages/jordanpartridge/github-client) [![License](https://img.shields.io/github/license/jordanpartridge/github-client?style=flat-square)](LICENSE.md) -`composer require` installs the latest tag on Packagist. The examples below match the default branch. `Github::forInstallation()`, `Github::withApp()`, and `installations()` are on that branch and are not in the `v3.0.0` tag. +`Github::forInstallation()`, `Github::withApp()`, and `installations()` are available from `v3.1.0`. ## Install @@ -286,7 +286,7 @@ Missing OAuth config throws `AuthenticationException` from `getAuthorizationUrl( ## GitHub App -These helpers are on the default branch. They are not in Packagist `v3.0.0`. +Available from `v3.1.0`. ```env GITHUB_APP_ID=123456 diff --git a/composer.json b/composer.json index 2f70889..b6c9099 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ } ], "require": { - "php": "^8.2|^8.3|^8.4", + "php": "^8.2", "firebase/php-jwt": "^7.0", "illuminate/contracts": "^11.0||^12.0||^13.0", "saloonphp/saloon": "^4.3", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..da2cbbe --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,81 @@ +# Architecture + +`jordanpartridge/github-client` is a Laravel package for the GitHub REST API. It is built on [Saloon](https://docs.saloon.dev) and returns typed PHP objects instead of raw arrays. It requires PHP 8.2 or newer and Laravel 11, 12, or 13. CI runs the suite on PHP 8.2, 8.3, and 8.4. + +## Layers + +``` +Facade (Github::) -> Github client -> Resource (repos, pullRequests, issues, ...) + -> Request class (one per endpoint) + -> GithubConnector (base URL, headers, auth, errors) + <- createDtoFromResponse() -> typed Data object +``` + +| Layer | Responsibility | Example | +| --- | --- | --- | +| Connector | Base URL, API version header, authentication, mapping HTTP errors to exceptions | [`src/Connectors/GithubConnector.php`](../src/Connectors/GithubConnector.php) | +| Requests | One class per endpoint: method, path, query, and response mapping | [`src/Requests/Pulls/Index.php`](../src/Requests/Pulls/Index.php) | +| Resources | Developer-facing methods grouped by API area | [`src/Resources/RepoResource.php`](../src/Resources/RepoResource.php) | +| Data | Typed objects built from responses | [`src/Data/Repos/RepoData.php`](../src/Data/Repos/RepoData.php) | +| Value objects and enums | Validated inputs and closed sets of options | [`src/ValueObjects/Repo.php`](../src/ValueObjects/Repo.php), [`src/Enums/MergeMethod.php`](../src/Enums/MergeMethod.php) | + +## Key design choices + +### 1. One connector owns every cross-cutting concern + +[`GithubConnector`](../src/Connectors/GithubConnector.php) sets the base URL, the `X-GitHub-Api-Version` header, and authentication. Its `getRequestException()` maps status codes to specific exceptions with a `match`: 401 to `AuthenticationException`, 403 to a rate-limit or permission error, 404 to `ResourceNotFoundException`, 422 to `ValidationException` with field-level details, 429 to `RateLimitException`, and 5xx to `NetworkException`. Calling code catches a meaningful type instead of checking status codes. + +### 2. One request class per endpoint, grouped behind resources + +Each endpoint is a small Saloon `Request` that knows its HTTP method, path, query, and how to turn the response into objects (`createDtoFromResponse()`). Resources such as [`RepoResource`](../src/Resources/RepoResource.php) and [`PullRequestResource`](../src/Resources/PullRequestResource.php) extend a `readonly` [`BaseResource`](../src/Resources/BaseResource.php) and give callers methods like `Github::pullRequests()->merge(...)`. Adding an endpoint means adding one request class and one resource method. + +### 3. Typed responses and validated inputs + +Responses map onto classes in [`src/Data`](../src/Data) with typed properties and `Carbon` dates, so IDEs and static analysis know what comes back. Inputs are validated at the edge: `Repo::fromFullName('owner/repo')` rejects malformed names before any HTTP call, and backed enums (`MergeMethod`, `Visibility`, `Direction`, `Pulls\State`) replace free-form strings for options. + +### 4. Pluggable authentication + +[`AuthenticationStrategy`](../src/Auth/AuthenticationStrategy.php) is a small interface (`getAuthorizationHeader`, `validate`, `needsRefresh`, `refresh`). The connector accepts either a token string or a strategy. [`GitHubAppAuthentication`](../src/Auth/GitHubAppAuthentication.php) signs an RS256 JWT with the App private key, exchanges it for an installation token, and refreshes that token before it expires. With no explicit credentials, [`TokenResolver`](../src/Auth/TokenResolver.php) tries `config('github-client.token')` (normally `GITHUB_TOKEN`), then `config('github-client.gh_token')` (normally `GH_TOKEN`), then `gh auth token`, and falls back to unauthenticated access for public data. + +```php +$github = Github::forInstallation($installationId); +$github->pullRequests()->all('owner', 'repo'); +``` + +### 5. Native Laravel integration + +[`GithubClientServiceProvider`](../src/GithubClientServiceProvider.php) uses `spatie/laravel-package-tools` for the config file and an Artisan command, and registers the connector as a singleton in the container. The package is auto-discovered, and the [`Github` facade](../src/Facades/Github.php) carries `@method` annotations so IDEs autocomplete every resource. The same client can be injected by type for code that avoids facades. + +### 6. Tests and CI + +* Pest tests use Saloon's `MockClient`, so no test touches the network. +* `Process::fake()` in [`tests/Pest.php`](../tests/Pest.php) keeps the GitHub CLI out of the test run. +* Architecture tests in [`tests/ArchTest.php`](../tests/ArchTest.php) enforce that resources extend `BaseResource`, requests extend Saloon `Request`, and no debug calls ship. +* GitHub Actions runs the suite across PHP 8.2, 8.3, 8.4 and Laravel 11, 12, 13 ([`run-tests.yml`](../.github/workflows/run-tests.yml)), plus PHPStan (Larastan) and Laravel Pint. +* Dependabot keeps Composer and Actions dependencies current. Minor and patch updates are set to auto-merge. + +## Usage + +```php +use JordanPartridge\GithubClient\Facades\Github; +use JordanPartridge\GithubClient\Enums\MergeMethod; + +$repo = Github::getRepo('laravel/framework'); // RepoData +$prs = Github::pullRequests()->all('laravel', 'framework'); +Github::pullRequests()->merge('owner', 'repo', 42, mergeMethod: MergeMethod::Squash); +``` + +## Where to look + +| Topic | File | +| --- | --- | +| Entry point | [`src/Github.php`](../src/Github.php) | +| HTTP, auth, error mapping | [`src/Connectors/GithubConnector.php`](../src/Connectors/GithubConnector.php) | +| Endpoint example | [`src/Requests/Pulls/Index.php`](../src/Requests/Pulls/Index.php) | +| Resource example | [`src/Resources/PullRequestResource.php`](../src/Resources/PullRequestResource.php) | +| Typed response | [`src/Data/Repos/RepoData.php`](../src/Data/Repos/RepoData.php) | +| Input validation | [`src/ValueObjects/Repo.php`](../src/ValueObjects/Repo.php) | +| GitHub App auth | [`src/Auth/GitHubAppAuthentication.php`](../src/Auth/GitHubAppAuthentication.php) | +| Container wiring | [`src/GithubClientServiceProvider.php`](../src/GithubClientServiceProvider.php) | +| Test conventions | [`tests/Pest.php`](../tests/Pest.php), [`tests/ArchTest.php`](../tests/ArchTest.php) | +| CI matrix | [`.github/workflows/run-tests.yml`](../.github/workflows/run-tests.yml) | diff --git a/docs/README.md b/docs/README.md index 354077b..5b4c769 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,9 @@ # GitHub Client documentation -The [README](../README.md) is the install and usage guide. These pages match the same public API. +The [README](../README.md) is the install and usage guide. The pages below cover the public API and how the package is structured. 1. [Getting started](./getting-started.md) 2. [API reference](./api-reference.md) 3. [Authentication and errors](./advanced-usage.md) 4. [How the client is put together](./core-concepts.md) +5. [Architecture](./ARCHITECTURE.md) diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index 4a7eea2..5730953 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -38,7 +38,7 @@ GITHUB_APP_PRIVATE_KEY= GITHUB_APP_PRIVATE_KEY_PATH= ``` -`Github::forInstallation($id)` and `Github::withApp($appId, $privateKey, $installationId)` return a new client. They are on the default branch and are not in the `v3.0.0` tag. The private key may be PEM text or a file path. +`Github::forInstallation($id)` and `Github::withApp($appId, $privateKey, $installationId)` return a new client. They are available from `v3.1.0`. The private key may be PEM text or a file path. ## Errors diff --git a/docs/api-reference.md b/docs/api-reference.md index f701f3d..e7aec7c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -107,7 +107,7 @@ Github::hasRateLimitExceeded(): bool ## GitHub App -On the default branch, and not in Packagist `v3.0.0`: +Available from `v3.1.0`: ```php Github::forInstallation(int $installationId): Github diff --git a/src/Connectors/GithubConnector.php b/src/Connectors/GithubConnector.php index fc5971a..918c651 100644 --- a/src/Connectors/GithubConnector.php +++ b/src/Connectors/GithubConnector.php @@ -130,7 +130,7 @@ public function getAuthenticationSource(): ?string */ public function getAuthenticationStatus(): array { - if (! $this->token) { + if (! $this->isAuthenticated()) { return [ 'authenticated' => false, 'source' => null, @@ -141,7 +141,7 @@ public function getAuthenticationStatus(): array return [ 'authenticated' => true, 'source' => $this->tokenSource, - 'token_preview' => substr($this->token, 0, 7) . '...', + 'token_preview' => $this->token ? substr($this->token, 0, 7) . '...' : null, 'message' => "Authenticated via {$this->tokenSource} (5,000 requests/hour).", ]; } @@ -192,7 +192,7 @@ protected function handleAuthenticationError(Response $response, string $message { $helpMessage = $message ?: 'GitHub authentication failed'; - if (! $this->token) { + if (! $this->isAuthenticated()) { $helpMessage .= "\n\nThis endpoint requires authentication. "; $helpMessage .= TokenResolver::getAuthenticationHelp(); } else { @@ -219,7 +219,7 @@ protected function handleForbiddenError(Response $response, string $message): \T // Otherwise it's a permissions issue $helpMessage = $message ?: 'Access to this GitHub resource is forbidden'; - if ($this->token) { + if ($this->isAuthenticated()) { $helpMessage .= "\n\nYour token may not have the required scopes for this operation."; $helpMessage .= "\nCheck that your token has the necessary permissions."; } else { @@ -259,14 +259,14 @@ protected function handleRateLimitError(Response $response, string $message): Ra { $headers = $response->headers(); $remaining = (int) ($headers->get('X-RateLimit-Remaining') ?? 0); - $limit = (int) ($headers->get('X-RateLimit-Limit') ?? ($this->token ? 5000 : 60)); + $limit = (int) ($headers->get('X-RateLimit-Limit') ?? ($this->isAuthenticated() ? 5000 : 60)); $reset = $headers->get('X-RateLimit-Reset'); $resetTime = $reset ? new \DateTimeImmutable('@' . $reset) : new \DateTimeImmutable('+1 hour'); // Build helpful message $helpMessage = $message ?: 'GitHub API rate limit exceeded'; - if (! $this->token) { + if (! $this->isAuthenticated()) { $helpMessage .= "\n\nYou're using unauthenticated requests (60/hour limit)."; $helpMessage .= "\n" . TokenResolver::getAuthenticationHelp(); } else { diff --git a/src/Resources/RepoResource.php b/src/Resources/RepoResource.php index da94430..d12baa5 100644 --- a/src/Resources/RepoResource.php +++ b/src/Resources/RepoResource.php @@ -26,7 +26,7 @@ * * Usage example: * ```php - * $repos = new RepoResource($connector); + * $repos = Github::repos(); * * // List all public repositories * $response = $repos->all( diff --git a/tests/Unit/Connectors/GithubConnectorAuthStatusTest.php b/tests/Unit/Connectors/GithubConnectorAuthStatusTest.php new file mode 100644 index 0000000..183b510 --- /dev/null +++ b/tests/Unit/Connectors/GithubConnectorAuthStatusTest.php @@ -0,0 +1,63 @@ +getAuthenticationStatus(); + + expect($status['authenticated'])->toBeTrue() + ->and($status['source'])->toBe('github_app') + ->and($status['token_preview'])->toBeNull(); +}); + +it('does not describe a strategy-based connector as unauthenticated when a request is rejected', function (int $status, string $message, string $exception, string $expected) { + $connector = githubAppConnector(); + $connector->withMockClient(new MockClient([ + MockResponse::make(['message' => $message], $status), + ])); + + $error = $connector->getRequestException($connector->send(new User())); + + expect($error)->toBeInstanceOf($exception) + ->and($error->getMessage())->toContain($expected) + ->and($error->getMessage())->not->toContain('unauthenticated') + ->and($error->getMessage())->not->toContain('requires authentication') + ->and($error->getMessage())->not->toContain('may require authentication'); +})->with([ + '401' => [401, 'Bad credentials', AuthenticationException::class, 'Current auth source: github_app'], + '403' => [403, 'Resource not accessible by integration', ApiException::class, 'required scopes'], + '429' => [429, 'API rate limit exceeded', RateLimitException::class, 'Authenticated via: github_app'], +]);