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
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 34 additions & 4 deletions .github/workflows/update-changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
81 changes: 81 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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) |
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion docs/advanced-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions src/Connectors/GithubConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ public function getAuthenticationSource(): ?string
*/
public function getAuthenticationStatus(): array
{
if (! $this->token) {
if (! $this->isAuthenticated()) {
return [
'authenticated' => false,
'source' => null,
Expand All @@ -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).",
];
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/Resources/RepoResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
*
* Usage example:
* ```php
* $repos = new RepoResource($connector);
* $repos = Github::repos();
*
* // List all public repositories
* $response = $repos->all(
Expand Down
63 changes: 63 additions & 0 deletions tests/Unit/Connectors/GithubConnectorAuthStatusTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

use JordanPartridge\GithubClient\Auth\AuthenticationStrategy;
use JordanPartridge\GithubClient\Connectors\GithubConnector;
use JordanPartridge\GithubClient\Exceptions\ApiException;
use JordanPartridge\GithubClient\Exceptions\AuthenticationException;
use JordanPartridge\GithubClient\Exceptions\RateLimitException;
use JordanPartridge\GithubClient\Requests\User;
use Saloon\Http\Faking\MockClient;
use Saloon\Http\Faking\MockResponse;

function githubAppConnector(): GithubConnector
{
$strategy = new class () implements AuthenticationStrategy {
public function getAuthorizationHeader(): string
{
return 'Bearer installation-token';
}

public function validate(): void {}

public function getType(): string
{
return 'github_app';
}

public function needsRefresh(): bool
{
return false;
}

public function refresh(): void {}
};

return new GithubConnector($strategy);
}

it('reports a strategy-based connector as authenticated', function () {
$status = githubAppConnector()->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'],
]);
Loading