diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index d0d11113..6a116516 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,186 +1,40 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Notes for working in this repository. -## Project Overview +## What this package is -A Laravel package providing a strongly-typed GitHub API client built on Saloon. This package integrates with Laravel's service container and configuration system while providing resource-based access to GitHub's API endpoints. +A Laravel package that calls the GitHub REST API with Saloon. Resources live in `src/Resources` and build request classes in `src/Requests`. Responses are mapped by `createDtoFromResponse()` onto plain PHP objects in `src/Data`. Those objects are not Spatie Laravel Data objects. -## Key Commands +The facade `JordanPartridge\GithubClient\Facades\Github` resolves `JordanPartridge\GithubClient\Github`. Inject that class. There is no `GithubConnectorInterface`. -### Testing -```bash -# Run all tests -vendor/bin/pest - -# Run specific test file -vendor/bin/pest tests/Feature/ReposTest.php +The connector is `JordanPartridge\GithubClient\Connectors\GithubConnector`. Its base URL is `https://api.github.com`. -# Run with coverage -vendor/bin/pest --coverage +## Commands -# Run tests matching pattern -vendor/bin/pest --filter="it can get a repository" -``` - -### Code Quality ```bash -# Format code with Laravel Pint -vendor/bin/pint - -# Check formatting without fixing -vendor/bin/pint --test - -# Run static analysis -vendor/bin/phpstan analyse +composer test +composer analyse +composer format ``` -### Development -```bash -# Install dependencies -composer install - -# Update dependencies -composer update - -# Run tests in CI environment -GITHUB_TOKEN=dummy-token-for-testing vendor/bin/pest -``` - -## Architecture - -### Core Components - -**Resource Pattern**: All API endpoints are organized as resources extending `BaseResource`: -- Resources live in `src/Resources/` and handle high-level API operations -- Each resource maps to a GitHub API entity (repos, pulls, issues, etc.) -- Resources use request classes in `src/Requests/` for actual API calls -- Resources return strongly-typed DTOs from `src/Data/` - -**Request/Response Flow**: -1. User calls method on resource (e.g., `Github::repos()->get()`) -2. Resource creates appropriate request object from `src/Requests/` -3. Request is sent via Saloon connector (`ConduitUi\GitHubConnector\GithubConnector`) -4. Response is transformed into DTO using `spatie/laravel-data` -5. Typed DTO is returned to user - -**Authentication System**: -- Uses `ConduitUi\GitHubConnector\GithubConnector` with token authentication -- Token configured via `GITHUB_TOKEN` environment variable -- OAuth support available through `GithubOAuth` class for user authentication flows - -### Directory Structure - -``` -src/ -├── Auth/ # Authentication strategies -├── Commands/ # Artisan commands -├── Concerns/ # Shared traits -├── Contracts/ # Interfaces -├── Data/ # DTOs for API responses -│ ├── Commits/ -│ ├── Issues/ -│ ├── Pulls/ -│ ├── Releases/ -│ └── Repos/ -├── Enums/ # Type-safe enums for API parameters -├── Exceptions/ # Custom exception classes -├── Facades/ # Laravel facades -├── Requests/ # Saloon request classes -│ ├── Actions/ -│ ├── Commits/ -│ ├── Files/ -│ ├── Issues/ -│ ├── Pulls/ -│ ├── Releases/ -│ └── Repos/ -├── Resources/ # Resource classes for API entities -└── ValueObjects/ # Value objects (e.g., Repo) -``` +Tests are Pest. HTTP is faked with Saloon's `MockClient`. `GITHUB_TOKEN` is set in `tests/Pest.php` so token resolution does not call the GitHub CLI. -### Key Design Patterns - -**Value Objects**: The `Repo` value object validates and parses repository identifiers: -- `Repo::fromFullName("owner/repo")` - Creates from full name -- `Repo::fromUrl("https://github.com/owner/repo")` - Creates from URL -- Provides `owner()` and `name()` accessors - -**Enum-Based Validation**: All API parameters use PHP enums for type safety: -- `Visibility` (PUBLIC, PRIVATE, ALL) -- `Sort` (CREATED, UPDATED, PUSHED, FULL_NAME) -- `Direction` (ASC, DESC) -- `MergeMethod` (MERGE, SQUASH, REBASE) -- Issue/PR specific enums in their respective namespaces - -**DTO Pattern**: All API responses are mapped to DTOs using `spatie/laravel-data`: -- Provides type safety and IDE autocompletion -- Handles nullable fields gracefully -- Supports nested data structures - -## Testing Approach - -Tests use Pest PHP with Laravel testing helpers. Key patterns: -- Mock Saloon responses using `MockClient` -- Test both success and failure scenarios -- Validate DTO structure and data transformation -- Test enum validation and parameter handling - -## Known Issues & TODOs - -### ReleasesResource Bug -The `ReleasesResource` class incorrectly calls `Repo::fromOwnerAndRepo()` which doesn't exist. Should use `Repo::fromFullName()` instead. Affected lines: ~77, ~106, ~122 - -### Authentication Enhancement Needed -- Make authentication optional for public repositories -- Support GitHub CLI token reuse (`gh auth token`) -- Better error messages for missing tokens -- Check authentication sources in order: GitHub CLI → env vars → config - -### Missing Helper Method -Add `Repo::fromOwnerAndRepo(string $owner, string $repo)` convenience method that internally calls `fromFullName()`. - -## Integration Points - -### Laravel Service Provider -`GithubClientServiceProvider` registers: -- GitHub facade binding -- Configuration publishing -- Service container bindings - -### Configuration -Published via `config/github-client.php`: -- Token configuration -- OAuth settings -- API endpoint customization - -### Facade Usage -```php -use JordanPartridge\GithubClient\Facades\Github; - -$repos = Github::repos()->all(); -``` - -### Dependency Injection -```php -use JordanPartridge\GithubClient\Contracts\GithubConnectorInterface; - -public function __construct(GithubConnectorInterface $github) { - $this->github = $github; -} -``` +## Public API shape -## Error Handling +- `Github::repos()->all()` returns a Saloon response. Call `dto()`. +- `Github::repos()->get()` takes `Repo::fromFullName('owner/repo')`. +- `Github::getRepo('owner/repo')` is the string helper. +- `Github::issues()->forRepo()` returns a Saloon response. `dto()` drops items that include a `pull_request` key. +- `Github::pullRequests()->merge()` returns a boolean. +- `Github::commits()->get($repo, $sha)` and `Github::files()->all($repo, $sha)` require a 40 character SHA. +- `Github::files()` has `all` only. +- `Github::releases()` has `all`, `get`, and `latest`. +- `Github::actions()` methods are `listWorkflows`, `getWorkflowRuns`, and `triggerWorkflow`. +- Token order is `github-client.token`, then `github-client.gh_token`, then `gh auth token`. -Custom exceptions provide context-specific error information: -- `ApiException` - API request failures -- `AuthenticationException` - Auth issues -- `RateLimitException` - Rate limit exceeded -- `ValidationException` - Input validation failures -- `NetworkException` - Connectivity issues +`Github::forInstallation()`, `Github::withApp()`, and `installations()` are on the default branch. They are not in the Packagist `v3.0.0` tag. -## Performance Considerations +## Tests and docs -- Auto-pagination available via `allWithPagination()` methods -- Rate limit checking via `getRateLimitStatus()` -- Response caching handled by Saloon -- Lazy loading of resources to minimize memory usage \ No newline at end of file +README examples are exercised in `tests/DocumentationExamplesTest.php`. If a signature changes, update that test and the README together. diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index dae87e64..f6deb49e 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -5,6 +5,13 @@ on: paths: - '**.php' - 'phpstan.neon.dist' + - 'phpstan-baseline.neon' + - '.github/workflows/phpstan.yml' + pull_request: + paths: + - '**.php' + - 'phpstan.neon.dist' + - 'phpstan-baseline.neon' - '.github/workflows/phpstan.yml' jobs: @@ -17,7 +24,7 @@ jobs: matrix: php: ['8.2', '8.3', '8.4'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index bc3177c4..2d2a2810 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -8,6 +8,13 @@ on: - 'phpunit.xml.dist' - 'composer.json' - 'composer.lock' + pull_request: + paths: + - '**.php' + - '.github/workflows/run-tests.yml' + - 'phpunit.xml.dist' + - 'composer.json' + - 'composer.lock' jobs: test: diff --git a/.gitignore b/.gitignore index 9062a6a3..c616d0da 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,6 @@ _ide_helper_models.php phpunit.xml phpstan.neon testbench.yaml -/docs /coverage **/.claude/settings.local.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 853dba5e..432f3643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to `github-client` will be documented in this file. ## Unreleased +* 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 +* Tokens are read from config (`GITHUB_TOKEN`, then `GH_TOKEN`) before the GitHub CLI +* PHPStan baseline no longer ignores errors the analyser does not report +* The test badge tracks the `master` branch + ## v2.5.0 - Complete GitHub Issues API - 2025-01-19 ### Major New Features @@ -43,29 +50,29 @@ All notable changes to `github-client` will be documented in this file. #### Key Features ```php -// List user issues -$issues = GitHub::issues()->index( +// List user issues. all() returns a Saloon response. +$issues = Github::issues()->all( state: State::OPEN, labels: 'bug,enhancement', sort: Sort::CREATED, - direction: Direction::DESC -); + direction: Direction::DESC, +)->dto(); // Get specific issue -$issue = GitHub::issues()->get('owner', 'repo', 42); +$issue = Github::issues()->get('owner', 'repo', 42); // Create new issue -$newIssue = GitHub::issues()->create('owner', 'repo', 'Bug Report', - bodyText: 'Detailed description...', +$newIssue = Github::issues()->create('owner', 'repo', 'Bug Report', + body: 'Detailed description...', labels: ['bug', 'priority-high'], assignees: ['username'] ); // Add comment -$comment = GitHub::issues()->createComment('owner', 'repo', 42, 'Thanks for reporting!'); +$comment = Github::issues()->addComment('owner', 'repo', 42, 'Thanks for reporting!'); // Auto-pagination support -$allIssues = GitHub::issues()->index()->collect(); +$allIssues = Github::issues()->allForRepo('owner', 'repo'); ``` #### Quality Improvements @@ -113,7 +120,7 @@ $allIssues = GitHub::issues()->index()->collect(); * Added support for retrieving individual commits by SHA ```php -GitHub::commits()->get('sha-hash'); +Github::commits()->get('owner/repo', '0123456789abcdef0123456789abcdef01234567'); ``` #### Details @@ -130,10 +137,9 @@ The new commit endpoint provides authenticated access to detailed commit informa #### Usage Example ```php -$commit = GitHub::commits()->get('abc123def456...'); -// Access commit data -$author = $commit->author; -$message = $commit->message; +$commit = Github::commits()->get('owner/repo', '0123456789abcdef0123456789abcdef01234567'); +$author = $commit->commit->author->name; +$message = $commit->commit->message; $changes = $commit->files; ``` diff --git a/README.md b/README.md index f2a1db4c..5d7da890 100644 --- a/README.md +++ b/README.md @@ -1,266 +1,406 @@ # GitHub Client for Laravel -**Stop wrestling with GitHub's API. Start shipping.** +A Laravel package for GitHub's REST API. It uses [Saloon](https://docs.saloon.dev) for HTTP, maps responses onto PHP objects, and exposes repositories, issues, pull requests, commits, releases, and Actions from a facade or the container. ```bash composer require jordanpartridge/github-client ``` -```php -// That's it. You're done. -$repos = Github::repos()->all(); -$issues = Github::issues()->forRepo('owner', 'repo'); -$pr = Github::pullRequests()->create('owner', 'repo', 'My PR', 'feature', 'main'); -``` - -[![Tests](https://img.shields.io/github/actions/workflow/status/jordanpartridge/github-client/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/jordanpartridge/github-client/actions) +[![Tests](https://img.shields.io/github/actions/workflow/status/jordanpartridge/github-client/run-tests.yml?branch=master&label=tests&style=flat-square)](https://github.com/jordanpartridge/github-client/actions/workflows/run-tests.yml) +[![PHPStan](https://img.shields.io/github/actions/workflow/status/jordanpartridge/github-client/phpstan.yml?branch=master&label=phpstan&style=flat-square)](https://github.com/jordanpartridge/github-client/actions/workflows/phpstan.yml) [![Latest Version](https://img.shields.io/packagist/v/jordanpartridge/github-client.svg?style=flat-square)](https://packagist.org/packages/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) ---- - -## Why This Package? - -- **Laravel Native** - Built for Laravel, not wrapped around it -- **Typed Responses** - DTOs everywhere, not arrays -- **Auto-Pagination** - `allWithPagination()` just works -- **Type-Safe Params** - Enums, not magic strings -- **Easy Testing** - Saloon MockClient built in +`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. -**One line: Modern GitHub API for modern Laravel.** +## Install ---- - -## Quick Start +```bash +composer require jordanpartridge/github-client +``` -### 1. Install +Publish the config file: ```bash -composer require jordanpartridge/github-client +php artisan vendor:publish --tag=github-client-config ``` -### 2. Configure +## Configure -Add your token to `.env`: +The package reads a token from config. The published file maps that value from the environment. ```env GITHUB_TOKEN=ghp_your_token_here ``` -Get one at [github.com/settings/tokens](https://github.com/settings/tokens) +`GH_TOKEN` is also read, through `github-client.gh_token`, when `GITHUB_TOKEN` is empty. + +Resolution order in `TokenResolver`: + +1. `config('github-client.token')` +2. `config('github-client.gh_token')` +3. `gh auth token`, when the GitHub CLI is installed and authenticated + +If none of those is set, requests go out without a token. Public repositories still respond. Unauthenticated calls use GitHub's lower rate limit. + +Create a token at [github.com/settings/tokens](https://github.com/settings/tokens). Listing the authenticated user's repositories needs a token. Creating issues and pull requests needs a token that can write to the repository. -### 3. Use +OAuth and GitHub App settings are in the same config file. See [OAuth](#oauth) and [GitHub App](#github-app). + +## Usage ```php use JordanPartridge\GithubClient\Facades\Github; -// Get your repos -$repos = Github::repos()->all(); +$repos = Github::repos()->all()->dto(); + +$repo = Github::getRepo('jordanpartridge/github-client'); + +echo $repo->name; +echo $repo->stargazers_count; +echo $repo->owner->login; +``` + +`repos()->all()` returns a Saloon response. `dto()` turns the body into a list of `RepoData` objects. `Github::getRepo()` accepts an `owner/repo` string, validates it, and returns one `RepoData`. -// Get ALL your repos (auto-pagination, no limits) -$allRepos = Github::repos()->allWithPagination(); +`repos()->get()` takes a `Repo` value object, not a string: -// Get a specific repo -$repo = Github::repos()->get('jordanpartridge/github-client'); +```php +use JordanPartridge\GithubClient\ValueObjects\Repo; -echo $repo->name; // "github-client" -echo $repo->stargazers_count; // 🤞 -echo $repo->owner->login; // "jordanpartridge" +$repo = Github::repos()->get(Repo::fromFullName('jordanpartridge/github-client')); ``` ---- +`repos()->delete()` takes the same value object and returns the Saloon response. + +### Pagination + +`all()` requests one page (GitHub's default is 30). `allWithPagination()` follows the `Link` header and returns every `RepoData` it can fetch, up to 1000 pages. + +```php +use JordanPartridge\GithubClient\Enums\Direction; +use JordanPartridge\GithubClient\Enums\Sort; +use JordanPartridge\GithubClient\Enums\Visibility; -## Real Examples +$repos = Github::repos()->allWithPagination( + visibility: Visibility::PUBLIC, + sort: Sort::CREATED, + direction: Direction::DESC, +); +``` -### Create an Issue +### Issues ```php +use JordanPartridge\GithubClient\Enums\Issues\State; + $issue = Github::issues()->create( owner: 'jordanpartridge', repo: 'github-client', title: 'Bug: Something broke', body: 'Here are the details...', - labels: ['bug', 'high-priority'], - assignees: ['jordanpartridge'] + labels: ['bug'], + assignees: ['jordanpartridge'], ); -echo $issue->number; // 42 -echo $issue->html_url; // Direct link to GitHub +echo $issue->number; +echo $issue->html_url; + +$bugs = Github::issues()->forRepo( + owner: 'jordanpartridge', + repo: 'github-client', + state: State::OPEN, + labels: 'bug', +)->dto(); + +$comments = Github::issues()->comments('jordanpartridge', 'github-client', $issue->number); + +Github::issues()->addComment('jordanpartridge', 'github-client', $issue->number, 'Fixed in the latest release.'); + +Github::issues()->close('jordanpartridge', 'github-client', $issue->number); ``` -### Create a Pull Request +`forRepo()` and `all()` return a Saloon response. Call `dto()` for `IssueDTO` objects. `allForRepo()` paginates the same way `allWithPagination()` does and returns the array itself. + +GitHub's issue list includes pull requests. List endpoints skip items that have a `pull_request` key. + +`labels` on `forRepo()` is a comma-separated string. `labels` on `create()` is a list of label names. + +### Pull requests ```php use JordanPartridge\GithubClient\Enums\MergeMethod; -// Create PR $pr = Github::pullRequests()->create( owner: 'jordanpartridge', repo: 'github-client', - title: 'Add new feature', + title: 'Add a feature', head: 'feature-branch', base: 'main', - body: 'This PR adds the thing.', - draft: false + body: 'This pull request adds the feature.', + draft: false, ); -// Merge it -Github::pullRequests()->merge( +$merged = Github::pullRequests()->merge( owner: 'jordanpartridge', repo: 'github-client', number: $pr->number, - mergeMethod: MergeMethod::Squash + mergeMethod: MergeMethod::Squash, ); ``` -### Work with Issue Comments +`merge()` returns `true` when GitHub reports the pull request as merged. + +`pullRequests()->all($owner, $repo)` returns `PullRequestDTO` objects. Pass GitHub query parameters in the third argument, for example `['state' => 'open']`. `files($owner, $repo, $number)` returns the changed files. There is no `commits()` method on the pull request resource. Use `commits()` for commit history. + +### Commits, releases, files, Actions ```php -// Get all comments on an issue -$comments = Github::issues()->comments('owner', 'repo', 42); +$commits = Github::commits()->all('laravel/framework'); -// Add a comment -Github::issues()->addComment('owner', 'repo', 42, 'Fixed in latest release.'); +$commit = Github::commits()->get( + 'laravel/framework', + '0123456789abcdef0123456789abcdef01234567', +); -// Close the issue -Github::issues()->close('owner', 'repo', 42); +echo $commit->sha; +echo $commit->commit->message; ``` -### Filter with Enums (Type-Safe) +`commits()->get()` and `files()->all()` require a 40 character hexadecimal SHA. ```php -use JordanPartridge\GithubClient\Enums\Visibility; -use JordanPartridge\GithubClient\Enums\Sort; -use JordanPartridge\GithubClient\Enums\Direction; -use JordanPartridge\GithubClient\Enums\Issues\State; +$latest = Github::releases()->latest('laravel', 'framework'); -// Only public repos, sorted by creation date -$repos = Github::repos()->allWithPagination( - visibility: Visibility::PUBLIC, - sort: Sort::CREATED, - direction: Direction::DESC -); - -// Open bugs only -$bugs = Github::issues()->forRepo( - owner: 'jordanpartridge', - repo: 'github-client', - state: State::OPEN, - labels: 'bug' -); +echo $latest->tag_name; ``` ---- +`releases()` can list releases and fetch one by id. It cannot create a release. -## Testing Your App +```php +$tree = Github::files()->all( + 'laravel/framework', + '0123456789abcdef0123456789abcdef01234567', +); +``` -Saloon's MockClient makes testing trivial: +`files()->all()` returns the Saloon response for that commit's tree. There is no `get()` or `contents()` method. ```php -use Saloon\Http\Faking\MockClient; -use Saloon\Http\Faking\MockResponse; -use JordanPartridge\GithubClient\Facades\Github; +$workflows = Github::actions()->listWorkflows('laravel', 'framework'); -it('creates issues', function () { - $mock = new MockClient([ - '*' => MockResponse::make([ - 'id' => 1, - 'number' => 42, - 'title' => 'Test Issue', - 'state' => 'open', - ], 201), - ]); +$runs = Github::actions()->getWorkflowRuns('laravel', 'framework', $workflowId); - Github::connector()->withMockClient($mock); +Github::actions()->triggerWorkflow('laravel', 'framework', $workflowId, [ + 'ref' => 'main', +]); +``` - $issue = Github::issues()->create('owner', 'repo', 'Test Issue'); +`ref` is required. `listWorkflows()` and `getWorkflowRuns()` return Saloon responses. Owner and repo are separate arguments. - expect($issue->number)->toBe(42); - expect($issue->title)->toBe('Test Issue'); -}); -``` +### Rate limit -No HTTP calls. No flaky tests. No rate limits in CI. +```php +$limits = Github::getRateLimitStatus(); +$core = Github::getRateLimitForResource('core'); +``` ---- +The package does not retry rate-limited calls. A rate limit response throws `RateLimitException`. -## Available Resources +## Resources -| Resource | Methods | -|----------|---------| -| `repos()` | `all`, `allWithPagination`, `get`, `delete`, `search` | -| `issues()` | `all`, `forRepo`, `allForRepo`, `get`, `create`, `update`, `close`, `reopen`, `comments`, `addComment` | -| `pullRequests()` | `all`, `get`, `create`, `merge`, `files`, `commits` | -| `commits()` | `all`, `get` | -| `files()` | `get`, `contents` | -| `releases()` | `all`, `get`, `latest`, `create` | -| `actions()` | `workflows`, `runs`, `trigger` | +| Call | Methods | Returns | +| --- | --- | --- | +| `repos()` | `all`, `allWithPagination`, `get(Repo)`, `delete(Repo)`, `search` | `all` is a Saloon response. `allWithPagination` and `get` return `RepoData`. `search` returns `SearchRepositoriesData`. | +| `issues()` | `all`, `forRepo`, `allForRepo`, `get`, `create`, `update`, `close`, `reopen`, `comments`, `addComment`, `getComment`, `updateComment`, `deleteComment` | `all` and `forRepo` are Saloon responses. `get`, `create`, `update`, `close`, and `reopen` return `IssueDTO`. | +| `pullRequests()` | `all`, `get`, `create`, `update`, `merge`, `files`, `diff`, `comments`, `summaries`, `detail` | `all` and `get` return pull request DTOs. `merge` returns a boolean. | +| `commits()` | `all(owner/repo)`, `get(owner/repo, sha)` | `CommitData` | +| `files()` | `all(owner/repo, sha)` | Saloon response | +| `releases()` | `all`, `get`, `latest` | `ReleaseData` | +| `actions()` | `listWorkflows`, `getWorkflowRuns`, `triggerWorkflow` | Saloon response | +| `comments()` | `forPullRequest`, `byAuthor`, `byAuthorType`, `bySeverity`, `forFile` | Pull request review comments. Pass `'repository' => 'owner/repo'` in the filters. `pullRequests()->comments($owner, $repo, $number)` is the direct call. | +| `installations()` | `list`, `listAll`, `get`, `createAccessToken` | GitHub App installation data. Requires app authentication. | ---- +`Github::getRepo('owner/repo')` and `Github::deleteRepo('owner/repo')` validate the name and call the repository resource. -## Dependency Injection +## Dependency injection -Don't like facades? Use DI: +The container binds `JordanPartridge\GithubClient\Github`. There is no connector interface. ```php -use JordanPartridge\GithubClient\Contracts\GithubConnectorInterface; +use JordanPartridge\GithubClient\Github; -class MyService +class RepoListing { public function __construct( - private readonly GithubConnectorInterface $github + private readonly Github $github, ) {} - public function getMyRepos() + public function handle(): array { - return $this->github->repos()->all(); + return $this->github->repos()->all()->dto(); } } ``` ---- +## OAuth -## OAuth Flow +Set these before resolving the `GithubOAuth` facade: -Building a GitHub app? OAuth is built in: +```env +GITHUB_CLIENT_ID=your-client-id +GITHUB_CLIENT_SECRET=your-client-secret +GITHUB_REDIRECT_URL=https://example.com/auth/github/callback +``` ```php +use JordanPartridge\GithubClient\Connectors\GithubConnector; use JordanPartridge\GithubClient\Facades\GithubOAuth; -// 1. Redirect user to GitHub -return redirect(GithubOAuth::getAuthorizationUrl(['repo', 'user'])); +$state = bin2hex(random_bytes(16)); -// 2. Handle callback -$token = GithubOAuth::getAccessToken($request->code); +return redirect(GithubOAuth::getAuthorizationUrl(['repo', 'user'], $state)); +``` + +Store `$state` in the session and compare it on the callback. The package does not store or check `state`. If you omit it, `getAuthorizationUrl()` adds a random value and discards it. + +```php +$token = GithubOAuth::getAccessToken($request->string('code')); -// 3. Use their token $github = new GithubConnector($token); -$theirRepos = $github->repos()->all(); +$repos = $github->send( + new \JordanPartridge\GithubClient\Requests\Repos\Index(), +); ``` ---- +To keep using the facade-style resources with that token, build a client around the connector: -## Requirements +```php +$github = new \JordanPartridge\GithubClient\Github(new GithubConnector($token)); -- PHP 8.2+ -- Laravel 11 or 12 +$repos = $github->repos()->all()->dto(); +``` ---- +Missing OAuth config throws `AuthenticationException` from `getAuthorizationUrl()` and `getAccessToken()`. -## Contributing +## GitHub App + +These helpers are on the default branch. They are not in Packagist `v3.0.0`. + +```env +GITHUB_APP_ID=123456 +GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" +``` + +`GITHUB_APP_PRIVATE_KEY_PATH` is used when `GITHUB_APP_PRIVATE_KEY` is empty. The path may be absolute or relative to the Laravel base path. + +```php +use JordanPartridge\GithubClient\Facades\Github; + +$github = Github::forInstallation(12345678); + +$repos = $github->repos()->all()->dto(); +``` + +`Github::withApp($appId, $privateKey, $installationId)` does the same without reading config. `installations()` lists installations and creates installation tokens. Those calls need a JWT, which `forInstallation()` and `withApp()` set up. Pass the installation id to `forInstallation()`. + +## Artisan -PRs welcome. Run tests first: +```bash +php artisan github-client test +php artisan github-client repo --repo=laravel/framework +php artisan github-client commits --repo=laravel/framework --limit=10 +``` + +`test` calls `repos()->all(per_page: 1)`. `repo` prints fields from `RepoData`. `commits` prints the SHA, author, date, and message. + +## Testing + +Mock the connector with Saloon. `IssueDTO` reads a full issue payload, including `user`. A short fixture will throw while mapping. `tests/TestCase.php` builds a complete one, and `tests/DocumentationExamplesTest.php` runs the calls from this README. + +```php +use JordanPartridge\GithubClient\Facades\Github; +use Saloon\Http\Faking\MockClient; +use Saloon\Http\Faking\MockResponse; + +it('creates an issue', function () { + Github::connector()->withMockClient(new MockClient([ + '*' => MockResponse::make([ + 'id' => 1, + 'number' => 42, + 'title' => 'Test Issue', + 'body' => 'Details', + 'state' => 'open', + 'comments' => 0, + 'html_url' => 'https://github.com/owner/repo/issues/42', + 'user' => [ + 'login' => 'owner', + 'id' => 1, + 'node_id' => 'MDQ6VXNlcjE=', + 'avatar_url' => 'https://github.com/owner.png', + 'url' => 'https://api.github.com/users/owner', + 'html_url' => 'https://github.com/owner', + 'followers_url' => 'https://api.github.com/users/owner/followers', + 'following_url' => 'https://api.github.com/users/owner/following{/other_user}', + 'gists_url' => 'https://api.github.com/users/owner/gists{/gist_id}', + 'starred_url' => 'https://api.github.com/users/owner/starred{/owner}{/repo}', + 'subscriptions_url' => 'https://api.github.com/users/owner/subscriptions', + 'organizations_url' => 'https://api.github.com/users/owner/orgs', + 'repos_url' => 'https://api.github.com/users/owner/repos', + 'events_url' => 'https://api.github.com/users/owner/events{/privacy}', + 'received_events_url' => 'https://api.github.com/users/owner/received_events', + 'type' => 'User', + ], + 'created_at' => '2024-01-01T00:00:00Z', + 'updated_at' => '2024-01-01T00:00:00Z', + 'closed_at' => null, + ], 201), + ])); + + $issue = Github::issues()->create('owner', 'repo', 'Test Issue'); + + expect($issue->number)->toBe(42); + expect($issue->title)->toBe('Test Issue'); +}); +``` + +Run the package tests with: ```bash composer test ``` ---- +`composer analyse` runs PHPStan. `composer format` runs Pint. -## License +## Errors + +Failed responses are mapped in `GithubConnector`: -MIT. Go build something. +| Status | Exception | +| --- | --- | +| 401 | `AuthenticationException` | +| 403 rate limit | `RateLimitException` | +| 403 permissions | `ApiException` | +| 404 | `ResourceNotFoundException` | +| 422 | `ValidationException` | +| 429 | `RateLimitException` | +| 500, 502, 503, 504 | `NetworkException` | + +## Requirements + +- PHP 8.2, 8.3, or 8.4 +- Laravel 11, 12, or 13 (`illuminate/contracts`) +- `saloonphp/saloon` ^4.3 +- `firebase/php-jwt` ^7.0, used by GitHub App authentication +- `spatie/laravel-package-tools` ^1.16 or ^2.0 + +## Contributing + +Pull requests are welcome. Run `composer test` before opening one. + +## License ---- +MIT. See [LICENSE.md](LICENSE.md). -**Built with [Saloon](https://github.com/Sammyjo20/Saloon)** by [Jordan Partridge](https://github.com/jordanpartridge) +Built with [Saloon](https://github.com/saloonphp/saloon) by [Jordan Partridge](https://github.com/jordanpartridge). diff --git a/composer.json b/composer.json index 529c1e2f..2f708890 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "jordanpartridge/github-client", - "description": "A powerful, Laravel-first GitHub API client with auto-pagination, strong typing, and comprehensive GitHub integration for repositories, pull requests, issues, and more.", + "description": "Laravel client for the GitHub REST API, built with Saloon. Typed responses for repositories, issues, pull requests, commits, releases, and Actions.", "keywords": [ "laravel", "github", @@ -9,7 +9,6 @@ "php", "saloon", "api-client", - "conduit-ui", "repositories", "pull-requests", "issues", @@ -46,8 +45,7 @@ }, "autoload": { "psr-4": { - "JordanPartridge\\GithubClient\\": "src/", - "JordanPartridge\\GithubClient\\Database\\Factories\\": "database/factories/" + "JordanPartridge\\GithubClient\\": "src/" } }, "autoload-dev": { diff --git a/config/github-client.php b/config/github-client.php index 9c193f7a..80f509ae 100644 --- a/config/github-client.php +++ b/config/github-client.php @@ -6,103 +6,45 @@ | GitHub Token |-------------------------------------------------------------------------- | - | GitHub personal access token for authentication. If not provided here, - | the TokenResolver will attempt to find a token from: - | 1. GitHub CLI (gh auth token) - | 2. Environment variables (GITHUB_TOKEN, GH_TOKEN) - | 3. This config value + | TokenResolver reads these config values, then `gh auth token`. + | `token` wins when both are set. Leave a value empty to skip it. + | Public repositories can be requested with no token. | - | Authentication is optional for public repositories but recommended - | for higher rate limits. */ 'token' => env('GITHUB_TOKEN'), - /* - |-------------------------------------------------------------------------- - | GitHub API Base URL - |-------------------------------------------------------------------------- - | - | The base URL for the GitHub API. Typically only changed when using - | GitHub Enterprise Server. - */ - 'base_url' => env('GITHUB_BASE_URL', 'https://api.github.com'), + 'gh_token' => env('GH_TOKEN'), /* |-------------------------------------------------------------------------- - | Authentication Options + | OAuth |-------------------------------------------------------------------------- | - | Configuration for authentication behavior. - */ - 'auth' => [ - // Whether to prefer GitHub CLI authentication over environment variables - 'prefer_github_cli' => env('GITHUB_PREFER_CLI_AUTH', true), - - // Whether to show authentication guidance when no token is found - 'show_auth_guidance' => env('GITHUB_SHOW_AUTH_GUIDANCE', true), - - // Environment variables to check for tokens (in priority order) - 'token_env_vars' => ['GITHUB_TOKEN', 'GH_TOKEN'], - ], - - /* - |-------------------------------------------------------------------------- - | OAuth Configuration - |-------------------------------------------------------------------------- + | Used by the GithubOAuth facade. Scopes are an argument to + | getAuthorizationUrl(), not a config value. | - | Configuration for OAuth authentication flow. */ 'oauth' => [ 'client_id' => env('GITHUB_CLIENT_ID'), 'client_secret' => env('GITHUB_CLIENT_SECRET'), 'redirect_url' => env('GITHUB_REDIRECT_URL'), - - // Default scopes to request - 'scopes' => [ - 'repo', - 'user', - 'read:org', - ], ], /* |-------------------------------------------------------------------------- - | GitHub App Configuration + | GitHub App |-------------------------------------------------------------------------- | - | Configuration for GitHub App authentication. GitHub Apps provide - | enhanced security and granular permissions compared to OAuth apps. + | Used by Github::forInstallation() and Github::withApp(). + | Pass the installation id to forInstallation(). This file does not + | store one. The private key may be PEM text or a filesystem path. + | */ '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 - |-------------------------------------------------------------------------- - | - | Configuration for handling GitHub API rate limits. - */ - 'rate_limit' => [ - // Whether to automatically retry requests when rate limited - 'auto_retry' => env('GITHUB_AUTO_RETRY', true), - - // Maximum number of retry attempts - 'max_retries' => env('GITHUB_MAX_RETRIES', 3), - - // Whether to respect rate limit headers and wait automatically - 'respect_rate_limits' => env('GITHUB_RESPECT_RATE_LIMITS', true), - ], ]; diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php deleted file mode 100644 index 4f918e6f..00000000 --- a/database/factories/ModelFactory.php +++ /dev/null @@ -1,19 +0,0 @@ -id(); - - // add fields - - $table->timestamps(); - }); - } -}; diff --git a/docs/README.md b/docs/README.md index 69c0e2d7..354077b2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,20 +1,8 @@ -# GitHub Client Documentation +# GitHub Client documentation -Welcome to the comprehensive documentation for the Laravel GitHub Client. +The [README](../README.md) is the install and usage guide. These pages match the same public API. -## Table of Contents - -1. [Getting Started](./getting-started.md) -2. [Core Concepts](./core-concepts.md) -3. [API Reference](./api-reference.md) -4. [Advanced Usage](./advanced-usage.md) -5. [Security Best Practices](./security.md) -6. [Contributing Guide](./contributing.md) -7. [Ethical Guidelines](./ethical-guidelines.md) - -## Quick Links - -- [Installation Guide](./getting-started.md#installation) -- [Basic Usage](./getting-started.md#basic-usage) -- [Configuration](./getting-started.md#configuration) -- [Troubleshooting](./advanced-usage.md#troubleshooting) +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) diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index 75b81fb8..4a7eea2d 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -1,99 +1,66 @@ -# Advanced Usage +# Authentication and errors -## Custom Configuration +## Personal access token -### Publishing Configuration - -```bash -php artisan vendor:publish --tag="github-client-config" +```env +GITHUB_TOKEN=ghp_your_token_here +GH_TOKEN= ``` -### Custom Token Handling +`config/github-client.php` copies those into `token` and `gh_token`. `TokenResolver` uses `token`, then `gh_token`, then the GitHub CLI. -```php -config([ - 'github-client.token' => resolve(TokenProvider::class)->getToken(), -]); -``` +The connector base URL is `https://api.github.com`. The package does not read a base URL from config. -## Rate Limiting +## OAuth -### Automatic Rate Limiting - -```php -GitHub::repos() - ->withRateLimiting() - ->get('owner/repo'); +```env +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_REDIRECT_URL= ``` -### Custom Rate Limiting - ```php -GitHub::repos() - ->withRateLimiting([ - 'max_requests' => 5000, - 'per_seconds' => 3600, - ]) - ->get('owner/repo'); -``` - -## Error Handling - -### Catching Specific Exceptions +use JordanPartridge\GithubClient\Facades\GithubOAuth; -```php -try { - $repo = GitHub::repos()->get('owner/repo'); -} catch (NotFoundException $e) { - // Handle 404 -} catch (RateLimitExceededException $e) { - // Handle rate limit -} catch (GitHubException $e) { - // Handle other exceptions -} +$url = GithubOAuth::getAuthorizationUrl(['repo'], $state); +$token = GithubOAuth::getAccessToken($code); ``` -## Testing - -### Mocking Responses +Default scopes are `['repo']` when you omit the argument. Config does not supply scopes. The package does not persist `state`. Pass a value you stored, and compare it on the callback. -```php -use JordanPartridge\GithubClient\Tests\Factories\RepositoryFactory; +`getAccessToken()` posts the code to `https://github.com/login/oauth/access_token` and returns the `access_token` string. A body without that field throws `AuthenticationException`. -$repo = RepositoryFactory::new()->create(); +## GitHub App -GitHub::fake([ - 'repos/owner/repo' => $repo, -]); +```env +GITHUB_APP_ID= +GITHUB_APP_PRIVATE_KEY= +GITHUB_APP_PRIVATE_KEY_PATH= ``` -## Extending +`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. -### Custom Resources +## Errors -```php -class CustomResource extends Resource -{ - public function customMethod(): mixed - { - return $this->connector - ->send(new CustomRequest()) - ->throw() - ->json(); - } -} -``` +| Status | Exception | +| --- | --- | +| 401 | `JordanPartridge\GithubClient\Exceptions\AuthenticationException` | +| 403 when the body or headers say the rate limit is spent | `RateLimitException` | +| 403 otherwise | `ApiException` | +| 404 | `ResourceNotFoundException` | +| 422 | `ValidationException` | +| 429 | `RateLimitException` | +| 500, 502, 503, 504 | `NetworkException` | +| other | `ApiException` | -### Custom Requests +There is no `withRateLimiting()` helper and no `Github::fake()`. In tests, call `Github::connector()->withMockClient()` with a Saloon `MockClient`. The mock body has to include the fields the DTO constructor reads. See `tests/DocumentationExamplesTest.php`. -```php -class CustomRequest extends Request -{ - protected Method $method = Method::POST; - - public function resolveEndpoint(): string - { - return '/custom/endpoint'; - } -} -``` +## What the config file does not do + +These are not implemented. They are not in the published config. + +- A custom API base URL +- Automatic retries +- A `prefer_github_cli` switch +- OAuth scopes from config +- A default GitHub App installation id diff --git a/docs/api-reference.md b/docs/api-reference.md index 14758fe8..f701f3d1 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,59 +1,122 @@ -# API Reference +# API reference -## Core Classes +Entry points are the `Github` facade and `JordanPartridge\GithubClient\Github` from the container. -### GitHub Client +Return values are not uniform. Some methods return a Saloon response. Call `dto()` on those. Others return a DTO or an array of DTOs. + +## Repositories ```php -use JordanPartridge\GithubClient\Facades\Github; +Github::repos()->all(?int $perPage, ?int $page, ?Visibility $visibility, ?Sort $sort, ?Direction $direction, ?Type $type): Response +Github::repos()->allWithPagination(?int $perPage = 100, ?Visibility $visibility, ?Sort $sort, ?Direction $direction, ?Type $type): array +Github::repos()->get(Repo $repo): RepoData +Github::repos()->delete(Repo $repo): Response +Github::repos()->search(string $query, ?string $sort, ?Direction $order, ?int $perPage, ?int $page): SearchRepositoriesData + +Github::getRepo(string $fullName): RepoData +Github::deleteRepo(string $fullName): Response ``` -## Available Methods +`all` hits `GET /user/repos` and needs an authenticated user. `search` hits the repository search API. `sort` on search is a string such as `stars`, not the `Sort` enum. -### Repositories +## Issues -#### all() ```php -Github::repos()->all(): Collection +Github::issues()->all(...): Response +Github::issues()->forRepo(string $owner, string $repo, ...): Response +Github::issues()->allForRepo(string $owner, string $repo, ...): array +Github::issues()->get(string $owner, string $repo, int $issueNumber): IssueDTO +Github::issues()->create(string $owner, string $repo, string $title, ?string $body, ?array $assignees, ?int $milestone, ?array $labels): IssueDTO +Github::issues()->update(...): IssueDTO +Github::issues()->close(string $owner, string $repo, int $issueNumber): IssueDTO +Github::issues()->reopen(string $owner, string $repo, int $issueNumber): IssueDTO +Github::issues()->comments(string $owner, string $repo, int $issueNumber, ?int $perPage, ?int $page, ?string $since): array +Github::issues()->addComment(string $owner, string $repo, int $issueNumber, string $body): IssueCommentDTO +Github::issues()->getComment(string $owner, string $repo, int $commentId): IssueCommentDTO +Github::issues()->updateComment(string $owner, string $repo, int $commentId, string $body): IssueCommentDTO +Github::issues()->deleteComment(string $owner, string $repo, int $commentId): bool ``` -#### get() +Filter arguments on `all`, `forRepo`, and `allForRepo` are `perPage`, `page`, `Issues\State`, `labels` (string), `Issues\Sort`, `Direction`, `assignee`, `creator`, `mentioned`, and `since`. + +`State` is `OPEN`, `CLOSED`, or `ALL`. `Sort` is `CREATED`, `UPDATED`, or `COMMENTS`. + +## Pull requests + ```php -Github::repos()->get(string $repository): Repository +Github::pullRequests()->all(string $owner, string $repo, array $parameters = []): array +Github::pullRequests()->get(string $owner, string $repo, int $number): PullRequestDTO +Github::pullRequests()->create(string $owner, string $repo, string $title, string $head, string $base, string $body = '', bool $draft = false): PullRequestDTO +Github::pullRequests()->update(string $owner, string $repo, int $number, array $parameters = []): PullRequestDTO +Github::pullRequests()->merge(string $owner, string $repo, int $number, ?string $commitMessage = null, ?string $sha = null, MergeMethod $mergeMethod = MergeMethod::Merge): bool +Github::pullRequests()->files(string $owner, string $repo, int $number): array +Github::pullRequests()->diff(string $owner, string $repo, int $number): array +Github::pullRequests()->comments(string $owner, string $repo, int $number): array +Github::pullRequests()->summaries(string $owner, string $repo, array $parameters = []): array +Github::pullRequests()->detail(string $owner, string $repo, int $number): PullRequestDetailDTO ``` -### Commits +`MergeMethod` is `Merge`, `Squash`, or `Rebase`. + +`diff()` groups the files from `files()` into `summary`, `categories`, `files`, and `analysis_tags`. Categories are `tests`, `config`, `docs`, `code`, and `other`, based on the path and extension. + +`comments()` here is the direct list. `Github::comments()->forPullRequest($number, ['repository' => 'owner/repo'])` is a filtered wrapper around the same review-comment endpoint. The wrapper throws if `repository` is missing. + +Review helpers on the pull request resource: `reviews`, `createReview`, `createComment`, `commentsWithFilters`, `getComment`, `updateComment`, `deleteComment`, `detailsForMultiple`, `recentDetails`. + +## Commits and files -#### all() ```php -Github::commits()->all(string $repository): Collection +Github::commits()->all(string $repoName, ?int $perPage = 100, ?int $page = 1): array +Github::commits()->get(string $repoName, string $commitSha): CommitData + +Github::files()->all(string $repoName, string $commitSha): Response ``` -#### get() +`$repoName` is `owner/repo`. `$commitSha` must match `^[0-9a-f]{40}$`. + +The commit message is `$commit->commit->message`. The GitHub user, when present, is `$commit->author->login`. + +## Releases + ```php -Github::commits()->get(string $sha): Commit +Github::releases()->all(string $owner, string $repo, ?int $perPage, ?int $page): array +Github::releases()->get(string $owner, string $repo, int $releaseId): ReleaseData +Github::releases()->latest(string $owner, string $repo): ReleaseData ``` -## Response Types +## Actions -### Repository ```php -class Repository extends Data -{ - public string $name; - public string $full_name; - public string $description; - // ... -} +Github::actions()->listWorkflows(string $owner, string $repo, ?int $perPage, ?int $page): Response +Github::actions()->getWorkflowRuns(string $owner, string $repo, int $workflowId, ...): Response +Github::actions()->triggerWorkflow(string $owner, string $repo, int $workflowId, array $data): Response ``` -### Commit +`$data['ref']` is required. `$data['inputs']` must be an array when it is set. + +## Rate limit + ```php -class Commit extends Data -{ - public string $sha; - public string $message; - public array $parents; - // ... -} +Github::getRateLimitStatus(): array +Github::getRateLimitForResource(string $resource = 'core'): RateLimitDTO +Github::hasRateLimitExceeded(): bool ``` + +`hasRateLimitExceeded()` returns `false` when the rate-limit request itself throws. + +## GitHub App + +On the default branch, and not in Packagist `v3.0.0`: + +```php +Github::forInstallation(int $installationId): Github +Github::withApp(string $appId, string $privateKey, ?int $installationId = null): Github + +Github::installations()->list(?int $perPage, ?int $page): array +Github::installations()->listAll(?int $perPage = 100): array +Github::installations()->get(int $installationId): InstallationData +Github::installations()->createAccessToken(int $installationId, ?array $repositories, ?array $permissions): InstallationTokenData +``` + +`forInstallation()` reads `github-client.github_app.app_id` and the private key from config. diff --git a/docs/core-concepts.md b/docs/core-concepts.md index 0b2b8738..e99d6d6b 100644 --- a/docs/core-concepts.md +++ b/docs/core-concepts.md @@ -1,70 +1,16 @@ -# Core Concepts +# How the client is put together -## Architecture Overview +`GithubClientServiceProvider` registers four things: -The GitHub Client is built on several key concepts: +- The `github-client` config file +- The `github-client` Artisan command +- A singleton `GithubConnector` +- `JordanPartridge\GithubClient\Github`, which the `Github` facade resolves -### 1. Resource Pattern +Each resource (`repos()`, `issues()`, `pullRequests()`, and the rest) builds a Saloon request, sends it through `GithubConnector`, and maps the body in `createDtoFromResponse()`. -The package follows Laravel's resource pattern for intuitive API interactions: +DTOs in `src/Data` are plain PHP objects with a `fromArray()` or `fromApiResponse()` method. They are not Spatie Laravel Data objects. A missing key throws while the object is constructed. List endpoints that use `HandlesIssueResponses` skip an item that cannot be mapped. -```php -GitHub::resource()->action(); -``` +`Repo::fromFullName('owner/repo')` checks that the string has one slash and that each side matches `[a-zA-Z0-9._-]+`. -### 2. Typed Responses - -All responses are fully typed using Spatie's Data objects: - -```php -class Repository extends Data -{ - public string $name; - public string $full_name; - // ... -} -``` - -### 3. Dependency Injection - -The package supports Laravel's dependency injection: - -```php -public function __construct( - private readonly GitHub $github -) {} -``` - -## Key Components - -### 1. Saloon Integration - -Built on Saloon for reliable API handling: - -```php -class GitHubConnector extends Connector -{ - public function resolveBaseUrl(): string - { - return 'https://api.github.com'; - } -} -``` - -### 2. Configuration - -Flexible configuration through Laravel's config system: - -```php -return [ - 'token' => env('GITHUB_TOKEN'), - 'token_type' => 'bearer', -]; -``` - -## Best Practices - -1. Use typed responses for better IDE support -2. Leverage dependency injection -3. Follow resource patterns consistently -4. Handle rate limiting appropriately +Enums replace a few query strings: `Visibility`, `Sort`, `Direction`, `Repos\Type`, `Issues\State`, `Issues\Sort`, `Pulls\State`, `Pulls\Sort`, and `MergeMethod`. Pull request list filters are still a plain array. diff --git a/docs/ethical-guidelines.md b/docs/ethical-guidelines.md deleted file mode 100644 index 37c061c3..00000000 --- a/docs/ethical-guidelines.md +++ /dev/null @@ -1,68 +0,0 @@ -# Ethical Guidelines - -## Overview - -This document outlines our ethical considerations and guidelines for using and contributing to the GitHub Client package. - -## Core Principles - -### 1. Access Control & Security - -- Secure handling of authentication tokens -- Responsible rate limiting -- Protection of user data -- Clear security documentation - -### 2. User Experience & Accessibility - -- Inclusive documentation -- Clear error messages -- Comprehensive examples -- Support for different skill levels - -### 3. Code Quality & Responsibility - -- Comprehensive testing -- Efficient resource usage -- Clear documentation -- Maintainable code structure - -### 4. Community Impact - -- Inclusive contribution guidelines -- Responsible dependency management -- Clear communication -- Community-focused development - -## Implementation Guidelines - -### Security Implementation - -```php -// Example of secure token handling -config([ - 'github-client.token' => env('GITHUB_TOKEN'), - 'github-client.token_type' => 'bearer', -]); -``` - -### Resource Usage - -```php -// Example of efficient resource usage -GitHub::repos() - ->withRateLimiting() - ->get('owner/repo'); -``` - -## Contributing Ethically - -1. Review the code of conduct -2. Follow security guidelines -3. Write inclusive documentation -4. Consider accessibility -5. Test thoroughly - -## Questions & Support - -If you have questions about these guidelines, please open a discussion in the GitHub repository. diff --git a/docs/getting-started.md b/docs/getting-started.md index ec1f3bfc..69dabcf4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,48 +1,39 @@ -# Getting Started +# Getting started -## Installation - -Install the package via Composer: +## Install ```bash composer require jordanpartridge/github-client +php artisan vendor:publish --tag=github-client-config ``` -## Basic Configuration +That Composer command installs the latest Packagist tag. The calls on this page match the default branch. -1. Generate a GitHub token in your [GitHub Settings](https://github.com/settings/tokens) -2. Add the token to your `.env` file: +## Token -```dotenv -GITHUB_TOKEN=your-token-here +```env +GITHUB_TOKEN=ghp_your_token_here ``` -## Basic Usage +`TokenResolver` checks `config('github-client.token')`, then `config('github-client.gh_token')`, then `gh auth token`. With no token, public repository requests still run, on GitHub's unauthenticated rate limit. -### Working with Repositories +## First calls ```php use JordanPartridge\GithubClient\Facades\Github; -// List repositories -$repos = Github::repos()->all(); +$repos = Github::repos()->all()->dto(); + +$repo = Github::getRepo('laravel/framework'); -// Get specific repository -$repo = Github::repos()->get('owner/repo'); +$commits = Github::commits()->all('laravel/framework'); ``` -### Working with Commits +`repos()->all()` returns a Saloon response, so call `dto()` before reading `RepoData` properties. `repos()->get()` does not accept a string. Pass `Repo::fromFullName('owner/repo')`, or use `Github::getRepo()`. -```php -// List commits -$commits = Github::commits()->all('owner/repo'); - -// Get specific commit -$commit = Github::commits()->get('sha'); -``` +`commits()->get($repo, $sha)` needs the full `owner/repo` name and a 40 character SHA. -## Next Steps +## Next -- Learn about [Core Concepts](./core-concepts.md) -- Explore [Advanced Usage](./advanced-usage.md) -- Review [Security Best Practices](./security.md) +- [API reference](./api-reference.md) +- [Authentication and errors](./advanced-usage.md) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8da3591a..aab49911 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,37 +1,2 @@ parameters: - ignoreErrors: - - - message: '#^Called ''env'' outside of the config directory which returns null when the config is cached, use ''config''\.$#' - identifier: larastan.noEnvCallsOutsideOfConfig - count: 4 - path: src/Auth/TokenResolver.php - - - - message: '#^Instantiated class JordanPartridge\\GithubClient\\Exceptions\\ResourceNotFoundException not found\.$#' - identifier: class.notFound - count: 1 - path: src/Connectors/GithubConnector.php - - - - message: '#^Missing parameter \$message \(string\) in call to JordanPartridge\\GithubClient\\Exceptions\\NetworkException constructor\.$#' - identifier: argument.missing - count: 1 - path: src/Connectors/GithubConnector.php - - - - message: '#^Strict comparison using \=\=\= between non\-falsy\-string and '''' will always evaluate to false\.$#' - identifier: identical.alwaysFalse - count: 1 - path: src/Connectors/GithubConnector.php - - - - message: '#^Unknown parameter \$reason in call to JordanPartridge\\GithubClient\\Exceptions\\NetworkException constructor\.$#' - identifier: argument.unknown - count: 1 - path: src/Connectors/GithubConnector.php - - - - message: '#^PHPDoc tag @method for method JordanPartridge\\GithubClient\\Facades\\Github\:\:connector\(\) return type contains unknown class JordanPartridge\\GithubClient\\Contracts\\GithubConnectorInterface\.$#' - identifier: class.notFound - count: 1 - path: src/Facades/Github.php + ignoreErrors: [] diff --git a/resources/views/.gitkeep b/resources/views/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/Auth/GithubOAuth.php b/src/Auth/GithubOAuth.php index 8a070b1e..dbf88b3d 100644 --- a/src/Auth/GithubOAuth.php +++ b/src/Auth/GithubOAuth.php @@ -8,18 +8,23 @@ class GithubOAuth { public function __construct( - protected string $clientId, - protected string $clientSecret, - protected string $redirectUrl, + protected ?string $clientId, + protected ?string $clientSecret, + protected ?string $redirectUrl, ) {} - public function getAuthorizationUrl(array $scopes = ['repo']): string + /** + * @param array $scopes + */ + public function getAuthorizationUrl(array $scopes = ['repo'], ?string $state = null): string { + $this->assertConfigured(); + $queryParams = http_build_query([ 'client_id' => $this->clientId, 'redirect_uri' => $this->redirectUrl, 'scope' => implode(' ', $scopes), - 'state' => $this->generateState(), + 'state' => $state ?? $this->generateState(), ]); return "https://github.com/login/oauth/authorize?{$queryParams}"; @@ -27,6 +32,8 @@ public function getAuthorizationUrl(array $scopes = ['repo']): string public function getAccessToken(string $code): string { + $this->assertConfigured(); + $response = Http::post('https://github.com/login/oauth/access_token', [ 'client_id' => $this->clientId, 'client_secret' => $this->clientSecret, @@ -47,4 +54,16 @@ protected function generateState(): string { return bin2hex(random_bytes(16)); } + + protected function assertConfigured(): void + { + if ($this->clientId === null || $this->clientId === '' + || $this->clientSecret === null || $this->clientSecret === '' + || $this->redirectUrl === null || $this->redirectUrl === '') { + throw new AuthenticationException( + 'GitHub OAuth is not configured. Set GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and GITHUB_REDIRECT_URL.', + 'oauth', + ); + } + } } diff --git a/src/Auth/TokenResolver.php b/src/Auth/TokenResolver.php index 339e8635..1e09630b 100644 --- a/src/Auth/TokenResolver.php +++ b/src/Auth/TokenResolver.php @@ -8,10 +8,10 @@ * Resolves GitHub authentication tokens from multiple sources. * * Priority order: - * 1. GitHub CLI (gh auth token) - * 2. Environment variables (GITHUB_TOKEN, GH_TOKEN) - * 3. Laravel config - * 4. Return null for optional auth (public repos) + * 1. config('github-client.token'), normally GITHUB_TOKEN + * 2. config('github-client.gh_token'), normally GH_TOKEN + * 3. GitHub CLI (gh auth token) + * 4. null when nothing is configured (public repositories) */ class TokenResolver { @@ -22,28 +22,24 @@ class TokenResolver */ public static function resolve(): ?string { - // 1. Check environment variables first (fast, no external process) - if ($token = self::getEnvironmentToken()) { - self::$lastSource = env('GITHUB_TOKEN') ? 'GITHUB_TOKEN' : 'GH_TOKEN'; + if ($token = self::configuredToken('github-client.token')) { + self::$lastSource = 'config'; return $token; } - // 2. Check Laravel config - if ($token = self::getConfigToken()) { - self::$lastSource = 'config'; + if ($token = self::configuredToken('github-client.gh_token')) { + self::$lastSource = 'GH_TOKEN'; 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; return null; @@ -77,38 +73,17 @@ private static function getGitHubCliToken(): ?string } /** - * Get token from environment variables. + * Read a token from config. Empty strings and the README placeholder are ignored. */ - private static function getEnvironmentToken(): ?string + private static function configuredToken(string $key): ?string { - // Check GITHUB_TOKEN first (standard) - $token = env('GITHUB_TOKEN'); - if (! empty($token)) { - return $token; - } + $token = config($key); - // Check GH_TOKEN (GitHub CLI convention) - $token = env('GH_TOKEN'); - if (! empty($token)) { - return $token; + if (! is_string($token) || $token === '' || $token === 'your-github-token-here' || $token === 'ghp_your_token_here') { + return null; } - return null; - } - - /** - * Get token from Laravel config. - */ - private static function getConfigToken(): ?string - { - $token = config('github-client.token'); - - // Only return if not empty and not a placeholder - if (! empty($token) && $token !== 'your-github-token-here') { - return $token; - } - - return null; + return $token; } /** @@ -124,18 +99,11 @@ public static function hasAuthentication(): bool */ public static function getAuthenticationStatus(): string { - // Check in same order as resolve() for consistency - if ($token = self::getEnvironmentToken()) { - $source = env('GITHUB_TOKEN') ? 'GITHUB_TOKEN' : 'GH_TOKEN'; - - return "Authenticated via environment variable ({$source})"; - } - - if ($token = self::getConfigToken()) { + if (self::configuredToken('github-client.token') || self::configuredToken('github-client.gh_token')) { return 'Authenticated via config file'; } - if ($token = self::getGitHubCliToken()) { + if (self::getGitHubCliToken()) { return 'Authenticated via GitHub CLI'; } @@ -149,18 +117,19 @@ public static function getAuthenticationHelp(): string { return <<<'HELP' GitHub authentication is recommended for better rate limits. - + You can authenticate using one of these methods: - - 1. GitHub CLI (recommended): - gh auth login - - 2. Environment variable: - export GITHUB_TOKEN=your_token_here - - 3. Laravel config: + + 1. Environment variable: + GITHUB_TOKEN or GH_TOKEN in .env + (read through config/github-client.php) + + 2. Laravel config: Set token in config/github-client.php - + + 3. GitHub CLI: + gh auth login + Note: Public repositories can be accessed without authentication, but rate limits are much lower (60 requests/hour vs 5000 with auth). HELP; diff --git a/src/Facades/Github.php b/src/Facades/Github.php index f155623d..952b12ed 100644 --- a/src/Facades/Github.php +++ b/src/Facades/Github.php @@ -10,7 +10,18 @@ * @method static \JordanPartridge\GithubClient\Resources\FileResource files() * @method static \JordanPartridge\GithubClient\Resources\PullRequestResource pullRequests() * @method static \JordanPartridge\GithubClient\Resources\ActionsResource actions() - * @method static \JordanPartridge\GithubClient\Contracts\GithubConnectorInterface connector() + * @method static \JordanPartridge\GithubClient\Resources\IssuesResource issues() + * @method static \JordanPartridge\GithubClient\Resources\CommentsResource comments() + * @method static \JordanPartridge\GithubClient\Resources\ReleasesResource releases() + * @method static \JordanPartridge\GithubClient\Resources\InstallationsResource installations() + * @method static \JordanPartridge\GithubClient\Connectors\GithubConnector connector() + * @method static array getRateLimitStatus() + * @method static \JordanPartridge\GithubClient\Data\RateLimitDTO getRateLimitForResource(string $resource = 'core') + * @method static bool hasRateLimitExceeded() + * @method static \JordanPartridge\GithubClient\Data\Repos\RepoData getRepo(string $fullName) + * @method static \Saloon\Http\Response deleteRepo(string $fullName) + * @method static self forInstallation(int $installationId) + * @method static self withApp(string $appId, string $privateKey, ?int $installationId = null) * * @see \JordanPartridge\GithubClient\Github */ diff --git a/src/Facades/GithubOAuth.php b/src/Facades/GithubOAuth.php index 3deb04d3..ea449587 100644 --- a/src/Facades/GithubOAuth.php +++ b/src/Facades/GithubOAuth.php @@ -6,7 +6,7 @@ use JordanPartridge\GithubClient\Auth\GithubOAuth as GithubOAuthClass; /** - * @method static string getAuthorizationUrl(array $scopes = ['repo']) + * @method static string getAuthorizationUrl(array $scopes = ['repo'], ?string $state = null) * @method static string getAccessToken(string $code) * * @see GithubOAuthClass diff --git a/src/GithubClientServiceProvider.php b/src/GithubClientServiceProvider.php index 721d48b6..c815da1f 100644 --- a/src/GithubClientServiceProvider.php +++ b/src/GithubClientServiceProvider.php @@ -15,8 +15,6 @@ public function configurePackage(Package $package): void $package ->name('github-client') ->hasConfigFile() - ->hasViews() - ->hasMigration('create_github_client_table') ->hasCommand(GithubClientCommand::class); $this->app->singleton(GithubConnector::class, function () { diff --git a/src/Resources/ActionsResource.php b/src/Resources/ActionsResource.php index 9d89280e..f769f65a 100644 --- a/src/Resources/ActionsResource.php +++ b/src/Resources/ActionsResource.php @@ -5,7 +5,6 @@ use JordanPartridge\GithubClient\Requests\Actions\GetWorkflowRuns; use JordanPartridge\GithubClient\Requests\Actions\ListWorkflows; use JordanPartridge\GithubClient\Requests\Actions\TriggerWorkflow; -use JordanPartridge\GithubClient\ValueObjects\Repo; use Saloon\Http\Response; /** @@ -19,18 +18,13 @@ * * Usage example: * ```php - * $actions = new ActionsResource($connector); + * $workflows = $actions->listWorkflows('owner', 'repo'); * - * // List workflows for a repository - * $workflows = $actions->listWorkflows('owner/repo'); + * $runs = $actions->getWorkflowRuns('owner', 'repo', $workflowId); * - * // Get workflow runs - * $runs = $actions->getWorkflowRuns('owner/repo', $workflow_id); - * - * // Trigger a workflow - * $result = $actions->triggerWorkflow('owner/repo', $workflow_id, [ + * $result = $actions->triggerWorkflow('owner', 'repo', $workflowId, [ * 'ref' => 'main', - * 'inputs' => ['test' => 'value'] + * 'inputs' => ['test' => 'value'], * ]); * ``` */ diff --git a/src/Resources/README.md b/src/Resources/README.md index 1ba8f7c0..0aae8544 100644 --- a/src/Resources/README.md +++ b/src/Resources/README.md @@ -1,205 +1,24 @@ # Resources -This directory contains the core resource classes that handle GitHub API interactions. Each resource class represents a specific GitHub API endpoint group and follows Laravel's resource pattern for consistent interaction. +Each class in this directory is one GitHub API area. `Github` builds them and they send Saloon requests through `GithubConnector`. -## Architecture +Resources are `readonly` and extend `BaseResource`. The constructor receives the `Github` client, not a connector interface. -Resources are the cornerstone of the GitHub Client's architecture, providing a clean, consistent interface to the GitHub API. The architecture follows these principles: - -1. **Resource Pattern**: Each GitHub entity has a dedicated resource class -2. **Strongly Typed**: All methods return strongly-typed responses using DTOs -3. **Immutable**: All resources are declared as `readonly` to enforce immutability -4. **Consistent API**: Resources follow a common pattern with methods like `all()` and `get()` -5. **Request Encapsulation**: API requests are encapsulated in dedicated request classes -6. **Value Objects**: Complex entity identifiers use value objects for validation - -## Class Structure - -- `BaseResource.php` - Abstract base class that provides common functionality for all resources -- `CommitResource.php` - Handles operations related to repository commits -- `FileResource.php` - Manages file-level operations within repositories -- `PullRequestResource.php` - Handles all pull request related operations -- `RepoResource.php` - Manages repository-level operations - -## Resource Pattern - -All resources extend the `BaseResource` class and implement the `ResourceInterface`: - -```php -readonly class SomeResource extends BaseResource -{ - public function __construct( - private GithubConnectorInterface $connector, - ) {} - - public function all(...): Response { ... } - public function get(...): SomeData { ... } - // Additional methods specific to this resource -} -``` - -## Standard Method Pattern - -Each resource follows a consistent pattern for method naming and behavior: +Methods do not share one return type. `RepoResource::all()` and `IssuesResource::forRepo()` return a Saloon response (`dto()` maps the body). `RepoResource::get()` returns `RepoData` and takes a `Repo` value object. `IssuesResource::get()` takes owner, repo, and issue number and returns `IssueDTO`. ```php use JordanPartridge\GithubClient\Facades\Github; +use JordanPartridge\GithubClient\ValueObjects\Repo; -// Common pattern across resources -$resource->all(); // List all resources (returns Response) -$resource->get(); // Get a specific resource (returns typed DTO) -$resource->create(); // Create a new resource when applicable (returns Response or DTO) -$resource->update(); // Update an existing resource when applicable (returns Response) -$resource->delete(); // Delete a resource when applicable (returns Response) -``` - -## Resource-Specific Methods - -### RepoResource - -```php -// List repositories with filtering options -$repos = Github::repos()->all( - per_page: 30, - page: 1, - visibility: Visibility::PUBLIC, - sort: Sort::CREATED, - direction: Direction::DESC -); - -// Get a specific repository (returns RepoData) -$repo = Github::repos()->get('jordanpartridge/github-client'); - -// Delete a repository -$response = Github::repos()->delete('jordanpartridge/github-client'); -``` - -### CommitResource - -```php -// List all commits for a repository -$commits = Github::commits()->all( - 'jordanpartridge/github-client', - per_page: 20, - page: 1 -); - -// Get a specific commit by SHA (returns CommitData) -$commit = Github::commits()->get('jordanpartridge/github-client', 'abc123deadbeef'); -``` - -### PullRequestResource - -```php -// List all pull requests -$pullRequests = Github::pullRequests()->all( - 'jordanpartridge/github-client', - state: State::OPEN, - sort: Sort::CREATED, - direction: Direction::DESC -); - -// Get a specific pull request (returns PullRequestDTO) -$pr = Github::pullRequests()->get('jordanpartridge/github-client', 123); - -// Create a new pull request -$newPr = Github::pullRequests()->create( - owner: 'jordanpartridge', - repo: 'github-client', - title: 'New feature implementation', - head: 'feature-branch', - base: 'main', - body: 'This PR implements the new feature' -); - -// Merge a pull request -$mergeResult = Github::pullRequests()->merge( - owner: 'jordanpartridge', - repo: 'github-client', - number: 123, - mergeMethod: MergeMethod::Squash -); - -// Work with reviews -$reviews = Github::pullRequests()->reviews('jordanpartridge/github-client', 123); - -// Work with comments -$comments = Github::pullRequests()->comments('jordanpartridge/github-client', 123); -``` - -### FileResource - -```php -// Get file contents -$fileContent = Github::files()->get( - owner: 'jordanpartridge', - repo: 'github-client', - path: 'README.md' -); - -// List directory contents -$contents = Github::files()->contents( - owner: 'jordanpartridge', - repo: 'github-client', - path: 'src' -); -``` - -## Data Transfer Objects (DTOs) +$repos = Github::repos()->all()->dto(); -Each resource typically returns strongly-typed DTOs for specific entities: +$repo = Github::repos()->get(Repo::fromFullName('laravel/framework')); -- `RepoResource->get()` returns a `RepoData` object -- `CommitResource->get()` returns a `CommitData` object -- `PullRequestResource->get()` returns a `PullRequestDTO` object -- `FileResource->get()` returns a `FileDTO` object +$issue = Github::issues()->get('laravel', 'framework', 1); -These DTOs provide type-safe access to response properties with proper typing: - -```php -$repo = Github::repos()->get('jordanpartridge/github-client'); - -// Access typed properties -echo $repo->name; // string -echo $repo->id; // int -echo $repo->created_at->format('Y-m-d'); // Carbon date -echo $repo->owner->login; // nested object +$pulls = Github::pullRequests()->all('laravel', 'framework'); ``` -## Error Handling - -All resources handle errors consistently through the BaseResource class and Saloon's error handling. Common error scenarios: - -- Rate limiting (429) -- Authentication issues (401) -- Not found resources (404) -- Validation errors (422) - -## Testing Resources - -Each resource has corresponding test cases in the `/tests` directory. When adding new resource methods, ensure proper test coverage using Saloon's MockClient: - -```php -// In tests -$mockClient = new MockClient([ - '*' => MockResponse::make([ - 'id' => 1, - 'name' => 'test-repo' - ], 200), -]); - -Github::connector()->withMockClient($mockClient); -``` - -## Creating New Resources - -When adding a new GitHub API resource, follow these guidelines: +`FileResource` has `all($ownerRepo, $sha)` and returns the response for that commit tree. `ReleasesResource` has `all`, `get`, and `latest`. `ActionsResource` has `listWorkflows`, `getWorkflowRuns`, and `triggerWorkflow`, each with separate owner and repo arguments. -1. Create a new class that extends `BaseResource` -2. Define it as `readonly` -3. Implement the common methods (`all()`, `get()`) where applicable -4. Create corresponding DTO classes in the `Data` directory -5. Create request classes in the `Requests` directory -6. Add the resource to the `GithubConnector` and `GithubConnectorInterface` -7. Add comprehensive PHPDoc comments with examples -8. Create tests for the new resource \ No newline at end of file +The method list that matches the code is in [docs/api-reference.md](../../docs/api-reference.md). diff --git a/src/Resources/RepoResource.php b/src/Resources/RepoResource.php index 56317426..da944305 100644 --- a/src/Resources/RepoResource.php +++ b/src/Resources/RepoResource.php @@ -169,14 +169,14 @@ public function allWithPagination( * * @param Repo $repo -- the repo value object, which handles the validation * - * @return RepoData Returns a Saloon response containing the repository details + * @return RepoData Repository details * * @link https://docs.github.com/en/rest/repos/repos#get-a-repository * * Example Usage: * ```php - * $repo = $repos->get('jordanpartridge/github-client'); - * $details = $repo->json(); + * $repo = $repos->get(Repo::fromFullName('jordanpartridge/github-client')); + * echo $repo->full_name; * ``` */ public function get(Repo $repo): RepoData @@ -192,10 +192,9 @@ public function delete(Repo $repo): Response /** * Search for repositories on GitHub * - * Searches for repositories based on a query string. This is useful for discovering - * repositories that match specific criteria, such as components for the Conduit project. + * Searches for repositories based on a query string. * - * @param string $query The search query string (e.g., "topic:conduit-component") + * @param string $query The search query string (e.g., "language:php stars:>1000") * @param string|null $sort Sort field: stars, forks, help-wanted-issues, updated * @param Direction|null $order Sort order: asc or desc * @param int|null $per_page Number of results per page (max 100) @@ -207,8 +206,8 @@ public function delete(Repo $repo): Response * * Example Usage: * ```php - * // Search for repositories by topic - * $results = $repos->search('topic:conduit-component'); + * // Search for repositories by language + * $results = $repos->search('language:php'); * * // Search with sorting and pagination * $results = $repos->search( diff --git a/src/ValueObjects/Repo.php b/src/ValueObjects/Repo.php index bc0d305e..df2f87d9 100644 --- a/src/ValueObjects/Repo.php +++ b/src/ValueObjects/Repo.php @@ -42,9 +42,6 @@ public static function fromOwnerAndRepo(string $owner, string $repo): self private static function validateAndParseRepoName(string $full_name): array { - /** - * if the `$full_name` doesn't contain a slash, I send it back - */ $parts = explode('/', $full_name); if (count($parts) !== 2) { throw new InvalidArgumentException('Repository must be in format "owner/repo".'); @@ -52,16 +49,10 @@ private static function validateAndParseRepoName(string $full_name): array [$owner, $name] = $parts; - /** - * If either are empty, that's a no-go fo sho - */ if (empty($owner) || empty($name)) { throw new InvalidArgumentException('Owner and repo name cannot be empty.'); } - /** - * While were at it, lets regex the parts to make sure they're valid - */ if (! preg_match('/^[a-zA-Z0-9._-]+$/', $owner) || ! preg_match('/^[a-zA-Z0-9._-]+$/', $name)) { throw new InvalidArgumentException("Invalid characters in repository name '{$full_name}'."); } diff --git a/tests/ArchTest.php b/tests/ArchTest.php index 6bb6b4b3..4993b82a 100644 --- a/tests/ArchTest.php +++ b/tests/ArchTest.php @@ -10,7 +10,7 @@ }); describe('Resources', function () { arch('resources extend the base resource', function () { - expect('JordanPartridge\GithubClient\Response')->toExtend(BaseResource::class); + expect('JordanPartridge\GithubClient\Resources')->toExtend(BaseResource::class); }); }); diff --git a/tests/AuthenticationTest.php b/tests/AuthenticationTest.php index 88b6593e..1cb2398e 100644 --- a/tests/AuthenticationTest.php +++ b/tests/AuthenticationTest.php @@ -179,11 +179,6 @@ ->and($repo->private)->toBeFalse(); }); - it('provides helpful error message when rate limit is exceeded without auth', function () { - // Skip this test for now - mock client exception handling needs investigation - expect(true)->toBeTrue(); - })->skip('Mock client exception handling needs investigation'); - 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 diff --git a/tests/CommentMappingBugfixTest.php b/tests/CommentMappingBugfixTest.php index 9687bdaa..da8d91ee 100644 --- a/tests/CommentMappingBugfixTest.php +++ b/tests/CommentMappingBugfixTest.php @@ -4,8 +4,7 @@ describe('Comment Mapping Bug Fix (Issue #71)', function () { it('handles GitHub API returning string values for comment counts', function () { - // This reproduces the exact scenario from conduit know: - // API returns comments: 1, review_comments: 20 but DTO shows 0, 0 + // GitHub sometimes returns comment counts that need an integer cast. $githubApiResponse = [ 'id' => 123456, 'number' => 42, diff --git a/tests/DocumentationExamplesTest.php b/tests/DocumentationExamplesTest.php new file mode 100644 index 00000000..2b2d0e89 --- /dev/null +++ b/tests/DocumentationExamplesTest.php @@ -0,0 +1,171 @@ + 'fake-token']); +}); + +describe('README examples', function () { + it('lists repositories and loads one by full name', function () { + $payload = $this->createMockRepoData('github-client', 1, 'jordanpartridge'); + + Github::connector()->withMockClient(new MockClient([ + '*' => MockResponse::make([$payload], 200), + ])); + + $repos = Github::repos()->all()->dto(); + + expect($repos)->toBeArray() + ->and($repos[0])->toBeInstanceOf(RepoData::class); + + Github::connector()->withMockClient(new MockClient([ + '*' => MockResponse::make($payload, 200), + ])); + + $repo = Github::getRepo('jordanpartridge/github-client'); + + expect($repo->name)->toBe('github-client') + ->and($repo->owner->login)->toBe('jordanpartridge') + ->and($repo->stargazers_count)->toBe(80); + + $same = Github::repos()->get(Repo::fromFullName('jordanpartridge/github-client')); + + expect($same->full_name)->toBe('jordanpartridge/github-client'); + }); + + it('paginates public repositories with enums', function () { + Github::connector()->withMockClient(new MockClient([ + '*' => MockResponse::make([ + $this->createMockRepoData('github-client', 1, 'jordanpartridge'), + ], 200), + ])); + + $repos = Github::repos()->allWithPagination( + visibility: Visibility::PUBLIC, + sort: Sort::CREATED, + direction: Direction::DESC, + ); + + expect($repos)->toHaveCount(1) + ->and($repos[0]->name)->toBe('github-client'); + }); + + it('creates an issue, lists bugs, comments, and closes it', function () { + $created = $this->createMockIssueData([ + 'number' => 42, + 'title' => 'Bug: Something broke', + 'html_url' => 'https://github.com/jordanpartridge/github-client/issues/42', + 'labels' => [ + ['id' => 1, 'name' => 'bug', 'color' => 'd73a4a', 'description' => null, 'default' => true], + ], + ]); + + Github::connector()->withMockClient(new MockClient([ + '*' => MockResponse::make($created, 201), + ])); + + $issue = Github::issues()->create( + owner: 'jordanpartridge', + repo: 'github-client', + title: 'Bug: Something broke', + body: 'Here are the details...', + labels: ['bug'], + assignees: ['jordanpartridge'], + ); + + expect($issue)->toBeInstanceOf(IssueDTO::class) + ->and($issue->number)->toBe(42) + ->and($issue->html_url)->toBe('https://github.com/jordanpartridge/github-client/issues/42'); + + Github::connector()->withMockClient(new MockClient([ + '*' => MockResponse::make([$created], 200), + ])); + + $bugs = Github::issues()->forRepo( + owner: 'jordanpartridge', + repo: 'github-client', + state: State::OPEN, + labels: 'bug', + )->dto(); + + expect($bugs)->toHaveCount(1) + ->and($bugs[0]->number)->toBe(42); + + Github::connector()->withMockClient(new MockClient([ + '*' => MockResponse::make([$this->createMockCommentData()], 200), + ])); + + $comments = Github::issues()->comments('jordanpartridge', 'github-client', $issue->number); + + expect($comments)->toHaveCount(1); + + Github::connector()->withMockClient(new MockClient([ + '*' => MockResponse::make($this->createMockCommentData(['body' => 'Fixed in the latest release.']), 201), + ])); + + $comment = Github::issues()->addComment( + 'jordanpartridge', + 'github-client', + $issue->number, + 'Fixed in the latest release.', + ); + + expect($comment->body)->toBe('Fixed in the latest release.'); + + Github::connector()->withMockClient(new MockClient([ + '*' => MockResponse::make($this->createMockIssueData(['number' => 42, 'state' => 'closed']), 200), + ])); + + $closed = Github::issues()->close('jordanpartridge', 'github-client', $issue->number); + + expect($closed->state)->toBe('closed'); + }); + + it('creates and merges a pull request', function () { + Github::connector()->withMockClient(new MockClient([ + '*' => MockResponse::make($this->createMockPullRequestData(['number' => 8]), 201), + ])); + + $pr = Github::pullRequests()->create( + owner: 'jordanpartridge', + repo: 'github-client', + title: 'Add a feature', + head: 'feature-branch', + base: 'main', + body: 'This pull request adds the feature.', + draft: false, + ); + + expect($pr)->toBeInstanceOf(PullRequestDTO::class) + ->and($pr->number)->toBe(8); + + Github::connector()->withMockClient(new MockClient([ + '*' => MockResponse::make([ + 'merged' => true, + 'sha' => '0123456789abcdef0123456789abcdef01234567', + 'message' => 'Pull request successfully merged', + ], 200), + ])); + + $merged = Github::pullRequests()->merge( + owner: 'jordanpartridge', + repo: 'github-client', + number: $pr->number, + mergeMethod: MergeMethod::Squash, + ); + + expect($merged)->toBeTrue(); + }); +}); diff --git a/tests/IssuesValidationTest.php b/tests/IssuesValidationTest.php index 3641d0b5..e7ecbd74 100644 --- a/tests/IssuesValidationTest.php +++ b/tests/IssuesValidationTest.php @@ -1,6 +1,8 @@ toThrow(InvalidArgumentException::class, 'Comment body cannot be empty'); }); - it('accepts valid issue numbers', function () { - // These should not throw exceptions during validation - // (they would fail when actually making HTTP calls without mocking) - $this->assertTrue(true); // Placeholder - validation passes + it('accepts a positive issue number', function () { + Github::connector()->withMockClient(new MockClient([ + '*' => MockResponse::make($this->createMockIssueData(['number' => 7]), 200), + ])); + + $issue = Github::issues()->get('test', 'repo', 7); + + expect($issue->number)->toBe(7); }); }); diff --git a/tests/OAuthTest.php b/tests/OAuthTest.php new file mode 100644 index 00000000..c76d46aa --- /dev/null +++ b/tests/OAuthTest.php @@ -0,0 +1,25 @@ + $oauth->getAuthorizationUrl()) + ->toThrow(AuthenticationException::class, 'GitHub OAuth is not configured'); + }); + + it('puts the caller supplied state on the authorize url', function () { + $oauth = new GithubOAuth('client-id', 'client-secret', 'https://example.com/callback'); + + $url = $oauth->getAuthorizationUrl(['repo', 'user'], 'fixed-state'); + + expect($url) + ->toStartWith('https://github.com/login/oauth/authorize?') + ->and($url)->toContain('client_id=client-id') + ->and($url)->toContain('state=fixed-state') + ->and($url)->toContain('scope=repo+user'); + }); +}); diff --git a/tests/PullRequestCommentMappingTest.php b/tests/PullRequestCommentMappingTest.php index 91c60d3a..73a4da44 100644 --- a/tests/PullRequestCommentMappingTest.php +++ b/tests/PullRequestCommentMappingTest.php @@ -163,7 +163,7 @@ $dto = PullRequestDTO::fromApiResponse($debugApiResponse); - // The exact scenario mentioned in conduit know + // Comment counts from the pull request payload. expect($dto->comments)->toBe(1) ->and($dto->review_comments)->toBe(20) ->and($dto->number)->toBe(42); diff --git a/tests/SearchTest.php b/tests/SearchTest.php index cec409e3..fef4786d 100644 --- a/tests/SearchTest.php +++ b/tests/SearchTest.php @@ -118,7 +118,7 @@ 'allow_forking' => true, 'is_template' => false, 'web_commit_signoff_required' => false, - 'topics' => ['conduit-component', 'laravel'], + 'topics' => ['laravel-package', 'laravel'], 'visibility' => 'public', 'forks' => 9, 'open_issues' => 0, @@ -135,7 +135,7 @@ ], ], 200)); - $result = Github::repos()->search('topic:conduit-component'); + $result = Github::repos()->search('topic:laravel-package'); expect($result)->toBeInstanceOf(SearchRepositoriesData::class); expect($result->total_count)->toBe(2); @@ -183,7 +183,7 @@ it('constructs correct query parameters', function () { $search = new Search( - searchQuery: 'topic:conduit-component', + searchQuery: 'topic:laravel-package', sort: 'stars', order: Direction::DESC, per_page: 20, @@ -196,7 +196,7 @@ $query = $method->invoke($search); expect($query)->toBe([ - 'q' => 'topic:conduit-component', + 'q' => 'topic:laravel-package', 'sort' => 'stars', 'order' => 'desc', 'per_page' => 20, diff --git a/tests/TestCase.php b/tests/TestCase.php index daadb168..2facb04c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,21 +2,11 @@ namespace JordanPartridge\GithubClient\Tests; -use Illuminate\Database\Eloquent\Factories\Factory; use JordanPartridge\GithubClient\GithubClientServiceProvider; use Orchestra\Testbench\TestCase as Orchestra; class TestCase extends Orchestra { - protected function setUp(): void - { - parent::setUp(); - - Factory::guessFactoryNamesUsing( - fn (string $modelName) => 'JordanPartridge\\GithubClient\\Database\\Factories\\' . class_basename($modelName) . 'Factory', - ); - } - protected function getPackageProviders($app) { return [ @@ -28,14 +18,7 @@ public function getEnvironmentSetUp($app) { config()->set('database.default', 'testing'); - // Set up github-client configuration for testing config()->set('github-client.token', 'test-token'); - config()->set('github-client.base_url', 'https://api.github.com'); - - /* - $migration = include __DIR__.'/../database/migrations/create_github-client_table.php.stub'; - $migration->up(); - */ } protected function createMockUserData(string $login = 'testuser', int $id = 1): array