diff --git a/README.md b/README.md index b5b0059..ace205d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,345 @@ # horde/githubapiclient -A horde/http based client for the Github REST API. +A horde/http based client for the GitHub REST API v3. -## Usage +## Installation -See bin/demo-client.php for basic usage \ No newline at end of file +```bash +composer require horde/githubapiclient +``` + +**Upgrading?** See the [Migration Guide](doc/MIGRATION.md) for upgrading from earlier versions. + +## Quick Start + +```php +use Horde\GithubApiClient\GithubApiClient; +use Horde\GithubApiClient\GithubApiConfig; +use Horde\GithubApiClient\GithubRepository; +use Horde\Http\Client; + +// Create configuration +$config = new GithubApiConfig(accessToken: 'your-github-token'); + +// Create HTTP client (PSR-18 compatible) +$httpClient = new Client(); +$requestFactory = new \Horde\Http\RequestFactory(); +$streamFactory = new \Horde\Http\StreamFactory(); + +// Create API client +$client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + +// Use the client +$repo = new GithubRepository(owner: 'horde', name: 'components'); +$pullRequests = $client->listPullRequests($repo); +``` + +## Features + +### Repository Management +- List repositories in an organization +- Get repository details + +### Pull Requests +- Create new pull requests +- List pull requests with filters (base branch, head ref, state) +- Get a single pull request with complete details +- Update pull request (title, body, base branch, state) +- Merge pull requests (merge, squash, or rebase) +- Close pull requests +- Reopen closed pull requests + +### Comments +- List all comments on a pull request +- Create a comment on a pull request +- Update existing comments +- Delete comments + +### Reviews +- List all reviews on a pull request +- Request reviewers (users and teams) +- Check review states (APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED) + +### Status Checks +- Get combined commit status +- List GitHub Actions check runs +- Monitor CI/CD pipeline status + +### Labels +- List labels on issues/pull requests +- Add labels to issues/pull requests +- Set (replace) all labels on issues/pull requests +- Remove labels from issues/pull requests + +### Rate Limiting +- Check current rate limit status +- Inspect OAuth token scopes + +## Detailed Usage + +### Working with Pull Requests + +#### List Pull Requests + +```php +$repo = new GithubRepository(owner: 'horde', name: 'components'); + +// List all open pull requests +$pullRequests = $client->listPullRequests($repo); + +// Filter by base branch +$pullRequests = $client->listPullRequests($repo, baseBranch: 'main'); + +// Filter by head ref +$pullRequests = $client->listPullRequests($repo, headRef: 'feature-branch'); + +foreach ($pullRequests as $pr) { + echo "PR #{$pr->number}: {$pr->title}\n"; +} +``` + +#### Get Pull Request Details + +```php +$pr = $client->getPullRequest($repo, number: 123); + +echo "Title: {$pr->title}\n"; +echo "State: {$pr->state}\n"; +echo "Mergeable: " . ($pr->mergeable ? 'Yes' : 'No') . "\n"; +echo "Draft: " . ($pr->draft ? 'Yes' : 'No') . "\n"; +``` + +#### Create Pull Request + +```php +use Horde\GithubApiClient\CreatePullRequestParams; + +// Create a regular pull request +$params = new CreatePullRequestParams( + title: 'Add new feature', + head: 'feature-branch', + base: 'main', + body: 'This PR adds a new feature\n\nCloses #123' +); +$newPr = $client->createPullRequest($repo, $params); + +// Create a draft pull request +$draftParams = new CreatePullRequestParams( + title: 'Work in progress', + head: 'wip-branch', + base: 'develop', + body: 'This is still being worked on', + draft: true +); +$draftPr = $client->createPullRequest($repo, $draftParams); + +// Create PR from a fork +$forkParams = new CreatePullRequestParams( + title: 'Fix from fork', + head: 'username:feature-branch', // Format: username:branch + base: 'main' +); +$forkPr = $client->createPullRequest($repo, $forkParams); +``` + +#### Update Pull Request + +```php +use Horde\GithubApiClient\PullRequestUpdate; + +// Update title and body +$update = new PullRequestUpdate( + title: 'New PR Title', + body: 'Updated description' +); +$updatedPr = $client->updatePullRequest($repo, 123, $update); + +// Change base branch +$update = new PullRequestUpdate(base: 'develop'); +$updatedPr = $client->updatePullRequest($repo, 123, $update); + +// Close a pull request +$closedPr = $client->closePullRequest($repo, 123); + +// Reopen a closed pull request +$reopenedPr = $client->reopenPullRequest($repo, 123); +``` + +#### Merge Pull Request + +```php +use Horde\GithubApiClient\MergePullRequestParams; + +// Simple merge with defaults +$params = new MergePullRequestParams(); +$result = $client->mergePullRequest($repo, 123, $params); + +if ($result->merged) { + echo "Merged successfully: {$result->sha}\n"; +} + +// Squash merge with custom commit message +$params = new MergePullRequestParams( + commitTitle: 'feat: add new feature', + commitMessage: 'This PR implements feature X\n\nCloses #123', + mergeMethod: 'squash' +); +$result = $client->mergePullRequest($repo, 123, $params); + +// Rebase merge +$params = new MergePullRequestParams(mergeMethod: 'rebase'); +$result = $client->mergePullRequest($repo, 123, $params); + +// Safe merge with SHA check +$params = new MergePullRequestParams( + sha: 'abc123def456', // Only merge if head SHA matches + mergeMethod: 'merge' +); +$result = $client->mergePullRequest($repo, 123, $params); +``` + +### Working with Comments + +```php +// List comments +$comments = $client->listPullRequestComments($repo, 123); +foreach ($comments as $comment) { + echo "{$comment->author->login}: {$comment->body}\n"; +} + +// Create a comment +$comment = $client->createPullRequestComment($repo, 123, 'Looks good to me!'); +echo "Created comment: {$comment->htmlUrl}\n"; + +// Update a comment +$updatedComment = $client->updateComment($repo, $comment->id, 'Updated comment text'); + +// Delete a comment +$client->deleteComment($repo, $comment->id); +``` + +### Working with Reviews + +```php +// List reviews +$reviews = $client->listPullRequestReviews($repo, 123); +foreach ($reviews as $review) { + echo "{$review->user->login}: {$review->state}\n"; +} + +// Request reviewers +$updatedPr = $client->requestReviewers( + $repo, + 123, + reviewers: ['username1', 'username2'], + teamReviewers: ['team-slug'] +); +``` + +### Working with Status Checks + +```php +// Get combined status for a commit or branch +$status = $client->getCombinedStatus($repo, 'main'); +echo "Overall status: {$status->state}\n"; +echo "Total checks: {$status->totalCount}\n"; + +foreach ($status->statuses as $check) { + echo "{$check->context}: {$check->state}\n"; +} + +// List check runs (GitHub Actions) +$checkRuns = $client->listCheckRuns($repo, 'feature-branch'); +foreach ($checkRuns as $run) { + echo "{$run->name}: {$run->status}/{$run->conclusion}\n"; +} +``` + +### Working with Labels + +```php +// List labels +$labels = $client->listIssueLabels($repo, 123); +foreach ($labels as $label) { + echo "{$label->name} (#{$label->color})\n"; +} + +// Add labels +$labels = $client->addLabels($repo, 123, ['bug', 'priority-high']); + +// Set labels (replaces all existing labels) +$labels = $client->setLabels($repo, 123, ['bug', 'in-progress']); + +// Remove a label +$client->removeLabel($repo, 123, 'wontfix'); +``` + +### Rate Limiting and Token Information + +```php +// Check rate limit +$rateLimit = $client->getRateLimit(); +echo "Remaining: {$rateLimit->remaining}/{$rateLimit->limit}\n"; +echo "Resets at: {$rateLimit->reset}\n"; + +// Check token scopes +$scopes = $client->getTokenScopes(); +if ($scopes->hasScope('repo')) { + echo "Token has repo access\n"; +} +``` + +## Architecture + +This library follows a Request Factory pattern: + +- **Value Objects**: Immutable data classes (`GithubPullRequest`, `GithubComment`, etc.) +- **Factories**: Create value objects from API responses +- **Request Factories**: Build PSR-7 HTTP requests for each API endpoint +- **Collections**: Typed collections implementing `Iterator` and `Countable` +- **DTOs**: Data Transfer Objects for complex request parameters + +All classes use PHP 8.2+ features including: +- Named parameters +- Readonly properties +- Strict types +- Constructor property promotion + +## Testing + +```bash +# Run unit tests +vendor/bin/phpunit + +# Run with coverage +vendor/bin/phpunit --coverage-html coverage/ +``` + +## Requirements + +- PHP 8.2 or higher +- PSR-18 HTTP Client implementation +- PSR-7 HTTP Message implementation +- PSR-17 HTTP Factories implementation + +## License + +See the enclosed file LICENSE for license information (LGPL 2.1). + +## Contributing + +This package follows PER-1 coding standards with strict type declarations. + +All commits should follow the Conventional Commits specification: +- `feat:` - New features +- `fix:` - Bug fixes +- `docs:` - Documentation changes +- `test:` - Test additions or modifications +- `refactor:` - Code refactoring +- `chore:` - Maintenance tasks + +## Additional Documentation + +- [API Reference](doc/API.md) - Complete API documentation for all classes and methods +- [Migration Guide](doc/MIGRATION.md) - Guide for upgrading from earlier versions +- `bin/demo-client.php` - Working examples of all features \ No newline at end of file diff --git a/bin/demo-client.php b/bin/demo-client.php index b187ca5..0c9bc75 100755 --- a/bin/demo-client.php +++ b/bin/demo-client.php @@ -21,6 +21,7 @@ use Horde\Http\ResponseFactory; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\StreamFactoryInterface; // Bootstrap the injector $strGithubApiToken = (string) getenv('GITHUB_TOKEN'); @@ -32,10 +33,167 @@ // Setup a curl client. This is a demo, don't get too involved $injector->setInstance(ClientInterface::class, new CurlClient(new ResponseFactory(), new StreamFactory(), new Options())); $injector->setInstance(RequestFactoryInterface::class, new RequestFactory()); +$injector->setInstance(StreamFactoryInterface::class, new StreamFactory()); $injector->setInstance(GithubApiConfig::class, new GithubApiConfig(accessToken: $strGithubApiToken)); $client = $injector->get(GithubApiClient::class); + +echo "=== GitHub API Client Demo ===\n\n"; + +// Example 1: List repositories in organization +echo "1. Listing repositories in Horde organization:\n"; $repos = $client->listRepositoriesInOrganization(new GithubOrganizationId('horde')); +$count = 0; foreach ($repos as $repo) { - echo $repo->getFullName() . "\n"; + echo " - {$repo->getFullName()}\n"; + if (++$count >= 5) { + echo " ... (showing first 5)\n"; + break; + } +} +echo "\n"; + +// Example 2: Check rate limit +echo "2. Checking rate limit:\n"; +$rateLimit = $client->getRateLimit(); +echo " Remaining: {$rateLimit->remaining}/{$rateLimit->limit}\n"; +echo " Resets at: {$rateLimit->reset}\n\n"; + +// Example 3: Check token scopes +echo "3. Checking token scopes:\n"; +$scopes = $client->getTokenScopes(); +echo " Has repo scope: " . ($scopes->hasScope('repo') ? 'Yes' : 'No') . "\n"; +echo " Has workflow scope: " . ($scopes->hasScope('workflow') ? 'Yes' : 'No') . "\n\n"; + +// Example 4: List pull requests (if DEMO_REPO is set) +$demoRepo = getenv('DEMO_REPO'); +if ($demoRepo && strpos($demoRepo, '/') !== false) { + [$owner, $name] = explode('/', $demoRepo, 2); + $repo = new GithubRepository(owner: $owner, name: $name); + + echo "4. Listing pull requests for {$demoRepo}:\n"; + $pullRequests = $client->listPullRequests($repo, state: 'open'); + $prCount = 0; + foreach ($pullRequests as $pr) { + echo " PR #{$pr->number}: {$pr->title}\n"; + echo " State: {$pr->state}, Draft: " . ($pr->draft ? 'Yes' : 'No') . "\n"; + + if (++$prCount >= 3) { + echo " ... (showing first 3)\n"; + break; + } + } + echo "\n"; + + // Example 5: Get detailed PR info for first PR + if ($prCount > 0) { + $firstPr = $pullRequests->toArray()[0]; + echo "5. Getting detailed info for PR #{$firstPr->number}:\n"; + $detailedPr = $client->getPullRequest($repo, $firstPr->number); + echo " Author: {$detailedPr->author->login}\n"; + echo " Created: {$detailedPr->createdAt}\n"; + echo " Mergeable: " . ($detailedPr->mergeable ? 'Yes' : 'No') . "\n"; + echo " Labels: " . count($detailedPr->labels) . "\n"; + echo " Reviewers: " . count($detailedPr->requestedReviewers) . "\n\n"; + + // Example 6: List comments + echo "6. Listing comments on PR #{$firstPr->number}:\n"; + $comments = $client->listPullRequestComments($repo, $firstPr->number); + echo " Total comments: " . count($comments) . "\n"; + foreach ($comments as $comment) { + echo " - {$comment->author->login}: " . substr($comment->body, 0, 50) . "...\n"; + if (++$commentCount >= 3) { + echo " ... (showing first 3)\n"; + break; + } + } + echo "\n"; + + // Example 7: List reviews + echo "7. Listing reviews on PR #{$firstPr->number}:\n"; + $reviews = $client->listPullRequestReviews($repo, $firstPr->number); + echo " Total reviews: " . count($reviews) . "\n"; + foreach ($reviews as $review) { + echo " - {$review->user->login}: {$review->state}\n"; + } + echo "\n"; + + // Example 8: Check CI/CD status + echo "8. Checking CI/CD status for PR #{$firstPr->number}:\n"; + $status = $client->getCombinedStatus($repo, $detailedPr->headSha); + echo " Overall status: {$status->state}\n"; + echo " Total checks: {$status->totalCount}\n"; + + $checkRuns = $client->listCheckRuns($repo, $detailedPr->headSha); + echo " Check runs: " . count($checkRuns) . "\n"; + foreach ($checkRuns as $run) { + echo " - {$run->name}: {$run->status}"; + if ($run->conclusion) { + echo " ({$run->conclusion})"; + } + echo "\n"; + } + echo "\n"; + } +} else { + echo "4-8. Skipped (set DEMO_REPO=owner/repo to see PR examples)\n\n"; } -// List Releases of a repo + +// Example 9: Create a pull request (if CREATE_PR_DEMO=1 is set) +if (getenv('CREATE_PR_DEMO') === '1' && $demoRepo && strpos($demoRepo, '/') !== false) { + [$owner, $name] = explode('/', $demoRepo, 2); + $repo = new GithubRepository(owner: $owner, name: $name); + + echo "9. Creating a demo pull request:\n"; + + $headBranch = getenv('PR_HEAD_BRANCH') ?: 'demo-branch'; + $baseBranch = getenv('PR_BASE_BRANCH') ?: 'main'; + + try { + $createParams = new CreatePullRequestParams( + title: 'Demo PR - API Client Test', + head: $headBranch, + base: $baseBranch, + body: "This is a demo pull request created by the GitHub API Client.\n\n" . + "Created at: " . date('Y-m-d H:i:s') . "\n" . + "This PR can be safely closed.", + draft: (getenv('PR_DRAFT') === '1'), + maintainerCanModify: true + ); + + $newPr = $client->createPullRequest($repo, $createParams); + echo " ✓ Created PR #{$newPr->number}: {$newPr->title}\n"; + echo " URL: {$newPr->htmlUrl}\n"; + echo " State: {$newPr->state}\n"; + echo " Draft: " . ($newPr->draft ? 'Yes' : 'No') . "\n"; + + // Example 10: Demonstrate reopen functionality by closing and reopening + if (getenv('DEMO_REOPEN') === '1') { + echo "\n10. Demonstrating close and reopen:\n"; + + // Close the PR + $closedPr = $client->closePullRequest($repo, $newPr->number); + echo " ✓ Closed PR #{$closedPr->number}, state: {$closedPr->state}\n"; + + sleep(1); // Brief pause for API rate limiting + + // Reopen the PR + $reopenedPr = $client->reopenPullRequest($repo, $newPr->number); + echo " ✓ Reopened PR #{$reopenedPr->number}, state: {$reopenedPr->state}\n"; + } + + } catch (\Exception $e) { + echo " ✗ Error: {$e->getMessage()}\n"; + echo " Note: Make sure the head branch exists and differs from base branch\n"; + } + echo "\n"; +} + +echo "=== Demo Complete ===\n"; +echo "\nTo see PR-related examples, export DEMO_REPO=owner/repo\n"; +echo "Example: export DEMO_REPO=horde/components\n"; +echo "\nTo test PR creation, also set:\n"; +echo " CREATE_PR_DEMO=1 Enable PR creation demo\n"; +echo " PR_HEAD_BRANCH=branch Source branch (default: demo-branch)\n"; +echo " PR_BASE_BRANCH=branch Target branch (default: main)\n"; +echo " PR_DRAFT=1 Create as draft PR\n"; +echo " DEMO_REOPEN=1 Demonstrate close/reopen functionality\n"; diff --git a/create-pr.php b/create-pr.php new file mode 100755 index 0000000..8051c59 --- /dev/null +++ b/create-pr.php @@ -0,0 +1,227 @@ +#!/usr/bin/env php +setInstance(ClientInterface::class, new CurlClient(new ResponseFactory(), new StreamFactory(), new Options())); +$injector->setInstance(RequestFactoryInterface::class, new RequestFactory()); +$injector->setInstance(StreamFactoryInterface::class, new StreamFactory()); +$injector->setInstance(GithubApiConfig::class, new GithubApiConfig(accessToken: $githubToken)); + +$client = $injector->get(GithubApiClient::class); + +// Repository details +$repo = GithubRepository::fromFullName('horde/githubapiclient'); + +// PR details +$title = 'feat: add comprehensive pull request management API'; + +$body = <<<'MARKDOWN' +## Summary + +This PR adds comprehensive pull request management capabilities to the GitHub API Client, transforming it from a basic client into a full-featured PR automation tool. + +## Features Added + +### Pull Request Operations +- ✅ Create pull requests (including draft PRs and from forks) +- ✅ List pull requests with filters (base branch, head ref, state) +- ✅ Get detailed pull request information +- ✅ Update pull requests (title, body, base branch, state) +- ✅ Merge pull requests (merge, squash, rebase methods) +- ✅ Close pull requests +- ✅ Reopen closed pull requests + +### Comment Management +- ✅ List all comments on pull requests +- ✅ Create comments +- ✅ Update comments +- ✅ Delete comments + +### Review Management +- ✅ List pull request reviews +- ✅ Request reviewers (users and teams) +- ✅ Support for all review states (APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED) + +### Status Checks & CI/CD +- ✅ Get combined commit status +- ✅ List GitHub Actions check runs +- ✅ Monitor pipeline status and conclusions + +### Label Management +- ✅ List labels on issues/pull requests +- ✅ Add labels +- ✅ Set (replace) all labels +- ✅ Remove labels + +## Technical Implementation + +### Architecture +- **Request Factory Pattern**: Each API endpoint has a dedicated request factory +- **Value Objects**: Immutable domain objects with static factory methods +- **DTOs**: Clean data transfer objects for complex parameters +- **Typed Collections**: All collections implement `Iterator` and `Countable` +- **PHP 8.2+ Features**: Named parameters, readonly properties, strict types +- **PSR Compliant**: PSR-7, PSR-17, PSR-18 + +### Code Quality +- **71 unit tests** with **249 assertions** - all passing ✅ +- **52 files changed**: 5,217 insertions, 7 deletions +- **PER-1 coding standards** throughout +- **Conventional Commits** for all commits + +## Documentation + +### Added Documentation Files +- **README.md**: Comprehensive usage guide with examples for all features +- **doc/API.md**: Complete API reference (574 lines) +- **doc/MIGRATION.md**: Upgrade guide with backwards compatibility notes (305 lines) +- **bin/demo-client.php**: Enhanced with 10 working examples + +## Breaking Changes + +**None** - This is a backwards-compatible addition. Existing code continues to work unchanged. + +**Optional Enhancement**: Add `StreamFactoryInterface` parameter to constructor to enable write operations: +```php +$client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); +``` + +## Example Usage + +### Create a Pull Request +```php +use Horde\GithubApiClient\CreatePullRequestParams; + +$params = new CreatePullRequestParams( + title: 'Add new feature', + head: 'feature-branch', + base: 'main', + body: 'This PR adds...' +); +$pr = $client->createPullRequest($repo, $params); +``` + +### Merge a Pull Request +```php +use Horde\GithubApiClient\MergePullRequestParams; + +$params = new MergePullRequestParams( + commitTitle: 'feat: add feature', + mergeMethod: 'squash' +); +$result = $client->mergePullRequest($repo, 123, $params); +``` + +### Manage Comments and Reviews +```php +// Add a comment +$comment = $client->createPullRequestComment($repo, 123, 'LGTM!'); + +// Request reviewers +$client->requestReviewers($repo, 123, ['reviewer1', 'reviewer2']); + +// Check CI status +$status = $client->getCombinedStatus($repo, 'main'); +echo "Status: {$status->state}\n"; +``` + +## Test Plan + +- [x] All 71 unit tests passing +- [x] Demo client tested with real GitHub API +- [x] Documentation reviewed and examples verified +- [x] Backwards compatibility verified +- [x] Code follows PER-1 and Conventional Commits standards + +## Commits + +This PR includes 10 well-structured commits: +1. Enhanced pull request API with user and label support +2. Pull request update capability +3. Pull request comment management +4. Pull request review management +5. Commit status and check runs support +6. Label management support +7. Pull request merge and close support +8. Comprehensive documentation and examples +9. Create and reopen pull request functionality +10. Factory method and constructor documentation improvements + +## Checklist + +- [x] Code follows project coding standards (PER-1) +- [x] Unit tests added and passing (71 tests, 249 assertions) +- [x] Documentation updated (README, API reference, migration guide) +- [x] Backwards compatible (existing code unaffected) +- [x] Conventional Commits used for all commits +- [x] Demo client includes working examples +- [x] No breaking changes introduced + +## Related Issues + +Closes #[issue number if applicable] +MARKDOWN; + +echo "Creating pull request...\n"; +echo "Repository: horde/githubapiclient\n"; +echo "Head: feat/enhanced-pull-request-api\n"; +echo "Base: FRAMEWORK_6_0\n\n"; + +try { + $params = new CreatePullRequestParams( + title: $title, + head: 'feat/enhanced-pull-request-api', + base: 'FRAMEWORK_6_0', + body: $body, + draft: false, + maintainerCanModify: true + ); + + $pr = $client->createPullRequest($repo, $params); + + echo "✅ Pull request created successfully!\n\n"; + echo "PR #{$pr->number}: {$pr->title}\n"; + echo "URL: {$pr->htmlUrl}\n"; + echo "State: {$pr->state}\n"; + echo "Author: {$pr->author->login}\n"; + echo "\nYou can view the PR at: {$pr->htmlUrl}\n"; + +} catch (\Exception $e) { + echo "❌ Error creating pull request:\n"; + echo $e->getMessage() . "\n"; + exit(1); +} diff --git a/doc/API.md b/doc/API.md new file mode 100644 index 0000000..b062239 --- /dev/null +++ b/doc/API.md @@ -0,0 +1,574 @@ +# GitHub API Client - API Reference + +## Table of Contents + +- [GithubApiClient](#githubapiclient) +- [Configuration](#configuration) +- [Value Objects](#value-objects) +- [Collections](#collections) +- [DTOs](#dtos) + +## GithubApiClient + +Main client class for interacting with the GitHub API. + +### Constructor + +```php +public function __construct( + ClientInterface $httpClient, + RequestFactoryInterface $requestFactory, + GithubApiConfig $config, + ?StreamFactoryInterface $streamFactory = null +) +``` + +**Parameters:** +- `$httpClient` - PSR-18 HTTP client +- `$requestFactory` - PSR-17 request factory +- `$config` - GitHub API configuration (contains access token) +- `$streamFactory` - PSR-17 stream factory (required for POST/PUT/PATCH operations) + +### Repository Methods + +#### listRepositoriesInOrganization() + +List all repositories in an organization. + +```php +public function listRepositoriesInOrganization( + GithubOrganizationId $org +): GithubRepositoryList +``` + +**Returns:** Collection of repositories + +### Pull Request Methods + +#### listPullRequests() + +List pull requests in a repository. + +```php +public function listPullRequests( + GithubRepository $repo, + string $baseBranch = '', + string $headRef = '', + string $state = 'open' +): GithubPullRequestList +``` + +**Parameters:** +- `$repo` - The repository +- `$baseBranch` - Filter by base branch (optional) +- `$headRef` - Filter by head reference (optional) +- `$state` - Filter by state: 'open', 'closed', 'all' (default: 'open') + +**Returns:** Collection of pull requests + +#### getPullRequest() + +Get a single pull request with complete details. + +```php +public function getPullRequest( + GithubRepository $repo, + int $number +): GithubPullRequest +``` + +**Returns:** Complete pull request details + +#### createPullRequest() + +Create a new pull request. + +```php +public function createPullRequest( + GithubRepository $repo, + CreatePullRequestParams $params +): GithubPullRequest +``` + +**Parameters:** +- `$repo` - The repository +- `$params` - Pull request creation parameters + +**Returns:** The created pull request + +#### updatePullRequest() + +Update a pull request's title, body, base branch, or state. + +```php +public function updatePullRequest( + GithubRepository $repo, + int $number, + PullRequestUpdate $update +): GithubPullRequest +``` + +**Returns:** Updated pull request + +#### reopenPullRequest() + +Reopen a closed pull request. + +```php +public function reopenPullRequest( + GithubRepository $repo, + int $number +): GithubPullRequest +``` + +**Returns:** Reopened pull request + +#### mergePullRequest() + +Merge a pull request. + +```php +public function mergePullRequest( + GithubRepository $repo, + int $number, + MergePullRequestParams $params +): MergeResult +``` + +**Parameters:** +- `$params` - Merge configuration (method, commit message, etc.) + +**Returns:** Merge operation result + +#### closePullRequest() + +Close a pull request without merging. + +```php +public function closePullRequest( + GithubRepository $repo, + int $number +): GithubPullRequest +``` + +**Returns:** Closed pull request + +### Comment Methods + +#### listPullRequestComments() + +List all comments on a pull request. + +```php +public function listPullRequestComments( + GithubRepository $repo, + int $number +): GithubCommentList +``` + +**Returns:** Collection of comments + +#### createPullRequestComment() + +Create a comment on a pull request. + +```php +public function createPullRequestComment( + GithubRepository $repo, + int $number, + string $body +): GithubComment +``` + +**Returns:** Created comment + +#### updateComment() + +Update an existing comment. + +```php +public function updateComment( + GithubRepository $repo, + int $commentId, + string $body +): GithubComment +``` + +**Returns:** Updated comment + +#### deleteComment() + +Delete a comment. + +```php +public function deleteComment( + GithubRepository $repo, + int $commentId +): void +``` + +### Review Methods + +#### listPullRequestReviews() + +List all reviews on a pull request. + +```php +public function listPullRequestReviews( + GithubRepository $repo, + int $number +): GithubReviewList +``` + +**Returns:** Collection of reviews + +#### requestReviewers() + +Request reviewers for a pull request. + +```php +public function requestReviewers( + GithubRepository $repo, + int $number, + array $reviewers = [], + array $teamReviewers = [] +): GithubPullRequest +``` + +**Parameters:** +- `$reviewers` - Array of user logins +- `$teamReviewers` - Array of team slugs + +**Returns:** Updated pull request with requested reviewers + +### Status Check Methods + +#### getCombinedStatus() + +Get combined status for a commit. + +```php +public function getCombinedStatus( + GithubRepository $repo, + string $ref +): GithubCombinedStatus +``` + +**Parameters:** +- `$ref` - Commit SHA, branch name, or tag name + +**Returns:** Combined status with all checks + +#### listCheckRuns() + +List check runs (GitHub Actions) for a commit. + +```php +public function listCheckRuns( + GithubRepository $repo, + string $ref +): GithubCheckRunList +``` + +**Parameters:** +- `$ref` - Commit SHA, branch name, or tag name + +**Returns:** Collection of check runs + +### Label Methods + +#### listIssueLabels() + +List labels on an issue or pull request. + +```php +public function listIssueLabels( + GithubRepository $repo, + int $number +): GithubLabelList +``` + +**Returns:** Collection of labels + +#### addLabels() + +Add labels to an issue or pull request. + +```php +public function addLabels( + GithubRepository $repo, + int $number, + array $labels +): GithubLabelList +``` + +**Parameters:** +- `$labels` - Array of label names + +**Returns:** Updated list of labels + +#### setLabels() + +Set (replace) all labels on an issue or pull request. + +```php +public function setLabels( + GithubRepository $repo, + int $number, + array $labels +): GithubLabelList +``` + +**Parameters:** +- `$labels` - Array of label names + +**Returns:** Updated list of labels + +#### removeLabel() + +Remove a label from an issue or pull request. + +```php +public function removeLabel( + GithubRepository $repo, + int $number, + string $labelName +): void +``` + +### Rate Limit Methods + +#### getRateLimit() + +Get current rate limit status. + +```php +public function getRateLimit(): RateLimit +``` + +**Returns:** Rate limit information + +#### getTokenScopes() + +Get OAuth scopes for the current access token. + +```php +public function getTokenScopes(): TokenScopes +``` + +**Returns:** Token scope information + +## Configuration + +### GithubApiConfig + +Configuration object containing API credentials. + +```php +public function __construct( + public readonly string $accessToken +) +``` + +## Value Objects + +### GithubPullRequest + +Represents a GitHub pull request. + +**Properties:** +- `int $number` - Pull request number +- `string $title` - Pull request title +- `string $state` - State: 'open', 'closed', 'merged' +- `string $htmlUrl` - Web URL +- `?string $body` - Description body +- `bool $draft` - Is draft PR +- `bool $merged` - Is merged +- `?string $mergedAt` - Merge timestamp +- `string $createdAt` - Creation timestamp +- `string $updatedAt` - Last update timestamp +- `string $headRef` - Head branch name +- `string $baseRef` - Base branch name +- `string $headSha` - Head commit SHA +- `?GithubUser $author` - PR author +- `array $labels` - Labels +- `array $requestedReviewers` - Requested reviewers +- `?bool $mergeable` - Can be merged +- `?string $mergeableState` - Mergeable state + +### GithubComment + +Represents a comment on a pull request. + +**Properties:** +- `int $id` - Comment ID +- `string $body` - Comment text +- `GithubUser $author` - Comment author +- `string $createdAt` - Creation timestamp +- `string $updatedAt` - Last update timestamp +- `string $htmlUrl` - Web URL +- `string $apiUrl` - API URL + +### GithubReview + +Represents a pull request review. + +**Properties:** +- `int $id` - Review ID +- `GithubUser $user` - Reviewer +- `string $body` - Review comment +- `string $state` - Review state: 'APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED' +- `string $htmlUrl` - Web URL +- `string $submittedAt` - Submission timestamp +- `string $commitId` - Commit SHA reviewed + +### GithubUser + +Represents a GitHub user. + +**Properties:** +- `string $login` - Username +- `int $id` - User ID +- `string $avatarUrl` - Avatar URL +- `string $htmlUrl` - Profile URL +- `string $type` - User type: 'User', 'Bot', 'Organization' + +### GithubLabel + +Represents a label. + +**Properties:** +- `string $name` - Label name +- `string $color` - Hex color (without #) +- `string $description` - Label description + +### GithubCommitStatus + +Represents a commit status check. + +**Properties:** +- `string $state` - Status: 'success', 'failure', 'pending', 'error' +- `string $context` - Check context/name +- `string $description` - Status description +- `string $targetUrl` - Details URL +- `string $createdAt` - Creation timestamp +- `string $updatedAt` - Update timestamp + +### GithubCombinedStatus + +Represents combined status of all checks. + +**Properties:** +- `string $state` - Overall state +- `string $sha` - Commit SHA +- `int $totalCount` - Total number of checks +- `array $statuses` - Individual statuses + +### GithubCheckRun + +Represents a GitHub Actions check run. + +**Properties:** +- `int $id` - Check run ID +- `string $name` - Check name +- `string $status` - Status: 'queued', 'in_progress', 'completed' +- `string $conclusion` - Conclusion: 'success', 'failure', 'neutral', 'cancelled', 'skipped', 'timed_out', 'action_required' +- `string $headSha` - Commit SHA +- `string $htmlUrl` - Web URL +- `string $detailsUrl` - Details URL +- `string $startedAt` - Start timestamp +- `string $completedAt` - Completion timestamp + +### MergeResult + +Represents the result of a merge operation. + +**Properties:** +- `string $sha` - Merge commit SHA +- `bool $merged` - Was merge successful +- `string $message` - Result message + +## Collections + +All collection classes implement `Iterator` and `Countable` interfaces. + +- `GithubRepositoryList` - Collection of repositories +- `GithubPullRequestList` - Collection of pull requests +- `GithubCommentList` - Collection of comments +- `GithubReviewList` - Collection of reviews +- `GithubLabelList` - Collection of labels +- `GithubCheckRunList` - Collection of check runs + +**Methods:** +- `count(): int` - Get number of items +- `toArray(): array` - Convert to array +- Plus all standard iterator methods + +## DTOs + +### CreatePullRequestParams + +Data transfer object for creating pull requests. + +```php +public function __construct( + public readonly string $title, + public readonly string $head, + public readonly string $base, + public readonly string $body = '', + public readonly bool $draft = false, + public readonly bool $maintainerCanModify = true +) +``` + +**Parameters:** +- `$title` - The title of the pull request (required) +- `$head` - The name of the branch where your changes are (required) + - Can be a simple branch name: `'feature-branch'` + - Can include owner prefix for forks: `'username:feature-branch'` +- `$base` - The name of the branch you want changes pulled into (required) +- `$body` - The description/body of the pull request (optional) +- `$draft` - Whether to create as a draft PR (optional, default: false) +- `$maintainerCanModify` - Whether maintainers can modify the PR (optional, default: true) + +**Methods:** +- `toArray(): array` - Convert to API payload + +### PullRequestUpdate + +Data transfer object for updating pull requests. + +```php +public function __construct( + public readonly ?string $title = null, + public readonly ?string $body = null, + public readonly ?string $base = null, + public readonly ?string $state = null +) +``` + +**Methods:** +- `toArray(): array` - Convert to API payload (excludes null fields) +- `isEmpty(): bool` - Check if any field is set + +### MergePullRequestParams + +Data transfer object for merge operations. + +```php +public function __construct( + public readonly string $commitTitle = '', + public readonly string $commitMessage = '', + public readonly string $mergeMethod = 'merge', + public readonly string $sha = '' +) +``` + +**Parameters:** +- `$commitTitle` - Custom commit title +- `$commitMessage` - Custom commit message +- `$mergeMethod` - Merge method: 'merge', 'squash', 'rebase' +- `$sha` - Expected head SHA (for safe merging) + +**Methods:** +- `toArray(): array` - Convert to API payload (excludes defaults) diff --git a/doc/MIGRATION.md b/doc/MIGRATION.md new file mode 100644 index 0000000..e2d3560 --- /dev/null +++ b/doc/MIGRATION.md @@ -0,0 +1,305 @@ +# Migration Guide + +## Upgrading to Enhanced API Version + +This guide helps you upgrade from the basic GitHub API client to the enhanced version with comprehensive pull request management capabilities. + +## What's New + +The enhanced version adds extensive pull request management features: + +- **Pull Request Operations**: Get, update, merge, and close pull requests +- **Comment Management**: Create, read, update, and delete PR comments +- **Review Management**: List reviews and request reviewers +- **Status Checks**: Monitor CI/CD pipeline status and check runs +- **Label Management**: Add, remove, and set labels on issues and PRs +- **Enhanced Data**: Pull requests now include many additional fields + +## Breaking Changes + +### StreamFactory Now Optional But Recommended + +**Previous:** +```php +$client = new GithubApiClient($httpClient, $requestFactory, $config); +``` + +**Now (recommended):** +```php +$client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); +``` + +**Impact:** Methods that create or update resources (POST/PUT/PATCH) require a StreamFactory: +- `updatePullRequest()` +- `createPullRequestComment()` +- `updateComment()` +- `requestReviewers()` +- `addLabels()` +- `setLabels()` +- `mergePullRequest()` + +If you attempt to use these methods without providing a StreamFactory, you'll get an exception with a clear message. + +**Action Required:** Pass a PSR-17 StreamFactory implementation to the constructor if you need write operations. + +### Enhanced GithubPullRequest Object + +The `GithubPullRequest` object now has many additional properties that may be null in older API responses: + +**New Properties:** +- `?string $body` - PR description +- `bool $draft` - Is draft PR +- `bool $merged` - Is merged +- `?string $mergedAt` - Merge timestamp +- `string $createdAt` - Creation timestamp +- `string $updatedAt` - Update timestamp +- `string $headRef` - Head branch +- `string $baseRef` - Base branch +- `string $headSha` - Head commit SHA +- `?GithubUser $author` - PR author +- `array $labels` - Labels +- `array $requestedReviewers` - Reviewers +- `?bool $mergeable` - Mergeable flag +- `?string $mergeableState` - Mergeable state + +**Impact:** If you're serializing or storing PR objects, you may need to handle these new fields. + +**Action Required:** Review code that processes `GithubPullRequest` objects. Most code should continue working as the core properties (`number`, `title`, `state`, `htmlUrl`) remain unchanged. + +## New Capabilities + +### 1. Get Detailed Pull Request Information + +```php +// New method - get complete PR details +$pr = $client->getPullRequest($repo, 123); + +// Now you can access many more fields +echo "Author: {$pr->author->login}\n"; +echo "Created: {$pr->createdAt}\n"; +echo "Mergeable: " . ($pr->mergeable ? 'Yes' : 'No') . "\n"; +echo "Draft: " . ($pr->draft ? 'Yes' : 'No') . "\n"; +``` + +### 2. Update Pull Requests + +```php +use Horde\GithubApiClient\PullRequestUpdate; + +// Update title +$update = new PullRequestUpdate(title: 'New Title'); +$client->updatePullRequest($repo, 123, $update); + +// Update multiple fields +$update = new PullRequestUpdate( + title: 'New Title', + body: 'Updated description', + base: 'develop' +); +$client->updatePullRequest($repo, 123, $update); + +// Close a PR +$client->closePullRequest($repo, 123); +``` + +### 3. Manage Comments + +```php +// List comments +$comments = $client->listPullRequestComments($repo, 123); + +// Create comment +$comment = $client->createPullRequestComment($repo, 123, 'Great work!'); + +// Update comment +$client->updateComment($repo, $comment->id, 'Even better work!'); + +// Delete comment +$client->deleteComment($repo, $comment->id); +``` + +### 4. Work with Reviews + +```php +// List reviews +$reviews = $client->listPullRequestReviews($repo, 123); +foreach ($reviews as $review) { + echo "{$review->user->login}: {$review->state}\n"; +} + +// Request reviewers +$client->requestReviewers($repo, 123, ['username1', 'username2']); +``` + +### 5. Monitor CI/CD Status + +```php +// Get combined status +$status = $client->getCombinedStatus($repo, 'main'); +echo "Status: {$status->state}\n"; + +// List check runs +$checkRuns = $client->listCheckRuns($repo, 'feature-branch'); +foreach ($checkRuns as $run) { + echo "{$run->name}: {$run->conclusion}\n"; +} +``` + +### 6. Manage Labels + +```php +// List labels +$labels = $client->listIssueLabels($repo, 123); + +// Add labels +$client->addLabels($repo, 123, ['bug', 'priority-high']); + +// Replace all labels +$client->setLabels($repo, 123, ['bug', 'in-progress']); + +// Remove a label +$client->removeLabel($repo, 123, 'wontfix'); +``` + +### 7. Merge Pull Requests + +```php +use Horde\GithubApiClient\MergePullRequestParams; + +// Simple merge +$params = new MergePullRequestParams(); +$result = $client->mergePullRequest($repo, 123, $params); + +// Squash merge with custom message +$params = new MergePullRequestParams( + commitTitle: 'feat: add feature X', + commitMessage: 'Implements feature X\n\nCloses #123', + mergeMethod: 'squash' +); +$result = $client->mergePullRequest($repo, 123, $params); + +if ($result->merged) { + echo "Successfully merged: {$result->sha}\n"; +} +``` + +## Gradual Migration Strategy + +You can adopt the new features gradually: + +### Phase 1: Update Constructor (Optional) +Add StreamFactory to enable write operations: +```php +$client = new GithubApiClient( + $httpClient, + $requestFactory, + $config, + $streamFactory // Add this +); +``` + +### Phase 2: Use New Read Operations +Start using new read-only methods that don't require StreamFactory: +- `getPullRequest()` +- `listPullRequestComments()` +- `listPullRequestReviews()` +- `getCombinedStatus()` +- `listCheckRuns()` +- `listIssueLabels()` + +### Phase 3: Add Write Operations +Once StreamFactory is added, use write operations: +- `updatePullRequest()` +- `createPullRequestComment()` +- `requestReviewers()` +- `addLabels()` +- `mergePullRequest()` + +## Testing Your Upgrade + +1. **Add StreamFactory:** + ```php + use Horde\Http\StreamFactory; + $streamFactory = new StreamFactory(); + ``` + +2. **Update client instantiation:** + ```php + $client = new GithubApiClient( + $httpClient, + $requestFactory, + $config, + $streamFactory + ); + ``` + +3. **Test basic operations still work:** + ```php + // Existing functionality should still work + $repos = $client->listRepositoriesInOrganization($org); + $pullRequests = $client->listPullRequests($repo); + ``` + +4. **Try new features:** + ```php + // New functionality + $pr = $client->getPullRequest($repo, 1); + $comments = $client->listPullRequestComments($repo, 1); + ``` + +## Backwards Compatibility + +The enhanced version maintains backwards compatibility for: +- All existing method signatures +- Core `GithubPullRequest` properties (`number`, `title`, `state`, `htmlUrl`) +- Repository listing functionality +- Basic pull request listing + +The only change that may require code updates is adding the StreamFactory parameter if you want to use write operations. + +## Getting Help + +- See [README.md](README.md) for comprehensive usage examples +- See [API.md](API.md) for complete API reference +- Run `bin/demo-client.php` to see working examples + +## Troubleshooting + +### "StreamFactory is required" Exception + +**Problem:** Trying to use a write operation without StreamFactory. + +**Solution:** Add StreamFactory to the constructor: +```php +use Horde\Http\StreamFactory; +$client = new GithubApiClient( + $httpClient, + $requestFactory, + $config, + new StreamFactory() // Add this +); +``` + +### Unexpected Properties on GithubPullRequest + +**Problem:** Code breaks when encountering new PR properties. + +**Solution:** The new properties are additions, not changes. Ensure your code only accesses the properties it needs and handles null values appropriately: +```php +// Safe +$title = $pr->title; // Always present + +// Also safe +$author = $pr->author?->login ?? 'Unknown'; // Nullable +``` + +### Missing Type Declarations + +**Problem:** Type errors when passing parameters. + +**Solution:** Use the correct types: +- Repository: `GithubRepository` object +- Update parameters: `PullRequestUpdate` DTO +- Merge parameters: `MergePullRequestParams` DTO +- Numbers: `int` (not string) +- Labels: `array` (not comma-separated string) diff --git a/phpunit.xml b/phpunit.xml index 1a3bdf0..bd9744f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -14,7 +14,7 @@ colors="true"> - tests/unit + test/unit diff --git a/src/AddLabelsRequestFactory.php b/src/AddLabelsRequestFactory.php new file mode 100644 index 0000000..d18303b --- /dev/null +++ b/src/AddLabelsRequestFactory.php @@ -0,0 +1,62 @@ + $labels Label names to add + */ + public function __construct( + private readonly RequestFactoryInterface $requestFactory, + private readonly StreamFactoryInterface $streamFactory, + private readonly GithubApiConfig $config, + private readonly GithubRepository $repo, + private readonly int $issueNumber, + private readonly array $labels + ) {} + + /** + * Create HTTP request to add labels + * + * @return RequestInterface + */ + public function create(): RequestInterface + { + $url = sprintf( + 'https://api.github.com/repos/%s/%s/issues/%d/labels', + $this->repo->owner, + $this->repo->name, + $this->issueNumber + ); + + $jsonBody = json_encode(['labels' => $this->labels], JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + + $request = $this->requestFactory->createRequest('POST', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/src/CreatePullRequestCommentRequestFactory.php b/src/CreatePullRequestCommentRequestFactory.php new file mode 100644 index 0000000..02fb825 --- /dev/null +++ b/src/CreatePullRequestCommentRequestFactory.php @@ -0,0 +1,59 @@ +repo->owner, + $this->repo->name, + $this->pullNumber + ); + + $jsonBody = json_encode(['body' => $this->body], JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + + $request = $this->requestFactory->createRequest('POST', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/src/CreatePullRequestParams.php b/src/CreatePullRequestParams.php new file mode 100644 index 0000000..5c04d74 --- /dev/null +++ b/src/CreatePullRequestParams.php @@ -0,0 +1,62 @@ + + */ + public function toArray(): array + { + $data = [ + 'title' => $this->title, + 'head' => $this->head, + 'base' => $this->base, + 'maintainer_can_modify' => $this->maintainerCanModify, + ]; + + if ($this->body !== '') { + $data['body'] = $this->body; + } + + if ($this->draft) { + $data['draft'] = true; + } + + return $data; + } +} diff --git a/src/CreatePullRequestRequestFactory.php b/src/CreatePullRequestRequestFactory.php new file mode 100644 index 0000000..edc72b6 --- /dev/null +++ b/src/CreatePullRequestRequestFactory.php @@ -0,0 +1,57 @@ +repo->owner, + $this->repo->name + ); + + $jsonBody = json_encode($this->params->toArray(), JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + + $request = $this->requestFactory->createRequest('POST', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/src/DeleteCommentRequestFactory.php b/src/DeleteCommentRequestFactory.php new file mode 100644 index 0000000..8163faf --- /dev/null +++ b/src/DeleteCommentRequestFactory.php @@ -0,0 +1,51 @@ +repo->owner, + $this->repo->name, + $this->commentId + ); + + $request = $this->requestFactory->createRequest('DELETE', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + + return $request; + } +} diff --git a/src/GetCombinedStatusRequestFactory.php b/src/GetCombinedStatusRequestFactory.php new file mode 100644 index 0000000..e654350 --- /dev/null +++ b/src/GetCombinedStatusRequestFactory.php @@ -0,0 +1,51 @@ +repo->owner, + $this->repo->name, + $this->ref + ); + + $request = $this->requestFactory->createRequest('GET', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + + return $request; + } +} diff --git a/src/GetPullRequestRequestFactory.php b/src/GetPullRequestRequestFactory.php new file mode 100644 index 0000000..a3f9cd2 --- /dev/null +++ b/src/GetPullRequestRequestFactory.php @@ -0,0 +1,51 @@ +repo->owner, + $this->repo->name, + $this->pullNumber + ); + + $request = $this->requestFactory->createRequest('GET', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + + return $request; + } +} diff --git a/src/GithubApiClient.php b/src/GithubApiClient.php index 2ef3629..c3c36b5 100644 --- a/src/GithubApiClient.php +++ b/src/GithubApiClient.php @@ -7,6 +7,7 @@ use Horde\Http\RequestFactory; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\StreamFactoryInterface; use Exception; use Stringable; use OutOfBoundsException; @@ -16,7 +17,8 @@ class GithubApiClient public function __construct( private readonly ClientInterface $httpClient, private readonly RequestFactoryInterface $requestFactory, - private readonly GithubApiConfig $config + private readonly GithubApiConfig $config, + private readonly ?StreamFactoryInterface $streamFactory = null ) {} public function listRepositoriesInOrganization(GithubOrganizationId $org): GithubRepositoryList @@ -102,6 +104,555 @@ public function getTokenScopes(): TokenScopes } } + /** + * Get a single pull request with complete details + * + * @param GithubRepository $repo The repository + * @param int $number The pull request number + * @return GithubPullRequest + * @throws Exception + */ + public function getPullRequest(GithubRepository $repo, int $number): GithubPullRequest + { + $requestFactory = new GetPullRequestRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $number + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + $prFactory = new GithubPullRequestFactory(); + return $prFactory->createFromApiResponse($data); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * Update a pull request (title, body, base, or state) + * + * @param GithubRepository $repo The repository + * @param int $number The pull request number + * @param PullRequestUpdate $update The fields to update + * @return GithubPullRequest The updated pull request + * @throws Exception + */ + public function updatePullRequest(GithubRepository $repo, int $number, PullRequestUpdate $update): GithubPullRequest + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for updatePullRequest. Please provide it in the constructor.'); + } + + $requestFactory = new UpdatePullRequestRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $repo, + $number, + $update + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + $prFactory = new GithubPullRequestFactory(); + return $prFactory->createFromApiResponse($data); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * List all comments on a pull request + * + * @param GithubRepository $repo The repository + * @param int $number The pull request number + * @return GithubCommentList + * @throws Exception + */ + public function listPullRequestComments(GithubRepository $repo, int $number): GithubCommentList + { + $requestFactory = new ListPullRequestCommentsRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $number + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + $commentFactory = new GithubCommentFactory(); + $comments = []; + foreach ($data as $commentData) { + $comments[] = $commentFactory->createFromApiResponse($commentData); + } + return new GithubCommentList($comments); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * Create a comment on a pull request + * + * @param GithubRepository $repo The repository + * @param int $number The pull request number + * @param string $body The comment body + * @return GithubComment The created comment + * @throws Exception + */ + public function createPullRequestComment(GithubRepository $repo, int $number, string $body): GithubComment + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for createPullRequestComment. Please provide it in the constructor.'); + } + + $requestFactory = new CreatePullRequestCommentRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $repo, + $number, + $body + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 201) { + $data = json_decode((string) $response->getBody()); + $commentFactory = new GithubCommentFactory(); + return $commentFactory->createFromApiResponse($data); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * Update a comment + * + * @param GithubRepository $repo The repository + * @param int $commentId The comment ID + * @param string $body The new comment body + * @return GithubComment The updated comment + * @throws Exception + */ + public function updateComment(GithubRepository $repo, int $commentId, string $body): GithubComment + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for updateComment. Please provide it in the constructor.'); + } + + $requestFactory = new UpdateCommentRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $repo, + $commentId, + $body + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + $commentFactory = new GithubCommentFactory(); + return $commentFactory->createFromApiResponse($data); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * Delete a comment + * + * @param GithubRepository $repo The repository + * @param int $commentId The comment ID + * @return void + * @throws Exception + */ + public function deleteComment(GithubRepository $repo, int $commentId): void + { + $requestFactory = new DeleteCommentRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $commentId + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() !== 204) { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * List all reviews on a pull request + * + * @param GithubRepository $repo The repository + * @param int $number The pull request number + * @return GithubReviewList + * @throws Exception + */ + public function listPullRequestReviews(GithubRepository $repo, int $number): GithubReviewList + { + $requestFactory = new ListPullRequestReviewsRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $number + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + $reviewFactory = new GithubReviewFactory(); + $reviews = []; + foreach ($data as $reviewData) { + $reviews[] = $reviewFactory->createFromApiResponse($reviewData); + } + return new GithubReviewList($reviews); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * Request reviewers for a pull request + * + * @param GithubRepository $repo The repository + * @param int $number The pull request number + * @param array $reviewers User logins to request as reviewers + * @param array $teamReviewers Team slugs to request as reviewers + * @return GithubPullRequest The updated pull request + * @throws Exception + */ + public function requestReviewers(GithubRepository $repo, int $number, array $reviewers = [], array $teamReviewers = []): GithubPullRequest + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for requestReviewers. Please provide it in the constructor.'); + } + + $requestFactory = new RequestReviewersRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $repo, + $number, + $reviewers, + $teamReviewers + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 201) { + $data = json_decode((string) $response->getBody()); + $prFactory = new GithubPullRequestFactory(); + return $prFactory->createFromApiResponse($data); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * Get combined status for a commit + * + * @param GithubRepository $repo The repository + * @param string $ref The commit SHA, branch name, or tag name + * @return GithubCombinedStatus + * @throws Exception + */ + public function getCombinedStatus(GithubRepository $repo, string $ref): GithubCombinedStatus + { + $requestFactory = new GetCombinedStatusRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $ref + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + return GithubCombinedStatus::fromApiResponse($data); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * List check runs for a commit + * + * @param GithubRepository $repo The repository + * @param string $ref The commit SHA, branch name, or tag name + * @return GithubCheckRunList + * @throws Exception + */ + public function listCheckRuns(GithubRepository $repo, string $ref): GithubCheckRunList + { + $requestFactory = new ListCheckRunsRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $ref + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + $checkRuns = []; + if (isset($data->check_runs) && is_array($data->check_runs)) { + foreach ($data->check_runs as $checkRunData) { + $checkRuns[] = GithubCheckRun::fromApiResponse($checkRunData); + } + } + return new GithubCheckRunList($checkRuns); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * List labels on an issue or pull request + * + * @param GithubRepository $repo The repository + * @param int $number The issue or pull request number + * @return GithubLabelList + * @throws Exception + */ + public function listIssueLabels(GithubRepository $repo, int $number): GithubLabelList + { + $requestFactory = new ListIssueLabelsRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $number + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + $labelFactory = new GithubLabelFactory(); + $labels = []; + foreach ($data as $labelData) { + $labels[] = $labelFactory->createFromApiResponse($labelData); + } + return new GithubLabelList($labels); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * Add labels to an issue or pull request + * + * @param GithubRepository $repo The repository + * @param int $number The issue or pull request number + * @param array $labels Label names to add + * @return GithubLabelList The updated list of labels + * @throws Exception + */ + public function addLabels(GithubRepository $repo, int $number, array $labels): GithubLabelList + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for addLabels. Please provide it in the constructor.'); + } + + $requestFactory = new AddLabelsRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $repo, + $number, + $labels + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + $labelFactory = new GithubLabelFactory(); + $labelsList = []; + foreach ($data as $labelData) { + $labelsList[] = $labelFactory->createFromApiResponse($labelData); + } + return new GithubLabelList($labelsList); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * Set (replace) all labels on an issue or pull request + * + * @param GithubRepository $repo The repository + * @param int $number The issue or pull request number + * @param array $labels Label names to set (replaces all existing labels) + * @return GithubLabelList The updated list of labels + * @throws Exception + */ + public function setLabels(GithubRepository $repo, int $number, array $labels): GithubLabelList + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for setLabels. Please provide it in the constructor.'); + } + + $requestFactory = new SetLabelsRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $repo, + $number, + $labels + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + $labelFactory = new GithubLabelFactory(); + $labelsList = []; + foreach ($data as $labelData) { + $labelsList[] = $labelFactory->createFromApiResponse($labelData); + } + return new GithubLabelList($labelsList); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * Remove a label from an issue or pull request + * + * @param GithubRepository $repo The repository + * @param int $number The issue or pull request number + * @param string $labelName The name of the label to remove + * @return void + * @throws Exception + */ + public function removeLabel(GithubRepository $repo, int $number, string $labelName): void + { + $requestFactory = new RemoveLabelRequestFactory( + $this->requestFactory, + $this->config, + $repo, + $number, + $labelName + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() !== 200) { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * Merge a pull request + * + * @param GithubRepository $repo The repository + * @param int $number The pull request number + * @param MergePullRequestParams $params Merge parameters + * @return MergeResult The result of the merge operation + * @throws Exception + */ + public function mergePullRequest(GithubRepository $repo, int $number, MergePullRequestParams $params): MergeResult + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for mergePullRequest. Please provide it in the constructor.'); + } + + $requestFactory = new MergePullRequestRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $repo, + $number, + $params + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 200) { + $data = json_decode((string) $response->getBody()); + return MergeResult::fromApiResponse($data); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + + /** + * Close a pull request without merging + * + * @param GithubRepository $repo The repository + * @param int $number The pull request number + * @return GithubPullRequest The closed pull request + * @throws Exception + */ + public function closePullRequest(GithubRepository $repo, int $number): GithubPullRequest + { + $update = new PullRequestUpdate(state: 'closed'); + return $this->updatePullRequest($repo, $number, $update); + } + + /** + * Reopen a closed pull request + * + * @param GithubRepository $repo The repository + * @param int $number The pull request number + * @return GithubPullRequest The reopened pull request + * @throws Exception + */ + public function reopenPullRequest(GithubRepository $repo, int $number): GithubPullRequest + { + $update = new PullRequestUpdate(state: 'open'); + return $this->updatePullRequest($repo, $number, $update); + } + + /** + * Create a new pull request + * + * @param GithubRepository $repo The repository + * @param CreatePullRequestParams $params The pull request parameters + * @return GithubPullRequest The created pull request + * @throws Exception + */ + public function createPullRequest(GithubRepository $repo, CreatePullRequestParams $params): GithubPullRequest + { + if ($this->streamFactory === null) { + throw new Exception('StreamFactory is required for createPullRequest. Please provide it in the constructor.'); + } + + $requestFactory = new CreatePullRequestRequestFactory( + $this->requestFactory, + $this->streamFactory, + $this->config, + $repo, + $params + ); + $request = $requestFactory->create(); + $response = $this->httpClient->sendRequest($request); + + if ($response->getStatusCode() === 201) { + $data = json_decode((string) $response->getBody()); + $prFactory = new GithubPullRequestFactory(); + return $prFactory->createFromApiResponse($data); + } else { + throw new Exception($response->getStatusCode() . ' ' . $response->getReasonPhrase()); + } + } + /** * @param array $repos * @param string $json diff --git a/src/GithubCheckRun.php b/src/GithubCheckRun.php new file mode 100644 index 0000000..f525955 --- /dev/null +++ b/src/GithubCheckRun.php @@ -0,0 +1,60 @@ +name, $this->status, $this->conclusion); + } + + /** + * Create from GitHub API response + * + * @param object $data The API response data + * @return self + */ + public static function fromApiResponse(object $data): self + { + return new self( + id: $data->id ?? 0, + name: $data->name ?? '', + status: $data->status ?? '', + conclusion: $data->conclusion ?? '', + headSha: $data->head_sha ?? '', + htmlUrl: $data->html_url ?? '', + detailsUrl: $data->details_url ?? '', + startedAt: $data->started_at ?? '', + completedAt: $data->completed_at ?? '' + ); + } +} diff --git a/src/GithubCheckRunList.php b/src/GithubCheckRunList.php new file mode 100644 index 0000000..139ef73 --- /dev/null +++ b/src/GithubCheckRunList.php @@ -0,0 +1,71 @@ + + */ +class GithubCheckRunList implements Iterator, Countable +{ + private int $position = 0; + + /** + * @param array $checkRuns + */ + public function __construct( + private readonly array $checkRuns + ) {} + + public function current(): GithubCheckRun + { + return $this->checkRuns[$this->position]; + } + + public function key(): int + { + return $this->position; + } + + public function next(): void + { + ++$this->position; + } + + public function rewind(): void + { + $this->position = 0; + } + + public function valid(): bool + { + return isset($this->checkRuns[$this->position]); + } + + public function count(): int + { + return count($this->checkRuns); + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->checkRuns; + } +} diff --git a/src/GithubCombinedStatus.php b/src/GithubCombinedStatus.php new file mode 100644 index 0000000..3f68e04 --- /dev/null +++ b/src/GithubCombinedStatus.php @@ -0,0 +1,60 @@ + $statuses + */ + public function __construct( + public readonly string $state, + public readonly string $sha, + public readonly int $totalCount, + public readonly array $statuses + ) {} + + public function __toString(): string + { + return sprintf('%s (%d checks)', $this->state, $this->totalCount); + } + + /** + * Create from GitHub API response + * + * @param object $data The API response data + * @return self + */ + public static function fromApiResponse(object $data): self + { + $statuses = []; + if (isset($data->statuses) && is_array($data->statuses)) { + foreach ($data->statuses as $statusData) { + $statuses[] = GithubCommitStatus::fromApiResponse($statusData); + } + } + + return new self( + state: $data->state ?? '', + sha: $data->sha ?? '', + totalCount: $data->total_count ?? 0, + statuses: $statuses + ); + } +} diff --git a/src/GithubComment.php b/src/GithubComment.php new file mode 100644 index 0000000..c8ac31b --- /dev/null +++ b/src/GithubComment.php @@ -0,0 +1,58 @@ +htmlUrl; + } + + /** + * Create GithubComment from GitHub API response + * + * @param object $data Decoded JSON from API + * @return self + */ + public static function fromApiResponse(object $data): self + { + $userFactory = new GithubUserFactory(); + + return new self( + id: $data->id ?? 0, + body: $data->body ?? '', + author: $userFactory->createFromApiResponse($data->user), + createdAt: $data->created_at ?? '', + updatedAt: $data->updated_at ?? '', + htmlUrl: $data->html_url ?? '', + apiUrl: $data->url ?? '' + ); + } +} diff --git a/src/GithubCommentFactory.php b/src/GithubCommentFactory.php new file mode 100644 index 0000000..333fff6 --- /dev/null +++ b/src/GithubCommentFactory.php @@ -0,0 +1,31 @@ + $comments + */ + public function __construct( + private array $comments = [] + ) {} + + public function current(): GithubComment + { + return $this->comments[$this->position]; + } + + public function key(): int + { + return $this->position; + } + + public function next(): void + { + ++$this->position; + } + + public function rewind(): void + { + $this->position = 0; + } + + public function valid(): bool + { + return isset($this->comments[$this->position]); + } + + public function count(): int + { + return count($this->comments); + } + + /** + * Get all comments as array + * + * @return array + */ + public function toArray(): array + { + return $this->comments; + } +} diff --git a/src/GithubCommitStatus.php b/src/GithubCommitStatus.php new file mode 100644 index 0000000..8c66af5 --- /dev/null +++ b/src/GithubCommitStatus.php @@ -0,0 +1,54 @@ +context, $this->state, $this->description); + } + + /** + * Create from GitHub API response + * + * @param object $data The API response data + * @return self + */ + public static function fromApiResponse(object $data): self + { + return new self( + state: $data->state ?? '', + context: $data->context ?? '', + description: $data->description ?? '', + targetUrl: $data->target_url ?? '', + createdAt: $data->created_at ?? '', + updatedAt: $data->updated_at ?? '' + ); + } +} diff --git a/src/GithubLabel.php b/src/GithubLabel.php new file mode 100644 index 0000000..899aa63 --- /dev/null +++ b/src/GithubLabel.php @@ -0,0 +1,48 @@ +name; + } + + /** + * Create GithubLabel from GitHub API response + * + * @param object $data Decoded JSON from API + * @return self + */ + public static function fromApiResponse(object $data): self + { + return new self( + name: $data->name ?? '', + color: $data->color ?? '000000', + description: $data->description ?? null + ); + } +} diff --git a/src/GithubLabelFactory.php b/src/GithubLabelFactory.php new file mode 100644 index 0000000..3a7098c --- /dev/null +++ b/src/GithubLabelFactory.php @@ -0,0 +1,31 @@ + + */ +class GithubLabelList implements Iterator, Countable +{ + private int $position = 0; + + /** + * @param array $labels + */ + public function __construct( + private readonly array $labels + ) {} + + public function current(): GithubLabel + { + return $this->labels[$this->position]; + } + + public function key(): int + { + return $this->position; + } + + public function next(): void + { + ++$this->position; + } + + public function rewind(): void + { + $this->position = 0; + } + + public function valid(): bool + { + return isset($this->labels[$this->position]); + } + + public function count(): int + { + return count($this->labels); + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->labels; + } +} diff --git a/src/GithubPullRequest.php b/src/GithubPullRequest.php index 0a50ac9..c10c045 100644 --- a/src/GithubPullRequest.php +++ b/src/GithubPullRequest.php @@ -8,20 +8,86 @@ use Stringable; use InvalidArgumentException; +/** + * Represents a GitHub Pull Request + * + * Copyright 2026 Horde LLC (http://www.horde.org/) + * + * See the enclosed file LICENSE for license information (LGPL). If you + * did not receive this file, see http://www.horde.org/licenses/lgpl21. + * + * @category Horde + * @package GithubApiClient + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ class GithubPullRequest implements Stringable { + /** + * Constructor - typically not called directly, use fromApiResponse() instead + * + * @internal This constructor has 20 parameters and should not be called directly by users. + * Use GithubPullRequest::fromApiResponse() or retrieve PRs via GithubApiClient methods. + * + * @param int $number PR number + * @param string $title PR title + * @param string $body PR description/body + * @param string $htmlUrl PR web URL + * @param string $apiUrl PR API URL + * @param string $state open, closed + * @param bool $draft Is draft PR + * @param bool $merged Has been merged + * @param ?string $mergedAt Merge timestamp (ISO 8601) + * @param string $createdAt Creation timestamp (ISO 8601) + * @param string $updatedAt Last update timestamp (ISO 8601) + * @param GithubRepository $baseRepo Base repository + * @param GithubRepository $headRepo Head repository + * @param string $baseBranch Base branch name + * @param string $headBranch Head branch name + * @param GithubUser $author PR author + * @param array $labels Labels on PR + * @param array $requestedReviewers Requested reviewers + * @param ?string $mergeableState Mergeable state (clean, dirty, unstable, blocked, unknown) + * @param ?bool $mergeable Can be merged + */ public function __construct( public readonly int $number, public readonly string $title, + public readonly string $body, public readonly string $htmlUrl, public readonly string $apiUrl, public readonly string $state, + public readonly bool $draft, + public readonly bool $merged, + public readonly ?string $mergedAt, + public readonly string $createdAt, + public readonly string $updatedAt, public readonly GithubRepository $baseRepo, public readonly GithubRepository $headRepo, + public readonly string $baseBranch, + public readonly string $headBranch, + public readonly GithubUser $author, + public readonly array $labels, + public readonly array $requestedReviewers, + public readonly ?string $mergeableState, + public readonly ?bool $mergeable, ) {} public function __toString(): string { return $this->htmlUrl; } + + /** + * Create GithubPullRequest from GitHub API response + * + * This is the recommended way to create GithubPullRequest objects. + * + * @param object $data Decoded JSON from GitHub API + * @return self + */ + public static function fromApiResponse(object $data): self + { + $factory = new GithubPullRequestFactory(); + return $factory->createFromApiResponse($data); + } } diff --git a/src/GithubPullRequestFactory.php b/src/GithubPullRequestFactory.php index a483260..5bdc33a 100644 --- a/src/GithubPullRequestFactory.php +++ b/src/GithubPullRequestFactory.php @@ -7,18 +7,68 @@ use stdClass; use InvalidArgumentException; +/** + * Factory for creating GithubPullRequest objects from API responses + * + * Copyright 2026 Horde LLC (http://www.horde.org/) + * + * See the enclosed file LICENSE for license information (LGPL). If you + * did not receive this file, see http://www.horde.org/licenses/lgpl21. + * + * @category Horde + * @package GithubApiClient + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ class GithubPullRequestFactory { + private GithubUserFactory $userFactory; + private GithubLabelFactory $labelFactory; + + public function __construct() + { + $this->userFactory = new GithubUserFactory(); + $this->labelFactory = new GithubLabelFactory(); + } + public function createFromApiResponse(stdClass $apiResponse): GithubPullRequest { + // Parse labels + $labels = []; + if (isset($apiResponse->labels) && is_array($apiResponse->labels)) { + foreach ($apiResponse->labels as $labelData) { + $labels[] = $this->labelFactory->createFromApiResponse($labelData); + } + } + + // Parse requested reviewers + $requestedReviewers = []; + if (isset($apiResponse->requested_reviewers) && is_array($apiResponse->requested_reviewers)) { + foreach ($apiResponse->requested_reviewers as $reviewerData) { + $requestedReviewers[] = $this->userFactory->createFromApiResponse($reviewerData); + } + } + return new GithubPullRequest( number: $apiResponse->number, title: $apiResponse->title, + body: $apiResponse->body ?? '', htmlUrl: $apiResponse->html_url, apiUrl: $apiResponse->url, state: $apiResponse->state, + draft: $apiResponse->draft ?? false, + merged: $apiResponse->merged ?? false, + mergedAt: $apiResponse->merged_at ?? null, + createdAt: $apiResponse->created_at ?? '', + updatedAt: $apiResponse->updated_at ?? '', baseRepo: GithubRepository::fromApiArray((array) $apiResponse->base->repo), headRepo: GithubRepository::fromApiArray((array) $apiResponse->head->repo), + baseBranch: $apiResponse->base->ref ?? '', + headBranch: $apiResponse->head->ref ?? '', + author: $this->userFactory->createFromApiResponse($apiResponse->user), + labels: $labels, + requestedReviewers: $requestedReviewers, + mergeableState: $apiResponse->mergeable_state ?? null, + mergeable: $apiResponse->mergeable ?? null, ); } } diff --git a/src/GithubRepository.php b/src/GithubRepository.php index 9891e1c..876b2ce 100644 --- a/src/GithubRepository.php +++ b/src/GithubRepository.php @@ -10,12 +10,20 @@ class GithubRepository { + public readonly string $owner; + public readonly string $name; + public function __construct( - private readonly string $name, + string $name, private readonly string $fullName, private readonly string $description, private readonly string $cloneUrl - ) {} + ) { + $this->name = $name; + // Extract owner from fullName + $parts = explode('/', $fullName, 2); + $this->owner = $parts[0] ?? ''; + } public function getName(): string { return $this->name; diff --git a/src/GithubReview.php b/src/GithubReview.php new file mode 100644 index 0000000..603cfc0 --- /dev/null +++ b/src/GithubReview.php @@ -0,0 +1,58 @@ +htmlUrl; + } + + /** + * Create from GitHub API response + * + * @param object $data The API response data + * @return self + */ + public static function fromApiResponse(object $data): self + { + $user = isset($data->user) ? GithubUser::fromApiResponse($data->user) : new GithubUser('', 0, '', '', ''); + + return new self( + id: $data->id ?? 0, + user: $user, + body: $data->body ?? '', + state: $data->state ?? '', + htmlUrl: $data->html_url ?? '', + submittedAt: $data->submitted_at ?? '', + commitId: $data->commit_id ?? '' + ); + } +} diff --git a/src/GithubReviewFactory.php b/src/GithubReviewFactory.php new file mode 100644 index 0000000..1de4a5e --- /dev/null +++ b/src/GithubReviewFactory.php @@ -0,0 +1,31 @@ + + */ +class GithubReviewList implements Iterator, Countable +{ + private int $position = 0; + + /** + * @param array $reviews + */ + public function __construct( + private readonly array $reviews + ) {} + + public function current(): GithubReview + { + return $this->reviews[$this->position]; + } + + public function key(): int + { + return $this->position; + } + + public function next(): void + { + ++$this->position; + } + + public function rewind(): void + { + $this->position = 0; + } + + public function valid(): bool + { + return isset($this->reviews[$this->position]); + } + + public function count(): int + { + return count($this->reviews); + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->reviews; + } +} diff --git a/src/GithubUser.php b/src/GithubUser.php new file mode 100644 index 0000000..b4e7a80 --- /dev/null +++ b/src/GithubUser.php @@ -0,0 +1,52 @@ +login; + } + + /** + * Create GithubUser from GitHub API response + * + * @param object $data Decoded JSON from API + * @return self + */ + public static function fromApiResponse(object $data): self + { + return new self( + login: $data->login ?? '', + id: $data->id ?? 0, + avatarUrl: $data->avatar_url ?? '', + htmlUrl: $data->html_url ?? '', + type: $data->type ?? 'User' + ); + } +} diff --git a/src/GithubUserFactory.php b/src/GithubUserFactory.php new file mode 100644 index 0000000..4ed8760 --- /dev/null +++ b/src/GithubUserFactory.php @@ -0,0 +1,31 @@ +repo->owner, + $this->repo->name, + $this->ref + ); + + $request = $this->requestFactory->createRequest('GET', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + + return $request; + } +} diff --git a/src/ListIssueLabelsRequestFactory.php b/src/ListIssueLabelsRequestFactory.php new file mode 100644 index 0000000..601d0bf --- /dev/null +++ b/src/ListIssueLabelsRequestFactory.php @@ -0,0 +1,51 @@ +repo->owner, + $this->repo->name, + $this->issueNumber + ); + + $request = $this->requestFactory->createRequest('GET', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + + return $request; + } +} diff --git a/src/ListPullRequestCommentsRequestFactory.php b/src/ListPullRequestCommentsRequestFactory.php new file mode 100644 index 0000000..b3f8fa0 --- /dev/null +++ b/src/ListPullRequestCommentsRequestFactory.php @@ -0,0 +1,52 @@ +repo->owner, + $this->repo->name, + $this->pullNumber + ); + + $request = $this->requestFactory->createRequest('GET', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + + return $request; + } +} diff --git a/src/ListPullRequestReviewsRequestFactory.php b/src/ListPullRequestReviewsRequestFactory.php new file mode 100644 index 0000000..cb388de --- /dev/null +++ b/src/ListPullRequestReviewsRequestFactory.php @@ -0,0 +1,51 @@ +repo->owner, + $this->repo->name, + $this->pullNumber + ); + + $request = $this->requestFactory->createRequest('GET', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + + return $request; + } +} diff --git a/src/MergePullRequestParams.php b/src/MergePullRequestParams.php new file mode 100644 index 0000000..7ad3cc7 --- /dev/null +++ b/src/MergePullRequestParams.php @@ -0,0 +1,58 @@ + + */ + public function toArray(): array + { + $data = []; + + if ($this->commitTitle !== '') { + $data['commit_title'] = $this->commitTitle; + } + if ($this->commitMessage !== '') { + $data['commit_message'] = $this->commitMessage; + } + if ($this->mergeMethod !== 'merge') { + $data['merge_method'] = $this->mergeMethod; + } + if ($this->sha !== '') { + $data['sha'] = $this->sha; + } + + return $data; + } +} diff --git a/src/MergePullRequestRequestFactory.php b/src/MergePullRequestRequestFactory.php new file mode 100644 index 0000000..be7d74a --- /dev/null +++ b/src/MergePullRequestRequestFactory.php @@ -0,0 +1,59 @@ +repo->owner, + $this->repo->name, + $this->pullNumber + ); + + $jsonBody = json_encode($this->params->toArray(), JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + + $request = $this->requestFactory->createRequest('PUT', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/src/MergeResult.php b/src/MergeResult.php new file mode 100644 index 0000000..cfcdf62 --- /dev/null +++ b/src/MergeResult.php @@ -0,0 +1,48 @@ +merged ? sprintf('Merged: %s (%s)', $this->message, $this->sha) : sprintf('Not merged: %s', $this->message); + } + + /** + * Create from GitHub API response + * + * @param object $data The API response data + * @return self + */ + public static function fromApiResponse(object $data): self + { + return new self( + sha: $data->sha ?? '', + merged: $data->merged ?? false, + message: $data->message ?? '' + ); + } +} diff --git a/src/PullRequestUpdate.php b/src/PullRequestUpdate.php new file mode 100644 index 0000000..890e02b --- /dev/null +++ b/src/PullRequestUpdate.php @@ -0,0 +1,74 @@ + + */ + public function toArray(): array + { + $data = []; + + if ($this->title !== null) { + $data['title'] = $this->title; + } + + if ($this->body !== null) { + $data['body'] = $this->body; + } + + if ($this->base !== null) { + $data['base'] = $this->base; + } + + if ($this->state !== null) { + $data['state'] = $this->state; + } + + return $data; + } + + /** + * Check if any field is set + * + * @return bool True if at least one field is set + */ + public function isEmpty(): bool + { + return $this->title === null + && $this->body === null + && $this->base === null + && $this->state === null; + } +} diff --git a/src/RemoveLabelRequestFactory.php b/src/RemoveLabelRequestFactory.php new file mode 100644 index 0000000..0418a1a --- /dev/null +++ b/src/RemoveLabelRequestFactory.php @@ -0,0 +1,53 @@ +repo->owner, + $this->repo->name, + $this->issueNumber, + urlencode($this->labelName) + ); + + $request = $this->requestFactory->createRequest('DELETE', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + + return $request; + } +} diff --git a/src/RequestReviewersRequestFactory.php b/src/RequestReviewersRequestFactory.php new file mode 100644 index 0000000..7f0f6fa --- /dev/null +++ b/src/RequestReviewersRequestFactory.php @@ -0,0 +1,72 @@ + $reviewers User logins + * @param array $teamReviewers Team slugs + */ + public function __construct( + private readonly RequestFactoryInterface $requestFactory, + private readonly StreamFactoryInterface $streamFactory, + private readonly GithubApiConfig $config, + private readonly GithubRepository $repo, + private readonly int $pullNumber, + private readonly array $reviewers = [], + private readonly array $teamReviewers = [] + ) {} + + /** + * Create HTTP request to request reviewers + * + * @return RequestInterface + */ + public function create(): RequestInterface + { + $url = sprintf( + 'https://api.github.com/repos/%s/%s/pulls/%d/requested_reviewers', + $this->repo->owner, + $this->repo->name, + $this->pullNumber + ); + + $payload = []; + if (!empty($this->reviewers)) { + $payload['reviewers'] = $this->reviewers; + } + if (!empty($this->teamReviewers)) { + $payload['team_reviewers'] = $this->teamReviewers; + } + + $jsonBody = json_encode($payload, JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + + $request = $this->requestFactory->createRequest('POST', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/src/SetLabelsRequestFactory.php b/src/SetLabelsRequestFactory.php new file mode 100644 index 0000000..61cecbd --- /dev/null +++ b/src/SetLabelsRequestFactory.php @@ -0,0 +1,62 @@ + $labels Label names to set (replaces existing) + */ + public function __construct( + private readonly RequestFactoryInterface $requestFactory, + private readonly StreamFactoryInterface $streamFactory, + private readonly GithubApiConfig $config, + private readonly GithubRepository $repo, + private readonly int $issueNumber, + private readonly array $labels + ) {} + + /** + * Create HTTP request to set labels + * + * @return RequestInterface + */ + public function create(): RequestInterface + { + $url = sprintf( + 'https://api.github.com/repos/%s/%s/issues/%d/labels', + $this->repo->owner, + $this->repo->name, + $this->issueNumber + ); + + $jsonBody = json_encode(['labels' => $this->labels], JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + + $request = $this->requestFactory->createRequest('PUT', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/src/UpdateCommentRequestFactory.php b/src/UpdateCommentRequestFactory.php new file mode 100644 index 0000000..a81f1e5 --- /dev/null +++ b/src/UpdateCommentRequestFactory.php @@ -0,0 +1,59 @@ +repo->owner, + $this->repo->name, + $this->commentId + ); + + $jsonBody = json_encode(['body' => $this->body], JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + + $request = $this->requestFactory->createRequest('PATCH', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/src/UpdatePullRequestRequestFactory.php b/src/UpdatePullRequestRequestFactory.php new file mode 100644 index 0000000..9730392 --- /dev/null +++ b/src/UpdatePullRequestRequestFactory.php @@ -0,0 +1,59 @@ +repo->owner, + $this->repo->name, + $this->pullNumber + ); + + $jsonBody = json_encode($this->update->toArray(), JSON_THROW_ON_ERROR); + $stream = $this->streamFactory->createStream($jsonBody); + + $request = $this->requestFactory->createRequest('PATCH', $url); + $request = $request->withHeader('Authorization', 'token ' . $this->config->accessToken); + $request = $request->withHeader('Accept', 'application/vnd.github.v3+json'); + $request = $request->withHeader('Content-Type', 'application/json'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/test/unit/CreatePullRequestParamsTest.php b/test/unit/CreatePullRequestParamsTest.php new file mode 100644 index 0000000..d300dbd --- /dev/null +++ b/test/unit/CreatePullRequestParamsTest.php @@ -0,0 +1,189 @@ +assertSame('Add new feature', $params->title); + $this->assertSame('feature-branch', $params->head); + $this->assertSame('main', $params->base); + $this->assertSame('', $params->body); + $this->assertFalse($params->draft); + $this->assertTrue($params->maintainerCanModify); + } + + public function testConstructorWithAllParameters(): void + { + $params = new CreatePullRequestParams( + title: 'Fix bug in authentication', + head: 'fix/auth-bug', + base: 'develop', + body: 'This PR fixes the authentication bug\n\nCloses #123', + draft: true, + maintainerCanModify: false + ); + + $this->assertSame('Fix bug in authentication', $params->title); + $this->assertSame('fix/auth-bug', $params->head); + $this->assertSame('develop', $params->base); + $this->assertSame('This PR fixes the authentication bug\n\nCloses #123', $params->body); + $this->assertTrue($params->draft); + $this->assertFalse($params->maintainerCanModify); + } + + public function testToArrayWithRequiredParametersOnly(): void + { + $params = new CreatePullRequestParams( + title: 'Test PR', + head: 'test', + base: 'main' + ); + + $array = $params->toArray(); + + $this->assertSame([ + 'title' => 'Test PR', + 'head' => 'test', + 'base' => 'main', + 'maintainer_can_modify' => true + ], $array); + } + + public function testToArrayWithAllParameters(): void + { + $params = new CreatePullRequestParams( + title: 'Test PR', + head: 'test', + base: 'main', + body: 'Test description', + draft: true, + maintainerCanModify: false + ); + + $array = $params->toArray(); + + $this->assertSame([ + 'title' => 'Test PR', + 'head' => 'test', + 'base' => 'main', + 'maintainer_can_modify' => false, + 'body' => 'Test description', + 'draft' => true + ], $array); + } + + public function testToArrayExcludesEmptyBody(): void + { + $params = new CreatePullRequestParams( + title: 'Test PR', + head: 'test', + base: 'main', + body: '' + ); + + $array = $params->toArray(); + + $this->assertArrayNotHasKey('body', $array); + } + + public function testToArrayExcludesDraftWhenFalse(): void + { + $params = new CreatePullRequestParams( + title: 'Test PR', + head: 'test', + base: 'main', + draft: false + ); + + $array = $params->toArray(); + + $this->assertArrayNotHasKey('draft', $array); + } + + public function testToArrayIncludesDraftWhenTrue(): void + { + $params = new CreatePullRequestParams( + title: 'Test PR', + head: 'test', + base: 'main', + draft: true + ); + + $array = $params->toArray(); + + $this->assertArrayHasKey('draft', $array); + $this->assertTrue($array['draft']); + } + + public function testMaintainerCanModifyDefaultTrue(): void + { + $params = new CreatePullRequestParams( + title: 'Test PR', + head: 'test', + base: 'main' + ); + + $array = $params->toArray(); + + $this->assertTrue($array['maintainer_can_modify']); + } + + public function testMaintainerCanModifyCanBeFalse(): void + { + $params = new CreatePullRequestParams( + title: 'Test PR', + head: 'test', + base: 'main', + maintainerCanModify: false + ); + + $array = $params->toArray(); + + $this->assertFalse($array['maintainer_can_modify']); + } + + public function testHeadBranchFormats(): void + { + // Simple branch name + $params1 = new CreatePullRequestParams('Test', 'feature', 'main'); + $this->assertSame('feature', $params1->head); + + // Branch with owner prefix + $params2 = new CreatePullRequestParams('Test', 'username:feature', 'main'); + $this->assertSame('username:feature', $params2->head); + + // Branch with slashes + $params3 = new CreatePullRequestParams('Test', 'feature/add-auth', 'main'); + $this->assertSame('feature/add-auth', $params3->head); + } + + public function testBaseBranchFormats(): void + { + // main branch + $params1 = new CreatePullRequestParams('Test', 'feature', 'main'); + $this->assertSame('main', $params1->base); + + // develop branch + $params2 = new CreatePullRequestParams('Test', 'feature', 'develop'); + $this->assertSame('develop', $params2->base); + + // release branch + $params3 = new CreatePullRequestParams('Test', 'hotfix', 'release/1.0'); + $this->assertSame('release/1.0', $params3->base); + } +} diff --git a/test/unit/GithubApiClientErrorHandlingTest.php b/test/unit/GithubApiClientErrorHandlingTest.php new file mode 100644 index 0000000..544e353 --- /dev/null +++ b/test/unit/GithubApiClientErrorHandlingTest.php @@ -0,0 +1,171 @@ +createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'test-token'); + + $request = $this->createMock(RequestInterface::class); + $stream = $this->createMock(StreamInterface::class); + $response = $this->createMock(ResponseInterface::class); + + // Mock the request creation chain + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($stream); + + // Mock 422 Unprocessable Entity response + $response->method('getStatusCode')->willReturn(422); + $response->method('getReasonPhrase')->willReturn('Unprocessable Entity'); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('horde/githubapiclient'); + $params = new CreatePullRequestParams( + title: 'Test PR', + head: 'non-existent-branch', + base: 'main' + ); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('422 Unprocessable Entity'); + + $client->createPullRequest($repo, $params); + } + + public function testCreatePullRequestThrowsOn404NotFound(): void + { + // This test covers the case where the repository doesn't exist + + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'test-token'); + + $request = $this->createMock(RequestInterface::class); + $stream = $this->createMock(StreamInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($stream); + + $response->method('getStatusCode')->willReturn(404); + $response->method('getReasonPhrase')->willReturn('Not Found'); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('nonexistent/repo'); + $params = new CreatePullRequestParams( + title: 'Test PR', + head: 'feature', + base: 'main' + ); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('404 Not Found'); + + $client->createPullRequest($repo, $params); + } + + public function testCreatePullRequestThrowsOn401Unauthorized(): void + { + // This test covers the case where the token is invalid or expired + + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'invalid-token'); + + $request = $this->createMock(RequestInterface::class); + $stream = $this->createMock(StreamInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($stream); + + $response->method('getStatusCode')->willReturn(401); + $response->method('getReasonPhrase')->willReturn('Unauthorized'); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('horde/githubapiclient'); + $params = new CreatePullRequestParams( + title: 'Test PR', + head: 'feature', + base: 'main' + ); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('401 Unauthorized'); + + $client->createPullRequest($repo, $params); + } + + public function testCreatePullRequestThrowsOn403Forbidden(): void + { + // This test covers the case where the token lacks necessary permissions + + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $config = new GithubApiConfig(accessToken: 'test-token'); + + $request = $this->createMock(RequestInterface::class); + $stream = $this->createMock(StreamInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + $request->method('withBody')->willReturnSelf(); + $streamFactory->method('createStream')->willReturn($stream); + + $response->method('getStatusCode')->willReturn(403); + $response->method('getReasonPhrase')->willReturn('Forbidden'); + $httpClient->method('sendRequest')->willReturn($response); + + $client = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + $repo = GithubRepository::fromFullName('horde/githubapiclient'); + $params = new CreatePullRequestParams( + title: 'Test PR', + head: 'feature', + base: 'main' + ); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('403 Forbidden'); + + $client->createPullRequest($repo, $params); + } +} diff --git a/test/unit/GithubCheckRunTest.php b/test/unit/GithubCheckRunTest.php new file mode 100644 index 0000000..6dae89f --- /dev/null +++ b/test/unit/GithubCheckRunTest.php @@ -0,0 +1,145 @@ +assertSame(123456, $checkRun->id); + $this->assertSame('Build and Test', $checkRun->name); + $this->assertSame('completed', $checkRun->status); + $this->assertSame('success', $checkRun->conclusion); + $this->assertSame('abc123def456', $checkRun->headSha); + $this->assertSame('https://github.com/owner/repo/runs/123456', $checkRun->htmlUrl); + $this->assertSame('https://github.com/owner/repo/runs/123456/details', $checkRun->detailsUrl); + $this->assertSame('2026-02-26T10:00:00Z', $checkRun->startedAt); + $this->assertSame('2026-02-26T10:05:00Z', $checkRun->completedAt); + } + + public function testToString(): void + { + $checkRun = new GithubCheckRun( + id: 789, + name: 'Lint', + status: 'completed', + conclusion: 'failure', + headSha: 'def789', + htmlUrl: '', + detailsUrl: '', + startedAt: '', + completedAt: '' + ); + + $this->assertSame('Lint: completed/failure', (string) $checkRun); + } + + public function testFromApiResponseWithCompleteData(): void + { + $data = (object) [ + 'id' => 999888, + 'name' => 'CodeQL Analysis', + 'status' => 'in_progress', + 'conclusion' => null, + 'head_sha' => 'ghi789jkl012', + 'html_url' => 'https://github.com/org/repo/runs/999888', + 'details_url' => 'https://github.com/org/repo/runs/999888/details', + 'started_at' => '2026-02-26T11:00:00Z', + 'completed_at' => null + ]; + + $checkRun = GithubCheckRun::fromApiResponse($data); + + $this->assertSame(999888, $checkRun->id); + $this->assertSame('CodeQL Analysis', $checkRun->name); + $this->assertSame('in_progress', $checkRun->status); + $this->assertSame('', $checkRun->conclusion); + $this->assertSame('ghi789jkl012', $checkRun->headSha); + $this->assertSame('https://github.com/org/repo/runs/999888', $checkRun->htmlUrl); + $this->assertSame('https://github.com/org/repo/runs/999888/details', $checkRun->detailsUrl); + $this->assertSame('2026-02-26T11:00:00Z', $checkRun->startedAt); + $this->assertSame('', $checkRun->completedAt); + } + + public function testFromApiResponseWithMinimalData(): void + { + $data = (object) []; + + $checkRun = GithubCheckRun::fromApiResponse($data); + + $this->assertSame(0, $checkRun->id); + $this->assertSame('', $checkRun->name); + $this->assertSame('', $checkRun->status); + $this->assertSame('', $checkRun->conclusion); + $this->assertSame('', $checkRun->headSha); + $this->assertSame('', $checkRun->htmlUrl); + $this->assertSame('', $checkRun->detailsUrl); + $this->assertSame('', $checkRun->startedAt); + $this->assertSame('', $checkRun->completedAt); + } + + public function testCheckRunStatuses(): void + { + // queued + $queuedRun = new GithubCheckRun(1, 'Test', 'queued', '', '', '', '', '', ''); + $this->assertSame('queued', $queuedRun->status); + + // in_progress + $inProgressRun = new GithubCheckRun(2, 'Test', 'in_progress', '', '', '', '', '', ''); + $this->assertSame('in_progress', $inProgressRun->status); + + // completed + $completedRun = new GithubCheckRun(3, 'Test', 'completed', '', '', '', '', '', ''); + $this->assertSame('completed', $completedRun->status); + } + + public function testCheckRunConclusions(): void + { + // success + $successRun = new GithubCheckRun(1, 'Test', 'completed', 'success', '', '', '', '', ''); + $this->assertSame('success', $successRun->conclusion); + + // failure + $failureRun = new GithubCheckRun(2, 'Test', 'completed', 'failure', '', '', '', '', ''); + $this->assertSame('failure', $failureRun->conclusion); + + // neutral + $neutralRun = new GithubCheckRun(3, 'Test', 'completed', 'neutral', '', '', '', '', ''); + $this->assertSame('neutral', $neutralRun->conclusion); + + // cancelled + $cancelledRun = new GithubCheckRun(4, 'Test', 'completed', 'cancelled', '', '', '', '', ''); + $this->assertSame('cancelled', $cancelledRun->conclusion); + + // skipped + $skippedRun = new GithubCheckRun(5, 'Test', 'completed', 'skipped', '', '', '', '', ''); + $this->assertSame('skipped', $skippedRun->conclusion); + + // timed_out + $timedOutRun = new GithubCheckRun(6, 'Test', 'completed', 'timed_out', '', '', '', '', ''); + $this->assertSame('timed_out', $timedOutRun->conclusion); + + // action_required + $actionRequiredRun = new GithubCheckRun(7, 'Test', 'completed', 'action_required', '', '', '', '', ''); + $this->assertSame('action_required', $actionRequiredRun->conclusion); + } +} diff --git a/test/unit/GithubCommentTest.php b/test/unit/GithubCommentTest.php new file mode 100644 index 0000000..0e2ab0a --- /dev/null +++ b/test/unit/GithubCommentTest.php @@ -0,0 +1,110 @@ +assertSame(456789, $comment->id); + $this->assertSame('This is a test comment', $comment->body); + $this->assertSame($author, $comment->author); + $this->assertSame('2026-02-26T10:00:00Z', $comment->createdAt); + $this->assertSame('2026-02-26T11:00:00Z', $comment->updatedAt); + $this->assertSame('https://github.com/owner/repo/pull/1#issuecomment-456789', $comment->htmlUrl); + $this->assertSame('https://api.github.com/repos/owner/repo/issues/comments/456789', $comment->apiUrl); + } + + public function testToStringReturnsHtmlUrl(): void + { + $author = new GithubUser( + login: 'testuser', + id: 123, + avatarUrl: 'https://avatar.example.com/test.png', + htmlUrl: 'https://github.com/testuser' + ); + + $comment = new GithubComment( + id: 456789, + body: 'Test', + author: $author, + createdAt: '2026-02-26T10:00:00Z', + updatedAt: '2026-02-26T10:00:00Z', + htmlUrl: 'https://github.com/owner/repo/pull/1#issuecomment-456789', + apiUrl: 'https://api.github.com/repos/owner/repo/issues/comments/456789' + ); + + $this->assertSame('https://github.com/owner/repo/pull/1#issuecomment-456789', (string) $comment); + } + + public function testFromApiResponseWithCompleteData(): void + { + $data = (object) [ + 'id' => 999888, + 'body' => 'API comment body', + 'user' => (object) [ + 'login' => 'apiuser', + 'id' => 777, + 'avatar_url' => 'https://avatar.example.com/api.png', + 'html_url' => 'https://github.com/apiuser', + 'type' => 'User' + ], + 'created_at' => '2026-01-15T08:30:00Z', + 'updated_at' => '2026-01-15T09:45:00Z', + 'html_url' => 'https://github.com/org/repo/pull/5#issuecomment-999888', + 'url' => 'https://api.github.com/repos/org/repo/issues/comments/999888' + ]; + + $comment = GithubComment::fromApiResponse($data); + + $this->assertSame(999888, $comment->id); + $this->assertSame('API comment body', $comment->body); + $this->assertSame('apiuser', $comment->author->login); + $this->assertSame('2026-01-15T08:30:00Z', $comment->createdAt); + $this->assertSame('2026-01-15T09:45:00Z', $comment->updatedAt); + $this->assertSame('https://github.com/org/repo/pull/5#issuecomment-999888', $comment->htmlUrl); + $this->assertSame('https://api.github.com/repos/org/repo/issues/comments/999888', $comment->apiUrl); + } + + public function testFromApiResponseWithMinimalData(): void + { + $data = (object) [ + 'user' => (object) [] + ]; + + $comment = GithubComment::fromApiResponse($data); + + $this->assertSame(0, $comment->id); + $this->assertSame('', $comment->body); + $this->assertSame('', $comment->author->login); + $this->assertSame('', $comment->createdAt); + $this->assertSame('', $comment->updatedAt); + $this->assertSame('', $comment->htmlUrl); + $this->assertSame('', $comment->apiUrl); + } +} diff --git a/test/unit/GithubLabelTest.php b/test/unit/GithubLabelTest.php new file mode 100644 index 0000000..8fe3dcb --- /dev/null +++ b/test/unit/GithubLabelTest.php @@ -0,0 +1,88 @@ +assertSame('bug', $label->name); + $this->assertSame('d73a4a', $label->color); + $this->assertSame('Something isn\'t working', $label->description); + } + + public function testConstructorWithoutDescription(): void + { + $label = new GithubLabel( + name: 'enhancement', + color: 'a2eeef' + ); + + $this->assertSame('enhancement', $label->name); + $this->assertSame('a2eeef', $label->color); + $this->assertNull($label->description); + } + + public function testToStringReturnsName(): void + { + $label = new GithubLabel( + name: 'feature', + color: '0052cc' + ); + + $this->assertSame('feature', (string) $label); + } + + public function testFromApiResponseWithCompleteData(): void + { + $data = (object) [ + 'name' => 'documentation', + 'color' => '0075ca', + 'description' => 'Improvements or additions to documentation' + ]; + + $label = GithubLabel::fromApiResponse($data); + + $this->assertSame('documentation', $label->name); + $this->assertSame('0075ca', $label->color); + $this->assertSame('Improvements or additions to documentation', $label->description); + } + + public function testFromApiResponseWithoutDescription(): void + { + $data = (object) [ + 'name' => 'wontfix', + 'color' => 'ffffff' + ]; + + $label = GithubLabel::fromApiResponse($data); + + $this->assertSame('wontfix', $label->name); + $this->assertSame('ffffff', $label->color); + $this->assertNull($label->description); + } + + public function testFromApiResponseWithMinimalData(): void + { + $data = (object) []; + + $label = GithubLabel::fromApiResponse($data); + + $this->assertSame('', $label->name); + $this->assertSame('000000', $label->color); + $this->assertNull($label->description); + } +} diff --git a/test/unit/GithubRepositoryTest.php b/test/unit/GithubRepositoryTest.php new file mode 100644 index 0000000..ff4fde9 --- /dev/null +++ b/test/unit/GithubRepositoryTest.php @@ -0,0 +1,119 @@ +assertSame('horde', $repo->owner); + $this->assertSame('githubapiclient', $repo->name); + $this->assertSame('horde/githubapiclient', $repo->getFullName()); + } + + public function testFromFullNameWithDifferentOwner(): void + { + $repo = GithubRepository::fromFullName('octocat/Hello-World'); + + $this->assertSame('octocat', $repo->owner); + $this->assertSame('Hello-World', $repo->name); + $this->assertSame('octocat/Hello-World', $repo->getFullName()); + } + + public function testFromFullNameWithHyphenatedNames(): void + { + $repo = GithubRepository::fromFullName('my-org/my-repo-name'); + + $this->assertSame('my-org', $repo->owner); + $this->assertSame('my-repo-name', $repo->name); + $this->assertSame('my-org/my-repo-name', $repo->getFullName()); + } + + public function testFromFullNameThrowsOnEmptyString(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Full name cannot be empty'); + + GithubRepository::fromFullName(''); + } + + public function testFromFullNameThrowsOnInvalidFormat(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid full name format'); + + GithubRepository::fromFullName('invalid-no-slash'); + } + + public function testFromFullNameThrowsOnTooManySlashes(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid full name format'); + + GithubRepository::fromFullName('owner/repo/extra'); + } + + public function testFromApiArrayParsesOwnerFromFullName(): void + { + $data = [ + 'name' => 'components', + 'full_name' => 'horde/components', + 'description' => 'Component management tool', + 'clone_url' => 'https://github.com/horde/components.git' + ]; + + $repo = GithubRepository::fromApiArray($data); + + $this->assertSame('horde', $repo->owner); + $this->assertSame('components', $repo->name); + $this->assertSame('horde/components', $repo->getFullName()); + $this->assertSame('Component management tool', $repo->getDescription()); + } + + public function testConstructorParsesOwnerFromFullName(): void + { + $repo = new GithubRepository( + name: 'test-repo', + fullName: 'test-owner/test-repo', + description: 'Test', + cloneUrl: 'https://github.com/test-owner/test-repo.git' + ); + + $this->assertSame('test-owner', $repo->owner); + $this->assertSame('test-repo', $repo->name); + } + + public function testOwnerAndNameArePubliclyAccessible(): void + { + $repo = GithubRepository::fromFullName('microsoft/vscode'); + + // Test that owner and name can be accessed as public properties + // This is required for request factories to build API URLs + $owner = $repo->owner; + $name = $repo->name; + + $this->assertSame('microsoft', $owner); + $this->assertSame('vscode', $name); + } + + public function testOwnerAndNameAreReadonly(): void + { + $repo = GithubRepository::fromFullName('github/gitignore'); + + // Verify properties are readonly (this will be caught by PHP at runtime) + $reflection = new \ReflectionProperty(GithubRepository::class, 'owner'); + $this->assertTrue($reflection->isReadOnly()); + + $reflection = new \ReflectionProperty(GithubRepository::class, 'name'); + $this->assertTrue($reflection->isReadOnly()); + } +} diff --git a/test/unit/GithubReviewTest.php b/test/unit/GithubReviewTest.php new file mode 100644 index 0000000..ca1940f --- /dev/null +++ b/test/unit/GithubReviewTest.php @@ -0,0 +1,133 @@ +assertSame(789123, $review->id); + $this->assertSame($user, $review->user); + $this->assertSame('Looks good to me', $review->body); + $this->assertSame('APPROVED', $review->state); + $this->assertSame('https://github.com/owner/repo/pull/1#pullrequestreview-789123', $review->htmlUrl); + $this->assertSame('2026-02-26T12:00:00Z', $review->submittedAt); + $this->assertSame('abc123def456', $review->commitId); + } + + public function testToStringReturnsHtmlUrl(): void + { + $user = new GithubUser( + login: 'reviewer', + id: 456, + avatarUrl: 'https://avatar.example.com/reviewer.png', + htmlUrl: 'https://github.com/reviewer', + type: 'User' + ); + + $review = new GithubReview( + id: 789123, + user: $user, + body: 'Test', + state: 'APPROVED', + htmlUrl: 'https://github.com/owner/repo/pull/1#pullrequestreview-789123', + submittedAt: '2026-02-26T12:00:00Z', + commitId: 'abc123' + ); + + $this->assertSame('https://github.com/owner/repo/pull/1#pullrequestreview-789123', (string) $review); + } + + public function testFromApiResponseWithCompleteData(): void + { + $data = (object) [ + 'id' => 111222, + 'user' => (object) [ + 'login' => 'apireviewer', + 'id' => 333, + 'avatar_url' => 'https://avatar.example.com/apireviewer.png', + 'html_url' => 'https://github.com/apireviewer', + 'type' => 'User' + ], + 'body' => 'API review comment', + 'state' => 'CHANGES_REQUESTED', + 'html_url' => 'https://github.com/org/repo/pull/5#pullrequestreview-111222', + 'submitted_at' => '2026-02-15T14:30:00Z', + 'commit_id' => 'def789ghi012' + ]; + + $review = GithubReview::fromApiResponse($data); + + $this->assertSame(111222, $review->id); + $this->assertSame('apireviewer', $review->user->login); + $this->assertSame('API review comment', $review->body); + $this->assertSame('CHANGES_REQUESTED', $review->state); + $this->assertSame('https://github.com/org/repo/pull/5#pullrequestreview-111222', $review->htmlUrl); + $this->assertSame('2026-02-15T14:30:00Z', $review->submittedAt); + $this->assertSame('def789ghi012', $review->commitId); + } + + public function testFromApiResponseWithMinimalData(): void + { + $data = (object) [ + 'user' => (object) [] + ]; + + $review = GithubReview::fromApiResponse($data); + + $this->assertSame(0, $review->id); + $this->assertSame('', $review->user->login); + $this->assertSame('', $review->body); + $this->assertSame('', $review->state); + $this->assertSame('', $review->htmlUrl); + $this->assertSame('', $review->submittedAt); + $this->assertSame('', $review->commitId); + } + + public function testReviewStates(): void + { + $user = new GithubUser('reviewer', 123, '', '', 'User'); + + // Test APPROVED state + $approvedReview = new GithubReview(1, $user, 'LGTM', 'APPROVED', '', '', ''); + $this->assertSame('APPROVED', $approvedReview->state); + + // Test CHANGES_REQUESTED state + $changesReview = new GithubReview(2, $user, 'Needs work', 'CHANGES_REQUESTED', '', '', ''); + $this->assertSame('CHANGES_REQUESTED', $changesReview->state); + + // Test COMMENTED state + $commentedReview = new GithubReview(3, $user, 'Some thoughts', 'COMMENTED', '', '', ''); + $this->assertSame('COMMENTED', $commentedReview->state); + + // Test DISMISSED state + $dismissedReview = new GithubReview(4, $user, 'Old review', 'DISMISSED', '', '', ''); + $this->assertSame('DISMISSED', $dismissedReview->state); + } +} diff --git a/test/unit/GithubStatusTest.php b/test/unit/GithubStatusTest.php new file mode 100644 index 0000000..b66c214 --- /dev/null +++ b/test/unit/GithubStatusTest.php @@ -0,0 +1,175 @@ +assertSame('success', $status->state); + $this->assertSame('continuous-integration/travis-ci', $status->context); + $this->assertSame('The Travis CI build passed', $status->description); + $this->assertSame('https://travis-ci.com/owner/repo/builds/12345', $status->targetUrl); + $this->assertSame('2026-02-26T10:00:00Z', $status->createdAt); + $this->assertSame('2026-02-26T10:05:00Z', $status->updatedAt); + } + + public function testCommitStatusToString(): void + { + $status = new GithubCommitStatus( + state: 'failure', + context: 'test/unit', + description: 'Tests failed', + targetUrl: '', + createdAt: '', + updatedAt: '' + ); + + $this->assertSame('test/unit: failure (Tests failed)', (string) $status); + } + + public function testCommitStatusFromApiResponse(): void + { + $data = (object) [ + 'state' => 'pending', + 'context' => 'test/integration', + 'description' => 'Running integration tests', + 'target_url' => 'https://ci.example.com/build/789', + 'created_at' => '2026-02-26T11:00:00Z', + 'updated_at' => '2026-02-26T11:01:00Z' + ]; + + $status = GithubCommitStatus::fromApiResponse($data); + + $this->assertSame('pending', $status->state); + $this->assertSame('test/integration', $status->context); + $this->assertSame('Running integration tests', $status->description); + $this->assertSame('https://ci.example.com/build/789', $status->targetUrl); + $this->assertSame('2026-02-26T11:00:00Z', $status->createdAt); + $this->assertSame('2026-02-26T11:01:00Z', $status->updatedAt); + } + + public function testCommitStatusFromApiResponseWithMinimalData(): void + { + $data = (object) []; + + $status = GithubCommitStatus::fromApiResponse($data); + + $this->assertSame('', $status->state); + $this->assertSame('', $status->context); + $this->assertSame('', $status->description); + $this->assertSame('', $status->targetUrl); + $this->assertSame('', $status->createdAt); + $this->assertSame('', $status->updatedAt); + } + + public function testCombinedStatusConstructor(): void + { + $status1 = new GithubCommitStatus('success', 'ci/travis', 'Passed', '', '', ''); + $status2 = new GithubCommitStatus('success', 'ci/github-actions', 'Passed', '', '', ''); + + $combined = new GithubCombinedStatus( + state: 'success', + sha: 'abc123def456', + totalCount: 2, + statuses: [$status1, $status2] + ); + + $this->assertSame('success', $combined->state); + $this->assertSame('abc123def456', $combined->sha); + $this->assertSame(2, $combined->totalCount); + $this->assertCount(2, $combined->statuses); + $this->assertSame($status1, $combined->statuses[0]); + $this->assertSame($status2, $combined->statuses[1]); + } + + public function testCombinedStatusToString(): void + { + $combined = new GithubCombinedStatus( + state: 'pending', + sha: 'abc123', + totalCount: 3, + statuses: [] + ); + + $this->assertSame('pending (3 checks)', (string) $combined); + } + + public function testCombinedStatusFromApiResponse(): void + { + $data = (object) [ + 'state' => 'success', + 'sha' => 'def789ghi012', + 'total_count' => 2, + 'statuses' => [ + (object) [ + 'state' => 'success', + 'context' => 'test1', + 'description' => 'Test 1 passed', + 'target_url' => 'https://ci.example.com/1', + 'created_at' => '2026-02-26T12:00:00Z', + 'updated_at' => '2026-02-26T12:01:00Z' + ], + (object) [ + 'state' => 'success', + 'context' => 'test2', + 'description' => 'Test 2 passed', + 'target_url' => 'https://ci.example.com/2', + 'created_at' => '2026-02-26T12:00:00Z', + 'updated_at' => '2026-02-26T12:02:00Z' + ] + ] + ]; + + $combined = GithubCombinedStatus::fromApiResponse($data); + + $this->assertSame('success', $combined->state); + $this->assertSame('def789ghi012', $combined->sha); + $this->assertSame(2, $combined->totalCount); + $this->assertCount(2, $combined->statuses); + $this->assertSame('test1', $combined->statuses[0]->context); + $this->assertSame('test2', $combined->statuses[1]->context); + } + + public function testCombinedStatusFromApiResponseWithMinimalData(): void + { + $data = (object) []; + + $combined = GithubCombinedStatus::fromApiResponse($data); + + $this->assertSame('', $combined->state); + $this->assertSame('', $combined->sha); + $this->assertSame(0, $combined->totalCount); + $this->assertCount(0, $combined->statuses); + } + + public function testCombinedStatusStates(): void + { + $successCombined = new GithubCombinedStatus('success', 'abc123', 1, []); + $this->assertSame('success', $successCombined->state); + + $failureCombined = new GithubCombinedStatus('failure', 'def456', 1, []); + $this->assertSame('failure', $failureCombined->state); + + $pendingCombined = new GithubCombinedStatus('pending', 'ghi789', 1, []); + $this->assertSame('pending', $pendingCombined->state); + } +} diff --git a/test/unit/GithubUserTest.php b/test/unit/GithubUserTest.php new file mode 100644 index 0000000..af85068 --- /dev/null +++ b/test/unit/GithubUserTest.php @@ -0,0 +1,102 @@ +assertSame('testuser', $user->login); + $this->assertSame(12345, $user->id); + $this->assertSame('https://avatar.example.com/testuser.png', $user->avatarUrl); + $this->assertSame('https://github.com/testuser', $user->htmlUrl); + $this->assertSame('User', $user->type); + } + + public function testConstructorWithDefaultType(): void + { + $user = new GithubUser( + login: 'bot', + id: 67890, + avatarUrl: 'https://avatar.example.com/bot.png', + htmlUrl: 'https://github.com/bot' + ); + + $this->assertSame('User', $user->type); + } + + public function testToStringReturnsLogin(): void + { + $user = new GithubUser( + login: 'testuser', + id: 12345, + avatarUrl: 'https://avatar.example.com/testuser.png', + htmlUrl: 'https://github.com/testuser' + ); + + $this->assertSame('testuser', (string) $user); + } + + public function testFromApiResponseWithCompleteData(): void + { + $data = (object) [ + 'login' => 'apiuser', + 'id' => 99999, + 'avatar_url' => 'https://avatar.example.com/apiuser.png', + 'html_url' => 'https://github.com/apiuser', + 'type' => 'Bot' + ]; + + $user = GithubUser::fromApiResponse($data); + + $this->assertSame('apiuser', $user->login); + $this->assertSame(99999, $user->id); + $this->assertSame('https://avatar.example.com/apiuser.png', $user->avatarUrl); + $this->assertSame('https://github.com/apiuser', $user->htmlUrl); + $this->assertSame('Bot', $user->type); + } + + public function testFromApiResponseWithMinimalData(): void + { + $data = (object) []; + + $user = GithubUser::fromApiResponse($data); + + $this->assertSame('', $user->login); + $this->assertSame(0, $user->id); + $this->assertSame('', $user->avatarUrl); + $this->assertSame('', $user->htmlUrl); + $this->assertSame('User', $user->type); + } + + public function testFromApiResponseWithPartialData(): void + { + $data = (object) [ + 'login' => 'partial', + 'id' => 54321 + ]; + + $user = GithubUser::fromApiResponse($data); + + $this->assertSame('partial', $user->login); + $this->assertSame(54321, $user->id); + $this->assertSame('', $user->avatarUrl); + $this->assertSame('', $user->htmlUrl); + $this->assertSame('User', $user->type); + } +} diff --git a/test/unit/MergeTest.php b/test/unit/MergeTest.php new file mode 100644 index 0000000..dce81f5 --- /dev/null +++ b/test/unit/MergeTest.php @@ -0,0 +1,169 @@ +assertSame('', $params->commitTitle); + $this->assertSame('', $params->commitMessage); + $this->assertSame('merge', $params->mergeMethod); + $this->assertSame('', $params->sha); + } + + public function testMergePullRequestParamsConstructorWithAllParameters(): void + { + $params = new MergePullRequestParams( + commitTitle: 'Merge pull request #123', + commitMessage: 'This PR adds feature X', + mergeMethod: 'squash', + sha: 'abc123def456' + ); + + $this->assertSame('Merge pull request #123', $params->commitTitle); + $this->assertSame('This PR adds feature X', $params->commitMessage); + $this->assertSame('squash', $params->mergeMethod); + $this->assertSame('abc123def456', $params->sha); + } + + public function testMergePullRequestParamsToArrayWithDefaults(): void + { + $params = new MergePullRequestParams(); + $array = $params->toArray(); + + $this->assertSame([], $array); + } + + public function testMergePullRequestParamsToArrayWithAllFields(): void + { + $params = new MergePullRequestParams( + commitTitle: 'Test title', + commitMessage: 'Test message', + mergeMethod: 'squash', + sha: 'abc123' + ); + $array = $params->toArray(); + + $this->assertSame([ + 'commit_title' => 'Test title', + 'commit_message' => 'Test message', + 'merge_method' => 'squash', + 'sha' => 'abc123' + ], $array); + } + + public function testMergePullRequestParamsToArrayExcludesDefaultMergeMethod(): void + { + $params = new MergePullRequestParams( + commitTitle: 'Test', + mergeMethod: 'merge' + ); + $array = $params->toArray(); + + $this->assertArrayHasKey('commit_title', $array); + $this->assertArrayNotHasKey('merge_method', $array); + } + + public function testMergePullRequestParamsMergeMethods(): void + { + // merge + $mergeParams = new MergePullRequestParams(mergeMethod: 'merge'); + $this->assertSame('merge', $mergeParams->mergeMethod); + + // squash + $squashParams = new MergePullRequestParams(mergeMethod: 'squash'); + $this->assertSame('squash', $squashParams->mergeMethod); + + // rebase + $rebaseParams = new MergePullRequestParams(mergeMethod: 'rebase'); + $this->assertSame('rebase', $rebaseParams->mergeMethod); + } + + public function testMergeResultConstructor(): void + { + $result = new MergeResult( + sha: 'def789ghi012', + merged: true, + message: 'Pull Request successfully merged' + ); + + $this->assertSame('def789ghi012', $result->sha); + $this->assertTrue($result->merged); + $this->assertSame('Pull Request successfully merged', $result->message); + } + + public function testMergeResultToStringWhenMerged(): void + { + $result = new MergeResult( + sha: 'abc123', + merged: true, + message: 'Merged successfully' + ); + + $this->assertSame('Merged: Merged successfully (abc123)', (string) $result); + } + + public function testMergeResultToStringWhenNotMerged(): void + { + $result = new MergeResult( + sha: '', + merged: false, + message: 'Pull request is not mergeable' + ); + + $this->assertSame('Not merged: Pull request is not mergeable', (string) $result); + } + + public function testMergeResultFromApiResponseWhenMerged(): void + { + $data = (object) [ + 'sha' => 'ghi789jkl012', + 'merged' => true, + 'message' => 'Pull Request successfully merged' + ]; + + $result = MergeResult::fromApiResponse($data); + + $this->assertSame('ghi789jkl012', $result->sha); + $this->assertTrue($result->merged); + $this->assertSame('Pull Request successfully merged', $result->message); + } + + public function testMergeResultFromApiResponseWhenNotMerged(): void + { + $data = (object) [ + 'sha' => '', + 'merged' => false, + 'message' => 'Merge conflict' + ]; + + $result = MergeResult::fromApiResponse($data); + + $this->assertSame('', $result->sha); + $this->assertFalse($result->merged); + $this->assertSame('Merge conflict', $result->message); + } + + public function testMergeResultFromApiResponseWithMinimalData(): void + { + $data = (object) []; + + $result = MergeResult::fromApiResponse($data); + + $this->assertSame('', $result->sha); + $this->assertFalse($result->merged); + $this->assertSame('', $result->message); + } +} diff --git a/test/unit/PullRequestUpdateTest.php b/test/unit/PullRequestUpdateTest.php new file mode 100644 index 0000000..c9ce97c --- /dev/null +++ b/test/unit/PullRequestUpdateTest.php @@ -0,0 +1,135 @@ +assertSame('feat: new feature', $update->title); + $this->assertSame('This PR adds a new feature', $update->body); + $this->assertSame('main', $update->base); + $this->assertSame('open', $update->state); + } + + public function testConstructorWithOnlyTitle(): void + { + $update = new PullRequestUpdate(title: 'Updated title'); + + $this->assertSame('Updated title', $update->title); + $this->assertNull($update->body); + $this->assertNull($update->base); + $this->assertNull($update->state); + } + + public function testConstructorWithNoFields(): void + { + $update = new PullRequestUpdate(); + + $this->assertNull($update->title); + $this->assertNull($update->body); + $this->assertNull($update->base); + $this->assertNull($update->state); + } + + public function testToArrayIncludesOnlySetFields(): void + { + $update = new PullRequestUpdate( + title: 'New title', + state: 'closed' + ); + + $array = $update->toArray(); + + $this->assertArrayHasKey('title', $array); + $this->assertArrayHasKey('state', $array); + $this->assertArrayNotHasKey('body', $array); + $this->assertArrayNotHasKey('base', $array); + $this->assertSame('New title', $array['title']); + $this->assertSame('closed', $array['state']); + } + + public function testToArrayWithAllFields(): void + { + $update = new PullRequestUpdate( + title: 'Title', + body: 'Body', + base: 'develop', + state: 'open' + ); + + $array = $update->toArray(); + + $this->assertCount(4, $array); + $this->assertSame('Title', $array['title']); + $this->assertSame('Body', $array['body']); + $this->assertSame('develop', $array['base']); + $this->assertSame('open', $array['state']); + } + + public function testToArrayWithNoFields(): void + { + $update = new PullRequestUpdate(); + + $array = $update->toArray(); + + $this->assertEmpty($array); + $this->assertIsArray($array); + } + + public function testIsEmptyReturnsTrueWhenNoFieldsSet(): void + { + $update = new PullRequestUpdate(); + + $this->assertTrue($update->isEmpty()); + } + + public function testIsEmptyReturnsFalseWhenTitleSet(): void + { + $update = new PullRequestUpdate(title: 'Test'); + + $this->assertFalse($update->isEmpty()); + } + + public function testIsEmptyReturnsFalseWhenBodySet(): void + { + $update = new PullRequestUpdate(body: 'Test body'); + + $this->assertFalse($update->isEmpty()); + } + + public function testIsEmptyReturnsFalseWhenBaseSet(): void + { + $update = new PullRequestUpdate(base: 'main'); + + $this->assertFalse($update->isEmpty()); + } + + public function testIsEmptyReturnsFalseWhenStateSet(): void + { + $update = new PullRequestUpdate(state: 'closed'); + + $this->assertFalse($update->isEmpty()); + } + + public function testIsEmptyReturnsFalseWhenMultipleFieldsSet(): void + { + $update = new PullRequestUpdate(title: 'Title', body: 'Body'); + + $this->assertFalse($update->isEmpty()); + } +}