From 17c796f9b3907eee9b624d05791f203e422cbdff Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Mon, 6 Apr 2026 14:40:57 -0700 Subject: [PATCH 1/3] feat: add Checks API, file contents, and Laravel 13 support - Add ChecksResource with forRef(), combinedStatus(), allPassing() - Add GetCheckRunsForRef and GetCombinedStatus requests - Add FileResource::contents() and getContent() for reading files at a ref - Add GetContents request for /repos/{owner}/{repo}/contents/{path} - Bump illuminate/contracts to ^11.0||^12.0||^13.0 - 223 tests passing --- composer.json | 2 +- src/Github.php | 6 +++ src/Requests/Checks/GetCheckRunsForRef.php | 32 +++++++++++ src/Requests/Checks/GetCombinedStatus.php | 22 ++++++++ src/Requests/Files/GetContents.php | 30 +++++++++++ src/Resources/ChecksResource.php | 63 ++++++++++++++++++++++ src/Resources/FileResource.php | 29 ++++++++++ 7 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 src/Requests/Checks/GetCheckRunsForRef.php create mode 100644 src/Requests/Checks/GetCombinedStatus.php create mode 100644 src/Requests/Files/GetContents.php create mode 100644 src/Resources/ChecksResource.php diff --git a/composer.json b/composer.json index 5c3ac3b..2c2fd8a 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "require": { "php": "^8.2|^8.3|^8.4", "firebase/php-jwt": "^7.0", - "illuminate/contracts": "^11.0||^12.0", + "illuminate/contracts": "^11.0||^12.0||^13.0", "jordanpartridge/conduit-interfaces": "^1.1", "saloonphp/saloon": "^3.10", "spatie/laravel-package-tools": "^1.16|^2.0" diff --git a/src/Github.php b/src/Github.php index e976adc..6175ad2 100644 --- a/src/Github.php +++ b/src/Github.php @@ -9,6 +9,7 @@ use JordanPartridge\GithubClient\Exceptions\NetworkException; use JordanPartridge\GithubClient\Requests\RateLimit\Get; use JordanPartridge\GithubClient\Resources\ActionsResource; +use JordanPartridge\GithubClient\Resources\ChecksResource; use JordanPartridge\GithubClient\Resources\CommentsResource; use JordanPartridge\GithubClient\Resources\CommitResource; use JordanPartridge\GithubClient\Resources\FileResource; @@ -57,6 +58,11 @@ public function actions(): ActionsResource return new ActionsResource($this); } + public function checks(): ChecksResource + { + return new ChecksResource($this); + } + public function issues(): IssuesResource { return new IssuesResource($this); diff --git a/src/Requests/Checks/GetCheckRunsForRef.php b/src/Requests/Checks/GetCheckRunsForRef.php new file mode 100644 index 0000000..e6e377a --- /dev/null +++ b/src/Requests/Checks/GetCheckRunsForRef.php @@ -0,0 +1,32 @@ +owner}/{$this->repo}/commits/{$this->ref}/check-runs"; + } + + protected function defaultQuery(): array + { + return array_filter([ + 'per_page' => $this->perPage, + 'page' => $this->page, + ], fn ($value) => $value !== null); + } +} diff --git a/src/Requests/Checks/GetCombinedStatus.php b/src/Requests/Checks/GetCombinedStatus.php new file mode 100644 index 0000000..aef1625 --- /dev/null +++ b/src/Requests/Checks/GetCombinedStatus.php @@ -0,0 +1,22 @@ +owner}/{$this->repo}/commits/{$this->ref}/status"; + } +} diff --git a/src/Requests/Files/GetContents.php b/src/Requests/Files/GetContents.php new file mode 100644 index 0000000..fdba2c0 --- /dev/null +++ b/src/Requests/Files/GetContents.php @@ -0,0 +1,30 @@ +owner}/{$this->repo}/contents/{$this->path}"; + } + + protected function defaultQuery(): array + { + return array_filter([ + 'ref' => $this->ref, + ], fn ($value) => $value !== null); + } +} diff --git a/src/Resources/ChecksResource.php b/src/Resources/ChecksResource.php new file mode 100644 index 0000000..43621bc --- /dev/null +++ b/src/Resources/ChecksResource.php @@ -0,0 +1,63 @@ +>} + */ + public function forRef(string $owner, string $repo, string $ref): array + { + $response = $this->github()->connector()->send( + new GetCheckRunsForRef($owner, $repo, $ref), + ); + + return $response->json(); + } + + /** + * Get the combined commit status for a reference. + * + * Returns the overall state (success, failure, pending) and individual statuses. + * + * @return array{state: string, statuses: array>, total_count: int} + */ + public function combinedStatus(string $owner, string $repo, string $ref): array + { + $response = $this->github()->connector()->send( + new GetCombinedStatus($owner, $repo, $ref), + ); + + return $response->json(); + } + + /** + * Check if all checks pass for a given ref. + */ + public function allPassing(string $owner, string $repo, string $ref): bool + { + $checkRuns = $this->forRef($owner, $repo, $ref); + + if (empty($checkRuns['check_runs'])) { + return true; + } + + foreach ($checkRuns['check_runs'] as $run) { + if ($run['status'] !== 'completed') { + return false; + } + + if ($run['conclusion'] !== 'success' && $run['conclusion'] !== 'skipped') { + return false; + } + } + + return true; + } +} diff --git a/src/Resources/FileResource.php b/src/Resources/FileResource.php index f580c62..b4f7f9b 100644 --- a/src/Resources/FileResource.php +++ b/src/Resources/FileResource.php @@ -3,6 +3,7 @@ namespace JordanPartridge\GithubClient\Resources; use InvalidArgumentException; +use JordanPartridge\GithubClient\Requests\Files\GetContents; use JordanPartridge\GithubClient\Requests\Files\Index; use JordanPartridge\GithubClient\ValueObjects\Repo; use Saloon\Http\Response; @@ -19,4 +20,32 @@ public function all(string $repo_name, string $commit_sha): Response return $this->github()->connector()->send(new Index($repo->fullName(), $commit_sha)); } + + /** + * Get the contents of a file at a specific ref (branch, tag, or SHA). + * + * @return array{name: string, path: string, sha: string, size: int, content: string, encoding: string} + */ + public function contents(string $owner, string $repo, string $path, ?string $ref = null): array + { + $response = $this->github()->connector()->send( + new GetContents($owner, $repo, $path, $ref), + ); + + return $response->json(); + } + + /** + * Get the decoded contents of a file at a specific ref. + */ + public function getContent(string $owner, string $repo, string $path, ?string $ref = null): string + { + $data = $this->contents($owner, $repo, $path, $ref); + + if (($data['encoding'] ?? '') === 'base64') { + return base64_decode($data['content']); + } + + return $data['content'] ?? ''; + } } From 42a8e27042fd3ba06aeadaa825977ee6ba40151a Mon Sep 17 00:00:00 2001 From: jordanpartridge <9040417+jordanpartridge@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:41:25 +0000 Subject: [PATCH 2/3] Fix styling --- src/Auth/AuthenticationStrategy.php | 6 ++++-- src/Commands/GithubClientCommand.php | 3 ++- src/Resources/PullRequestResource.php | 3 ++- tests/ArchTest.php | 3 ++- tests/AuthenticationTest.php | 5 +++-- tests/CommitResourceTest.php | 2 +- tests/DTOPatternTest.php | 3 ++- tests/PullRequestsTest.php | 3 ++- tests/Unit/Data/RepoDataTest.php | 7 ++++--- tests/Unit/ValueObjects/RepoTest.php | 16 ++++++++-------- 10 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/Auth/AuthenticationStrategy.php b/src/Auth/AuthenticationStrategy.php index 5027a85..31dbb91 100644 --- a/src/Auth/AuthenticationStrategy.php +++ b/src/Auth/AuthenticationStrategy.php @@ -2,6 +2,8 @@ namespace JordanPartridge\GithubClient\Auth; +use JordanPartridge\GithubClient\Exceptions\AuthenticationException; + /** * Interface for different GitHub authentication strategies. */ @@ -15,7 +17,7 @@ public function getAuthorizationHeader(): string; /** * Validate that the authentication credentials are properly configured. * - * @throws \JordanPartridge\GithubClient\Exceptions\AuthenticationException + * @throws AuthenticationException */ public function validate(): void; @@ -32,7 +34,7 @@ public function needsRefresh(): bool; /** * Refresh the authentication if needed. * - * @throws \JordanPartridge\GithubClient\Exceptions\AuthenticationException + * @throws AuthenticationException */ public function refresh(): void; } diff --git a/src/Commands/GithubClientCommand.php b/src/Commands/GithubClientCommand.php index 55cbff9..4000398 100644 --- a/src/Commands/GithubClientCommand.php +++ b/src/Commands/GithubClientCommand.php @@ -5,6 +5,7 @@ use Illuminate\Console\Command; use JordanPartridge\GithubClient\Facades\Github; use JordanPartridge\GithubClient\ValueObjects\Repo; +use Illuminate\Support\Str; class GithubClientCommand extends Command { @@ -109,7 +110,7 @@ private function showCommits(): int substr($commit->sha, 0, 8), $commit->commit->author->name, $commit->commit->author->date->format('Y-m-d H:i'), - \Illuminate\Support\Str::limit($commit->commit->message, 50), + Str::limit($commit->commit->message, 50), ]; } diff --git a/src/Resources/PullRequestResource.php b/src/Resources/PullRequestResource.php index bede471..ab97e12 100644 --- a/src/Resources/PullRequestResource.php +++ b/src/Resources/PullRequestResource.php @@ -25,6 +25,7 @@ use JordanPartridge\GithubClient\Requests\Pulls\Reviews; use JordanPartridge\GithubClient\Requests\Pulls\Update; use JordanPartridge\GithubClient\Requests\Pulls\UpdateComment; +use Illuminate\Support\Collection; readonly class PullRequestResource extends BaseResource { @@ -99,7 +100,7 @@ public function reviews( string $owner, string $repo, int $number, - ): \Illuminate\Support\Collection { + ): Collection { $response = $this->github()->connector()->send(new Reviews("{$owner}/{$repo}", $number)); return $response->dto(); diff --git a/tests/ArchTest.php b/tests/ArchTest.php index f589e25..6bb6b4b 100644 --- a/tests/ArchTest.php +++ b/tests/ArchTest.php @@ -1,6 +1,7 @@ toExtend(\Saloon\Http\Request::class); + expect('JordanPartridge\GithubClient\Requests')->toExtend(Request::class); }); }); diff --git a/tests/AuthenticationTest.php b/tests/AuthenticationTest.php index 25b763d..9459e94 100644 --- a/tests/AuthenticationTest.php +++ b/tests/AuthenticationTest.php @@ -5,6 +5,7 @@ use JordanPartridge\GithubClient\Facades\Github; use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; +use JordanPartridge\GithubClient\Data\Repos\RepoData; describe('Authentication improvements', function () { it('allows unauthenticated requests for public repositories', function () { @@ -168,12 +169,12 @@ $connector->withMockClient($mockClient); // Create Github instance with unauthenticated connector - $github = new \JordanPartridge\GithubClient\Github($connector); + $github = new JordanPartridge\GithubClient\Github($connector); // Should be able to get public repo without auth $repo = $github->getRepo('owner/public-repo'); - expect($repo)->toBeInstanceOf(\JordanPartridge\GithubClient\Data\Repos\RepoData::class) + expect($repo)->toBeInstanceOf(RepoData::class) ->and($repo->name)->toBe('public-repo') ->and($repo->private)->toBeFalse(); }); diff --git a/tests/CommitResourceTest.php b/tests/CommitResourceTest.php index 94230b3..48bfcbf 100644 --- a/tests/CommitResourceTest.php +++ b/tests/CommitResourceTest.php @@ -16,7 +16,7 @@ ]); Github::connector()->withMockClient($mockClient); - $this->resource = new CommitResource(app(\JordanPartridge\GithubClient\Github::class)); + $this->resource = new CommitResource(app(JordanPartridge\GithubClient\Github::class)); }); it('can fetch all commits for a repository', function () { diff --git a/tests/DTOPatternTest.php b/tests/DTOPatternTest.php index 33378c6..76fb0c4 100644 --- a/tests/DTOPatternTest.php +++ b/tests/DTOPatternTest.php @@ -3,6 +3,7 @@ use JordanPartridge\GithubClient\Data\Pulls\PullRequestDetailDTO; use JordanPartridge\GithubClient\Data\Pulls\PullRequestDTOFactory; use JordanPartridge\GithubClient\Data\Pulls\PullRequestSummaryDTO; +use JordanPartridge\GithubClient\Data\Pulls\PullRequestDTO; describe('DTO Pattern: Summary vs Detail DTOs', function () { beforeEach(function () { @@ -175,7 +176,7 @@ describe('Backward Compatibility', function () { it('maintains compatibility with existing PullRequestDTO usage', function () { // The original PullRequestDTO still works exactly as before - $originalDto = \JordanPartridge\GithubClient\Data\Pulls\PullRequestDTO::fromApiResponse($this->detailResponseData); + $originalDto = PullRequestDTO::fromApiResponse($this->detailResponseData); expect($originalDto->number)->toBe(47) ->and($originalDto->comments)->toBe(1) diff --git a/tests/PullRequestsTest.php b/tests/PullRequestsTest.php index a7ff987..5556f33 100644 --- a/tests/PullRequestsTest.php +++ b/tests/PullRequestsTest.php @@ -7,6 +7,7 @@ use JordanPartridge\GithubClient\Facades\Github; use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; +use Illuminate\Support\Collection; beforeEach(function () { config(['github-client.token' => 'fake-token']); @@ -162,7 +163,7 @@ $reviews = Github::pullRequests()->reviews('test', 'repo', 1); expect($reviews) - ->toBeInstanceOf(\Illuminate\Support\Collection::class) + ->toBeInstanceOf(Collection::class) ->and($reviews->isEmpty())->toBeFalse() ->and($reviews->first()) ->toBeInstanceOf(PullRequestReviewDTO::class) diff --git a/tests/Unit/Data/RepoDataTest.php b/tests/Unit/Data/RepoDataTest.php index a3e1e98..7bf3cc7 100644 --- a/tests/Unit/Data/RepoDataTest.php +++ b/tests/Unit/Data/RepoDataTest.php @@ -2,6 +2,7 @@ use JordanPartridge\GithubClient\Data\GitUserData; use JordanPartridge\GithubClient\Data\Repos\RepoData; +use Carbon\Carbon; it('can create RepoData from array', function () { $data = [ @@ -193,9 +194,9 @@ labels_url: 'https://api.github.com/repos/user/test-repo/labels{/name}', releases_url: 'https://api.github.com/repos/user/test-repo/releases{/id}', deployments_url: 'https://api.github.com/repos/user/test-repo/deployments', - created_at: \Carbon\Carbon::parse('2011-01-26T19:01:12Z'), - updated_at: \Carbon\Carbon::parse('2024-01-26T19:14:43Z'), - pushed_at: \Carbon\Carbon::parse('2024-01-26T19:14:43Z'), + created_at: Carbon::parse('2011-01-26T19:01:12Z'), + updated_at: Carbon::parse('2024-01-26T19:14:43Z'), + pushed_at: Carbon::parse('2024-01-26T19:14:43Z'), git_url: 'git://github.com/user/test-repo.git', ssh_url: 'git@github.com:user/test-repo.git', clone_url: 'https://github.com/user/test-repo.git', diff --git a/tests/Unit/ValueObjects/RepoTest.php b/tests/Unit/ValueObjects/RepoTest.php index ac9d563..98158cd 100644 --- a/tests/Unit/ValueObjects/RepoTest.php +++ b/tests/Unit/ValueObjects/RepoTest.php @@ -14,20 +14,20 @@ it('throws exception for invalid format', function () { expect(fn () => Repo::fromFullName('invalid')) - ->toThrow(\InvalidArgumentException::class, 'Repository must be in format "owner/repo"'); + ->toThrow(InvalidArgumentException::class, 'Repository must be in format "owner/repo"'); }); it('throws exception for empty owner or name', function () { expect(fn () => Repo::fromFullName('/repository')) - ->toThrow(\InvalidArgumentException::class, 'Owner and repo name cannot be empty.'); + ->toThrow(InvalidArgumentException::class, 'Owner and repo name cannot be empty.'); expect(fn () => Repo::fromFullName('owner/')) - ->toThrow(\InvalidArgumentException::class, 'Owner and repo name cannot be empty.'); + ->toThrow(InvalidArgumentException::class, 'Owner and repo name cannot be empty.'); }); it('throws exception for invalid characters', function () { expect(fn () => Repo::fromFullName('owner@invalid/repository')) - ->toThrow(\InvalidArgumentException::class, "Invalid characters in repository name 'owner@invalid/repository'."); + ->toThrow(InvalidArgumentException::class, "Invalid characters in repository name 'owner@invalid/repository'."); }); it('accepts valid characters (letters, numbers, dots, underscores, hyphens)', function () { @@ -49,22 +49,22 @@ it('throws exception for empty owner', function () { expect(fn () => Repo::fromOwnerAndRepo('', 'repository')) - ->toThrow(\InvalidArgumentException::class, 'Owner cannot be empty.'); + ->toThrow(InvalidArgumentException::class, 'Owner cannot be empty.'); }); it('throws exception for empty repository name', function () { expect(fn () => Repo::fromOwnerAndRepo('owner', '')) - ->toThrow(\InvalidArgumentException::class, 'Repository name cannot be empty.'); + ->toThrow(InvalidArgumentException::class, 'Repository name cannot be empty.'); }); it('throws exception for invalid characters in owner', function () { expect(fn () => Repo::fromOwnerAndRepo('owner@invalid', 'repository')) - ->toThrow(\InvalidArgumentException::class, "Invalid characters in owner name 'owner@invalid'."); + ->toThrow(InvalidArgumentException::class, "Invalid characters in owner name 'owner@invalid'."); }); it('throws exception for invalid characters in repository name', function () { expect(fn () => Repo::fromOwnerAndRepo('owner', 'repo@invalid')) - ->toThrow(\InvalidArgumentException::class, "Invalid characters in repository name 'repo@invalid'."); + ->toThrow(InvalidArgumentException::class, "Invalid characters in repository name 'repo@invalid'."); }); it('accepts valid characters in both parameters', function () { From 207d1babb8e2ed9b41b395a17f301e45e7db1086 Mon Sep 17 00:00:00 2001 From: Jordan Partridge Date: Mon, 6 Apr 2026 14:44:58 -0700 Subject: [PATCH 3/3] fix: restore phpstan-baseline.neon and fix FileResource type errors - Restore phpstan-baseline.neon deleted in #111 cleanup (CI depends on it) - Baseline 4 pre-existing GithubConnector errors - Fix FileResource::getContent() null coalescing on always-present keys --- phpstan-baseline.neon | 21 +++++++++++++++++++++ src/Resources/FileResource.php | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 phpstan-baseline.neon diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..63a1354 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,21 @@ +parameters: + ignoreErrors: + - + message: "#^Instantiated class JordanPartridge\\\\GithubClient\\\\Exceptions\\\\ResourceNotFoundException not found\\.$#" + count: 1 + path: src/Connectors/GithubConnector.php + + - + message: "#^Missing parameter \\$message \\(string\\) in call to JordanPartridge\\\\GithubClient\\\\Exceptions\\\\NetworkException constructor\\.$#" + count: 1 + path: src/Connectors/GithubConnector.php + + - + message: "#^Strict comparison using \\=\\=\\= between non\\-falsy\\-string and '' will always evaluate to false\\.$#" + count: 1 + path: src/Connectors/GithubConnector.php + + - + message: "#^Unknown parameter \\$reason in call to JordanPartridge\\\\GithubClient\\\\Exceptions\\\\NetworkException constructor\\.$#" + count: 1 + path: src/Connectors/GithubConnector.php diff --git a/src/Resources/FileResource.php b/src/Resources/FileResource.php index b4f7f9b..9ab5c3e 100644 --- a/src/Resources/FileResource.php +++ b/src/Resources/FileResource.php @@ -42,10 +42,10 @@ public function getContent(string $owner, string $repo, string $path, ?string $r { $data = $this->contents($owner, $repo, $path, $ref); - if (($data['encoding'] ?? '') === 'base64') { + if ($data['encoding'] === 'base64') { return base64_decode($data['content']); } - return $data['content'] ?? ''; + return $data['content']; } }