diff --git a/.github/bin/ci-bootstrap.sh b/.github/bin/ci-bootstrap.sh new file mode 100755 index 0000000..86d0cd2 --- /dev/null +++ b/.github/bin/ci-bootstrap.sh @@ -0,0 +1,166 @@ +#!/bin/bash +# Horde CI Bootstrap Script (GitHub Actions) +# Generated by: horde-components 1.0.0-RC10 +# Template version: 1.5.2 +# Generated: 2026-07-02 20:16:48 UTC +# +# DO NOT EDIT - Regenerate with: horde-components ci init +# +# This script uses the runner's preinstalled PHP (8.3 on ubuntu-24.04). +# horde-components will install additional PHP versions as needed. + +set -e +set -o pipefail + +# Configuration +COMPONENTS_PHAR_URL="${COMPONENTS_PHAR_URL:-https://github.com/horde/components/releases/latest/download/horde-components.phar}" +COMPONENT_NAME="GithubApiClient" +WORK_DIR="/tmp/horde-ci" + +# Colors for output (disabled in CI) +RED='' +GREEN='' +YELLOW='' +NC='' + +# Logging functions +log_info() { + # Bootstrap-stage progress lines stay as plain stdout under + # GitHub Actions. Routing them through ::notice:: would flood the + # Annotations panel with non-actionable entries. log_warn and + # log_error still emit workflow commands because bootstrap-level + # problems (missing GITHUB_TOKEN, download failure, etc.) belong + # in the panel. + echo "[INFO] $*" +} + +log_warn() { + if [ -n "$GITHUB_ACTIONS" ]; then + echo "::warning::$*" + else + echo "[WARN] $*" + fi +} + +log_error() { + if [ -n "$GITHUB_ACTIONS" ]; then + echo "::error::$*" + else + echo "[ERROR] $*" + fi >&2 +} + +# Stage 1: Validate environment +log_info "Horde CI Bootstrap (GitHub Actions mode)" +log_info "Component: $COMPONENT_NAME" + +# Check we're in GitHub Actions +if [ -z "$GITHUB_ACTIONS" ]; then + log_error "This script is for GitHub Actions. Use ci setup --ci-mode=local for local development." + exit 1 +fi + +# Validate required environment variables +if [ -z "$GITHUB_REPOSITORY" ]; then + log_error "GITHUB_REPOSITORY not set" + exit 1 +fi + +if [ -z "$GITHUB_REF" ]; then + log_error "GITHUB_REF not set" + exit 1 +fi + +if [ -z "$GITHUB_TOKEN" ]; then + log_error "GITHUB_TOKEN not set" + exit 1 +fi + +# Stage 2: Verify PHP (use runner's default - PHP 8.3) +if ! command -v php &> /dev/null; then + log_error "PHP not found. ubuntu-24.04 runner should have PHP preinstalled." + exit 1 +fi + +PHP_VERSION=$(php -r 'echo PHP_VERSION;') +log_info "Using runner's PHP version: $PHP_VERSION" + +# Stage 3: Locate horde-components.phar +# +# Lookup order: +# 1. Repo-bundled phar at $GITHUB_WORKSPACE/ci-tools/horde-components.phar +# (committed alongside the workflow for repos that pin a specific build) +# 2. Cached phar from a previous run at $WORK_DIR/bin/horde-components.phar +# 3. Download from $COMPONENTS_PHAR_URL +REPO_PHAR="$GITHUB_WORKSPACE/ci-tools/horde-components.phar" +mkdir -p "$WORK_DIR/bin" +COMPONENTS_PHAR="$WORK_DIR/bin/horde-components.phar" + +if [ -f "$REPO_PHAR" ]; then + log_info "Using repo-bundled horde-components.phar from ci-tools/" + cp "$REPO_PHAR" "$COMPONENTS_PHAR" + chmod +x "$COMPONENTS_PHAR" +elif [ -f "$COMPONENTS_PHAR" ]; then + log_info "Using cached horde-components.phar" +else + log_info "Downloading horde-components from $COMPONENTS_PHAR_URL" + + if ! curl -sS -L -o "$COMPONENTS_PHAR" "$COMPONENTS_PHAR_URL"; then + log_error "Failed to download horde-components.phar" + exit 1 + fi +fi + +# Validate it's a valid phar +if ! php "$COMPONENTS_PHAR" help &> /dev/null; then + log_error "horde-components.phar is not valid or not executable" + exit 1 +fi + +log_info "horde-components.phar ready at $COMPONENTS_PHAR" + +# Stage 4: Install sudo helper script +log_info "Installing sudo helper script" + +# Extract and install the helper script from PHAR +php -r "copy('phar://$COMPONENTS_PHAR/data/ci/sudo-helper.sh', '$WORK_DIR/sudo-helper.sh');" 2>/dev/null || \ + log_warn "Could not extract sudo helper from PHAR (older version?)" + +if [ -f "$WORK_DIR/sudo-helper.sh" ]; then + sudo install -m 755 "$WORK_DIR/sudo-helper.sh" /usr/local/bin/horde-ci-sudo-helper + log_info "Sudo helper installed successfully" +else + log_warn "Sudo helper not found, will try to proceed without it" +fi + +# Stage 5: Handoff to PHP (horde-components ci setup) +log_info "Bootstrap complete. Handing off to horde-components ci setup" + +php "$COMPONENTS_PHAR" ci setup \ + --ci-mode=github \ + --work-dir="$WORK_DIR" \ + --component="$COMPONENT_NAME" + +EXIT_CODE=$? + +if [ $EXIT_CODE -eq 0 ]; then + log_info "CI setup complete. Workspace: $WORK_DIR" +else + log_error "CI setup failed with exit code $EXIT_CODE" + exit $EXIT_CODE +fi + +# Stage 6: Run CI tests +log_info "Running CI tests" + +php "$COMPONENTS_PHAR" ci run \ + --work-dir="$WORK_DIR" + +EXIT_CODE=$? + +if [ $EXIT_CODE -eq 0 ]; then + log_info "CI tests complete" +else + log_error "CI tests failed with exit code $EXIT_CODE" + exit $EXIT_CODE +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f7af7ec --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +# Generated by: horde-components 1.0.0-RC10 +# Template version: 1.5.2 +# Generated: 2026-07-02 20:16:48 UTC +# +# DO NOT EDIT - Regenerate with: horde-components ci init +# +# This workflow uses the runner's preinstalled PHP 8.3 for bootstrap. +# horde-components will install additional PHP versions as needed. + +on: + push: + branches: [ FRAMEWORK_6_0 ] + pull_request: + branches: [ FRAMEWORK_6_0 ] + workflow_dispatch: + +jobs: + ci: + name: CI + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: write + checks: write + + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + # Fetch git submodules alongside the working tree. + # A no-op for components without a .gitmodules + # Required for pulling in externally maintained test suites + # i.e. horde/Yaml pulls yaml-test-suite as a submodule because it's large and not ours. + submodules: recursive + + - name: Cache horde-components.phar + uses: actions/cache@v5 + with: + path: /tmp/horde-ci/bin + key: components-phar-${{ hashFiles('.github/bin/ci-bootstrap.sh') }} + + - name: Cache QC tools (PHPUnit, PHPStan, PHP-CS-Fixer) + uses: actions/cache@v5 + with: + path: /tmp/horde-ci/tools + key: ci-tools-${{ hashFiles('.horde.yml') }} + + - name: Run CI + run: bash .github/bin/ci-bootstrap.sh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMPONENTS_PHAR_URL: ${{ vars.COMPONENTS_PHAR_URL || 'https://github.com/horde/components/releases/latest/download/horde-components.phar' }} + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v5 + with: + name: ci-results-pr${{ github.event.pull_request.number || github.run_number }}-${{ github.sha }} + path: | + /tmp/horde-ci/lanes/*/GithubApiClient/build/*.json + /tmp/horde-ci/lanes/*/GithubApiClient/build/*.xml + retention-days: 30 diff --git a/.github/workflows/on-pr-merged.yml b/.github/workflows/on-pr-merged.yml deleted file mode 100644 index 92735b1..0000000 --- a/.github/workflows/on-pr-merged.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: After-Merge Chores -on: - pull_request: - types: - - closed - # branches: - # - FRAMEWORK_6_0 - workflow_dispatch: - -jobs: - PostMerge: - if: github.event.pull_request.merged == true - runs-on: ubuntu-24.04 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2.37.1 - with: - php-version: 8.3 - extensions: bcmath, ctype, curl, dom, gd, gettext, iconv, imagick, json, ldap, mbstring, mysql, opcache, openssl, pcntl, pdo, posix, redis, soap, sockets, sqlite, tokenizer, xmlwriter, xdebug - ini-values: post_max_size=512M, max_execution_time=360 - coverage: xdebug - tools: php-cs-fixer, phpunit:${{ matrix.phpunit-versions }}, composer:v2 - - name: Setup Github Token as composer credential - run: composer config -g github-oauth.github.com ${{ secrets.GITHUB_TOKEN }} - - name: Install dependencies and local tools - run: | - COMPOSER_ROOT_VERSION=dev-FRAMEWORK_6_0 composer config minimum-stability dev - COMPOSER_ROOT_VERSION=dev-FRAMEWORK_6_0 composer config prefer-stable true - COMPOSER_ROOT_VERSION=dev-FRAMEWORK_6_0 composer install --no-interaction --no-progress - diff --git a/.github/workflows/on-pr.yml b/.github/workflows/on-pr.yml deleted file mode 100644 index 67775af..0000000 --- a/.github/workflows/on-pr.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Pull Request Chores -on: - pull_request: - branches: - - FRAMEWORK_6_0 - workflow_dispatch: - -jobs: - CI: - runs-on: ubuntu-24.04 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2.37.1 - with: - php-version: 8.3 - extensions: bcmath, ctype, curl, dom, gd, gettext, iconv, imagick, json, ldap, mbstring, mysql, opcache, openssl, pcntl, pdo, posix, redis, soap, sockets, sqlite, tokenizer, xmlwriter, xdebug - ini-values: post_max_size=512M, max_execution_time=360 - coverage: xdebug - tools: php-cs-fixer, phpunit:${{ matrix.phpunit-versions }}, composer:v2 - - name: Setup Github Token as composer credential - run: composer config -g github-oauth.github.com ${{ secrets.GITHUB_TOKEN }} - - name: Install dependencies and local tools - run: | - COMPOSER_ROOT_VERSION=dev-FRAMEWORK_6_0 composer config minimum-stability dev - COMPOSER_ROOT_VERSION=dev-FRAMEWORK_6_0 composer config prefer-stable true - COMPOSER_ROOT_VERSION=dev-FRAMEWORK_6_0 composer install --no-interaction --no-progress - - - name: Run PHPUnit - run: vendor/bin/phpunit --testdox - - - name: Run php-cs-fixer - run: vendor/bin/php-cs-fixer check -vvv - - - name: Run phpstan (mandatory level) - run: vendor/bin/phpstan --no-progress - - - name: Run phpstan (level 9, allowed to fail) - run: vendor/bin/phpstan --no-progress --level=9 - continue-on-error: true - diff --git a/.horde.yml b/.horde.yml index 677dbe0..31b66cc 100644 --- a/.horde.yml +++ b/.horde.yml @@ -29,11 +29,6 @@ dependencies: psr/http-client: ^1.0 psr/http-factory: ^1.0 psr/http-message: ^2.0 - dev: - composer: - phpunit/phpunit: ^11 || ^12 - friendsofphp/php-cs-fixer: ^3 - phpstan/phpstan: ^2 nocommands: - bin/demo-client.php quality: diff --git a/composer.json b/composer.json index 303c393..9fd9325 100644 --- a/composer.json +++ b/composer.json @@ -21,9 +21,6 @@ "psr/http-message": "^2.0" }, "require-dev": { - "phpunit/phpunit": "^11 || ^12", - "friendsofphp/php-cs-fixer": "^3", - "phpstan/phpstan": "^2" }, "suggest": {}, "autoload": { diff --git a/phpstan.neon b/phpstan.neon deleted file mode 100644 index a8c8145..0000000 --- a/phpstan.neon +++ /dev/null @@ -1,7 +0,0 @@ -parameters: - level: 5 - errorFormat: github - treatPhpDocTypesAsCertain: false - paths: - - src - - tests diff --git a/src/GithubApiClient.php b/src/GithubApiClient.php index 06e6abe..71637c1 100644 --- a/src/GithubApiClient.php +++ b/src/GithubApiClient.php @@ -61,6 +61,88 @@ public function listRepositoriesInOrganization(GithubOrganizationId $org): Githu return new GithubRepositoryList($repos); } + + /** + * List releases for a repository. + * + * Paginates via Link headers so callers get every release regardless + * of repo size. Empty repos and repos with no published releases + * return an empty list without erroring. + */ + public function listReleases(GithubRepository $repo): GithubReleaseList + { + $requestFactory = new ListReleasesRequestFactory($this->requestFactory, $this->config); + $request = $requestFactory->create($repo); + $releases = []; + while (true) { + $response = $this->httpClient->sendRequest($request); + if ($response->getStatusCode() !== 200) { + throw new Exception($this->parseErrorResponse($response)); + } + $data = json_decode((string) $response->getBody()); + if (is_array($data)) { + foreach ($data as $releaseData) { + if (is_object($releaseData)) { + $releases[] = GithubRelease::fromApiResponse($releaseData); + } + } + } + $pagination = new GithubApiPagination($request, $response); + if (!$pagination->hasNextLink()) { + break; + } + $request = $pagination->nextRequest(); + } + return new GithubReleaseList($releases); + } + + /** + * List issue comments in a repository, or on a single issue. + * + * With `$issueNumber = null`, walks the repo-wide comments endpoint, + * optionally filtered to comments updated at or after `$since` + * (ISO 8601). Passing a specific issue number restricts to that + * issue's timeline. Both modes paginate via Link headers. + * + * PR conversation comments (the non-code-anchored ones) arrive + * through this endpoint too; use `listPullRequestComments()` for + * PR review comments that carry file/line anchors. + */ + public function listIssueComments( + GithubRepository $repo, + ?int $issueNumber = null, + string $since = '' + ): GithubCommentList { + $requestFactory = new ListIssueCommentsRequestFactory( + $this->requestFactory, + $this->config, + since: $since + ); + $request = $requestFactory->create($repo, $issueNumber); + $comments = []; + $factory = new GithubCommentFactory(); + while (true) { + $response = $this->httpClient->sendRequest($request); + if ($response->getStatusCode() !== 200) { + throw new Exception($this->parseErrorResponse($response)); + } + $data = json_decode((string) $response->getBody()); + if (is_array($data)) { + foreach ($data as $commentData) { + if (is_object($commentData)) { + $comments[] = $factory->createFromApiResponse($commentData); + } + } + } + $pagination = new GithubApiPagination($request, $response); + if (!$pagination->hasNextLink()) { + break; + } + $request = $pagination->nextRequest(); + } + return new GithubCommentList($comments); + } + public function listPullRequests(GithubRepository $repo, string $baseBranch = '', string $headRef = '', string $state = 'open'): GithubPullRequestList { $pullRequests = []; diff --git a/src/GithubReleaseList.php b/src/GithubReleaseList.php new file mode 100644 index 0000000..c176cc7 --- /dev/null +++ b/src/GithubReleaseList.php @@ -0,0 +1,71 @@ + + */ +class GithubReleaseList implements Iterator, Countable +{ + private int $position = 0; + + /** + * @param array $releases + */ + public function __construct( + private readonly array $releases = [] + ) {} + + public function current(): GithubRelease + { + return $this->releases[$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->releases[$this->position]); + } + + public function count(): int + { + return count($this->releases); + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->releases; + } +} diff --git a/src/GithubRepository.php b/src/GithubRepository.php index 9e588f9..a27383c 100644 --- a/src/GithubRepository.php +++ b/src/GithubRepository.php @@ -21,9 +21,11 @@ public function __construct( public readonly string $nodeId = '', ) { $this->name = $name; - // Extract owner from fullName + // Extract owner from fullName. explode('/', $fullName, 2) + // always yields a non-empty list, so $parts[0] is guaranteed + // to exist. $parts = explode('/', $fullName, 2); - $this->owner = $parts[0] ?? ''; + $this->owner = $parts[0]; } public function getName(): string { @@ -55,7 +57,7 @@ public static function fromApiArray(array $apiArray): GithubRepository fullName: (string) $apiArray['full_name'], description: (string) ($apiArray['description'] ?? ''), cloneUrl: (string) $apiArray['clone_url'], - nodeId: (string) ($apiArray['node_id'] ?? ''), + nodeId: isset($apiArray['node_id']) ? (string) $apiArray['node_id'] : '', ); } throw new InvalidArgumentException(); @@ -80,7 +82,7 @@ public static function fromFullName(string $fullName, string $apiUrl = ''): Gith } /** - * @phpstan-assert-if-true array{'name': string|Stringable, 'full_name': string|Stringable, 'clone_url': string|Stringable, 'description': string|Stringable|null} $apiArray + * @phpstan-assert-if-true array{'name': string|Stringable, 'full_name': string|Stringable, 'clone_url': string|Stringable, 'description'?: string|Stringable|null, 'node_id'?: string|Stringable|null} $apiArray * @param array $apiArray */ public static function isValidArrayRepresentation(array $apiArray): bool diff --git a/src/GithubRepositoryList.php b/src/GithubRepositoryList.php index 0594494..85026bb 100644 --- a/src/GithubRepositoryList.php +++ b/src/GithubRepositoryList.php @@ -27,8 +27,7 @@ public function __construct(iterable $elements = []) if ($element instanceof GithubRepository) { $this->repositories[$element->getFullName()] = $element; } elseif ( - is_array($element) - && array_key_exists('name', $element) + array_key_exists('name', $element) && array_key_exists('full_name', $element) && array_key_exists('clone_url', $element)) { $repository = GithubRepository::fromApiArray($element); diff --git a/src/IssueUpdate.php b/src/IssueUpdate.php index a776bfd..0167cdf 100644 --- a/src/IssueUpdate.php +++ b/src/IssueUpdate.php @@ -45,7 +45,7 @@ class IssueUpdate private array $assignees = []; private bool $assigneesSet = false; - private int|string|null $milestone = null; + private ?int $milestone = null; private bool $milestoneSet = false; private ?string $type = null; diff --git a/src/ListIssueCommentsRequestFactory.php b/src/ListIssueCommentsRequestFactory.php new file mode 100644 index 0000000..baa4e24 --- /dev/null +++ b/src/ListIssueCommentsRequestFactory.php @@ -0,0 +1,80 @@ +itemsPerPage = $items; + return $this; + } + + public function create(GithubRepository $repo, ?int $issueNumber = null): RequestInterface + { + $base = $issueNumber === null + ? sprintf('%s/repos/%s/issues/comments', $this->config->endpoint, $repo->getFullName()) + : sprintf('%s/repos/%s/issues/%d/comments', $this->config->endpoint, $repo->getFullName(), $issueNumber); + + $query = [ + 'per_page' => (string) $this->itemsPerPage, + 'page' => (string) $this->page, + 'sort' => $this->sort, + 'direction' => $this->direction, + ]; + if ($this->since !== '') { + $query['since'] = $this->since; + } + $uri = $base . '?' . http_build_query($query); + + $request = $this->requestFactory->createRequest('GET', $uri) + ->withHeader('Accept', 'application/vnd.github+json') + ->withHeader('X-GitHub-Api-Version', '2022-11-28'); + if ($this->config->accessToken !== '') { + $request = $request->withHeader('Authorization', 'Bearer ' . $this->config->accessToken); + } + return $request; + } +} diff --git a/src/ListReleasesRequestFactory.php b/src/ListReleasesRequestFactory.php new file mode 100644 index 0000000..2410929 --- /dev/null +++ b/src/ListReleasesRequestFactory.php @@ -0,0 +1,54 @@ +itemsPerPage = $items; + return $this; + } + + public function create(GithubRepository $repo): RequestInterface + { + $uri = sprintf( + '%s/repos/%s/releases?per_page=%d&page=%d', + $this->config->endpoint, + $repo->getFullName(), + $this->itemsPerPage, + $this->page + ); + $request = $this->requestFactory->createRequest('GET', $uri) + ->withHeader('Accept', 'application/vnd.github+json') + ->withHeader('X-GitHub-Api-Version', '2022-11-28'); + if ($this->config->accessToken !== '') { + $request = $request->withHeader('Authorization', 'Bearer ' . $this->config->accessToken); + } + return $request; + } +} diff --git a/test/unit/GithubApiClientListIssueCommentsTest.php b/test/unit/GithubApiClientListIssueCommentsTest.php new file mode 100644 index 0000000..e0965ee --- /dev/null +++ b/test/unit/GithubApiClientListIssueCommentsTest.php @@ -0,0 +1,157 @@ +createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $request = $this->createMock(RequestInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + return [$httpClient, $requestFactory, $request]; + } + + /** + * @return array + */ + private function commentBody(int $id, string $body): array + { + return [ + 'id' => $id, + 'node_id' => 'IC_kwDO' . $id, + 'body' => $body, + 'html_url' => 'https://example.org/comment/' . $id, + 'user' => [ + 'login' => 'octocat', + 'id' => 1, + 'node_id' => 'MDQ6VXNlcjE=', + 'html_url' => 'https://example.org/octocat', + 'avatar_url' => '', + ], + 'created_at' => '2026-06-25T12:00:00Z', + 'updated_at' => '2026-06-25T12:00:00Z', + ]; + } + + private function stubResponse(array $comments, string $linkHeader = ''): ResponseInterface + { + $response = $this->createMock(ResponseInterface::class); + $body = $this->createMock(StreamInterface::class); + $body->method('__toString')->willReturn((string) json_encode($comments)); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($body); + $response->method('getHeaderLine')->willReturnCallback( + static fn (string $name): string => strtolower($name) === 'link' ? $linkHeader : '' + ); + $response->method('hasHeader')->willReturnCallback( + static fn (string $name): bool => strtolower($name) === 'link' && $linkHeader !== '' + ); + return $response; + } + + public function testRepoWideListing(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $httpClient->method('sendRequest')->willReturn($this->stubResponse([ + $this->commentBody(1, 'first'), + $this->commentBody(2, 'second'), + ])); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('horde/example'); + + $comments = $client->listIssueComments($repo); + + $this->assertInstanceOf(GithubCommentList::class, $comments); + $this->assertCount(2, $comments); + $items = $comments->toArray(); + $this->assertInstanceOf(GithubComment::class, $items[0]); + $this->assertSame('first', $items[0]->body); + } + + public function testSingleIssueListing(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $httpClient->method('sendRequest')->willReturn($this->stubResponse([ + $this->commentBody(11, 'on-30-a'), + $this->commentBody(12, 'on-30-b'), + ])); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('horde/example'); + + $comments = $client->listIssueComments($repo, issueNumber: 30); + + $this->assertCount(2, $comments); + } + + public function testEmptyPage(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $httpClient->method('sendRequest')->willReturn($this->stubResponse([])); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('horde/example'); + + $comments = $client->listIssueComments($repo); + $this->assertCount(0, $comments); + } + + public function testRaisesOnNon200(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $body = $this->createMock(StreamInterface::class); + $body->method('__toString')->willReturn('{"message":"Not Found"}'); + $response->method('getStatusCode')->willReturn(404); + $response->method('getBody')->willReturn($body); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('horde/does-not-exist'); + + $this->expectException(Exception::class); + $client->listIssueComments($repo); + } +} diff --git a/test/unit/GithubApiClientListReleasesTest.php b/test/unit/GithubApiClientListReleasesTest.php new file mode 100644 index 0000000..41d52b5 --- /dev/null +++ b/test/unit/GithubApiClientListReleasesTest.php @@ -0,0 +1,150 @@ +createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $request = $this->createMock(RequestInterface::class); + + $requestFactory->method('createRequest')->willReturn($request); + $request->method('withHeader')->willReturnSelf(); + + return [$httpClient, $requestFactory, $request]; + } + + /** + * @return array + */ + private function releaseBody(int $id, string $tag): array + { + return [ + 'id' => $id, + 'node_id' => 'RE_kwDO' . $id, + 'tag_name' => $tag, + 'name' => $tag, + 'body' => 'Release notes for ' . $tag, + 'draft' => false, + 'prerelease' => false, + 'created_at' => '2026-07-01T12:00:00Z', + 'published_at' => '2026-07-01T12:00:00Z', + 'html_url' => 'https://github.com/owner/repo/releases/tag/' . $tag, + 'upload_url' => 'https://uploads.example/' . $id, + 'author' => [ + 'login' => 'octocat', + 'id' => 1, + 'node_id' => 'MDQ6VXNlcjE=', + 'html_url' => 'https://example.org/octocat', + 'avatar_url' => '', + ], + 'assets' => [], + ]; + } + + private function stubResponse(array $releases, string $linkHeader = ''): ResponseInterface + { + $response = $this->createMock(ResponseInterface::class); + $body = $this->createMock(StreamInterface::class); + $body->method('__toString')->willReturn((string) json_encode($releases)); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($body); + // The pagination helper reads the Link header off the response + // to decide whether to keep going. An empty header ends the loop. + $response->method('getHeaderLine')->willReturnCallback( + static fn (string $name): string => strtolower($name) === 'link' ? $linkHeader : '' + ); + $response->method('hasHeader')->willReturnCallback( + static fn (string $name): bool => strtolower($name) === 'link' && $linkHeader !== '' + ); + return $response; + } + + public function testListReleasesEmptyPage(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $httpClient->method('sendRequest')->willReturn($this->stubResponse([])); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('horde/example'); + + $releases = $client->listReleases($repo); + + $this->assertInstanceOf(GithubReleaseList::class, $releases); + $this->assertCount(0, $releases); + } + + public function testListReleasesReturnsHydratedEntities(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $httpClient->method('sendRequest')->willReturn($this->stubResponse([ + $this->releaseBody(1, 'v1.0.0'), + $this->releaseBody(2, 'v1.1.0'), + ])); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('horde/example'); + + $releases = $client->listReleases($repo); + + $this->assertCount(2, $releases); + $items = $releases->toArray(); + $this->assertInstanceOf(GithubRelease::class, $items[0]); + $this->assertSame('v1.0.0', $items[0]->tagName); + $this->assertSame('v1.1.0', $items[1]->tagName); + } + + public function testListReleasesRaisesOnNon200(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $response = $this->createMock(ResponseInterface::class); + $body = $this->createMock(StreamInterface::class); + $body->method('__toString')->willReturn('{"message":"Not Found"}'); + $response->method('getStatusCode')->willReturn(404); + $response->method('getBody')->willReturn($body); + $httpClient->method('sendRequest')->willReturn($response); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('horde/does-not-exist'); + + $this->expectException(Exception::class); + $client->listReleases($repo); + } +}