diff --git a/src/GithubApiClient.php b/src/GithubApiClient.php index 71637c1..34f1192 100644 --- a/src/GithubApiClient.php +++ b/src/GithubApiClient.php @@ -143,6 +143,110 @@ public function listIssueComments( return new GithubCommentList($comments); } + /** + * List events on an issue (labeled, assigned, closed, etc.). + * + * Backed by `GET /repos/{owner}/{repo}/issues/{issue_number}/events`. + * Paginates via Link headers. + */ + public function getIssueEvents(GithubRepository $repo, int $issueNumber): GithubIssueEventList + { + $requestFactory = new ListIssueEventsRequestFactory($this->requestFactory, $this->config); + $request = $requestFactory->create($repo, $issueNumber); + $events = []; + $factory = new GithubIssueEventFactory(); + 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 $eventData) { + if (is_object($eventData)) { + $events[] = $factory->createFromApiResponse($eventData); + } + } + } + $pagination = new GithubApiPagination($request, $response); + if (!$pagination->hasNextLink()) { + break; + } + $request = $pagination->nextRequest(); + } + return new GithubIssueEventList($events); + } + + /** + * List the timeline of an issue. + * + * Backed by `GET /repos/{owner}/{repo}/issues/{issue_number}/timeline`. + * Broader than `getIssueEvents()`: includes cross-references, + * comments, and PR review activity in addition to issue events. + * Paginates via Link headers. + */ + public function getIssueTimeline(GithubRepository $repo, int $issueNumber): GithubTimelineEventList + { + $requestFactory = new ListIssueTimelineRequestFactory($this->requestFactory, $this->config); + $request = $requestFactory->create($repo, $issueNumber); + $events = []; + $factory = new GithubTimelineEventFactory(); + 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 $eventData) { + if (is_object($eventData)) { + $events[] = $factory->createFromApiResponse($eventData); + } + } + } + $pagination = new GithubApiPagination($request, $response); + if (!$pagination->hasNextLink()) { + break; + } + $request = $pagination->nextRequest(); + } + return new GithubTimelineEventList($events); + } + + /** + * List repository activity events. + * + * Backed by `GET /repos/{owner}/{repo}/events`. Paginates via + * Link headers. + */ + public function getRepositoryEvents(GithubRepository $repo): GithubRepositoryEventList + { + $requestFactory = new ListRepositoryEventsRequestFactory($this->requestFactory, $this->config); + $request = $requestFactory->create($repo); + $events = []; + $factory = new GithubRepositoryEventFactory(); + 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 $eventData) { + if (is_object($eventData)) { + $events[] = $factory->createFromApiResponse($eventData); + } + } + } + $pagination = new GithubApiPagination($request, $response); + if (!$pagination->hasNextLink()) { + break; + } + $request = $pagination->nextRequest(); + } + return new GithubRepositoryEventList($events); + } + public function listPullRequests(GithubRepository $repo, string $baseBranch = '', string $headRef = '', string $state = 'open'): GithubPullRequestList { $pullRequests = []; diff --git a/src/GithubIssueEvent.php b/src/GithubIssueEvent.php new file mode 100644 index 0000000..e0e9aa5 --- /dev/null +++ b/src/GithubIssueEvent.php @@ -0,0 +1,62 @@ +event; + } + + /** + * Create GithubIssueEvent from GitHub API response + * + * @param object $data Decoded JSON event object from API + * @return self + */ + public static function fromApiResponse(object $data): self + { + $userFactory = new GithubUserFactory(); + + return new self( + id: $data->id ?? 0, + event: $data->event ?? '', + actor: isset($data->actor) ? $userFactory->createFromApiResponse($data->actor) : null, + createdAt: $data->created_at ?? '', + apiUrl: $data->url ?? '', + commitId: $data->commit_id ?? '', + commitUrl: $data->commit_url ?? '', + nodeId: $data->node_id ?? '', + ); + } +} diff --git a/src/GithubIssueEventFactory.php b/src/GithubIssueEventFactory.php new file mode 100644 index 0000000..f3e33ba --- /dev/null +++ b/src/GithubIssueEventFactory.php @@ -0,0 +1,31 @@ + $events + */ + public function __construct( + private array $events = [] + ) {} + + public function current(): GithubIssueEvent + { + return $this->events[$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->events[$this->position]); + } + + public function count(): int + { + return count($this->events); + } + + /** + * Get all events as array + * + * @return array + */ + public function toArray(): array + { + return $this->events; + } +} diff --git a/src/GithubRepositoryEvent.php b/src/GithubRepositoryEvent.php new file mode 100644 index 0000000..d54d4df --- /dev/null +++ b/src/GithubRepositoryEvent.php @@ -0,0 +1,68 @@ + $payload Decoded event payload + */ + public function __construct( + public readonly string $id, + public readonly string $type, + public readonly ?GithubUser $actor, + public readonly string $repoName, + public readonly array $payload, + public readonly bool $public, + public readonly string $createdAt, + ) {} + + public function __toString(): string + { + return $this->type; + } + + /** + * Create GithubRepositoryEvent from GitHub API response + * + * @param object $data Decoded JSON event object from API + * @return self + */ + public static function fromApiResponse(object $data): self + { + $userFactory = new GithubUserFactory(); + + return new self( + id: $data->id ?? '', + type: $data->type ?? '', + actor: isset($data->actor) ? $userFactory->createFromApiResponse($data->actor) : null, + repoName: $data->repo->name ?? '', + payload: isset($data->payload) ? (array) $data->payload : [], + public: $data->public ?? true, + createdAt: $data->created_at ?? '', + ); + } +} diff --git a/src/GithubRepositoryEventFactory.php b/src/GithubRepositoryEventFactory.php new file mode 100644 index 0000000..37ebfd7 --- /dev/null +++ b/src/GithubRepositoryEventFactory.php @@ -0,0 +1,31 @@ + $events + */ + public function __construct( + private array $events = [] + ) {} + + public function current(): GithubRepositoryEvent + { + return $this->events[$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->events[$this->position]); + } + + public function count(): int + { + return count($this->events); + } + + /** + * Get all events as array + * + * @return array + */ + public function toArray(): array + { + return $this->events; + } +} diff --git a/src/GithubTimelineEvent.php b/src/GithubTimelineEvent.php new file mode 100644 index 0000000..e8bc601 --- /dev/null +++ b/src/GithubTimelineEvent.php @@ -0,0 +1,66 @@ + $raw Full decoded timeline item + */ + public function __construct( + public readonly string $event, + public readonly ?GithubUser $actor, + public readonly string $createdAt, + public readonly array $raw, + public readonly int $id = 0, + public readonly string $nodeId = '', + ) {} + + public function __toString(): string + { + return $this->event; + } + + /** + * Create GithubTimelineEvent from GitHub API response + * + * @param object $data Decoded JSON timeline item from API + * @return self + */ + public static function fromApiResponse(object $data): self + { + $userFactory = new GithubUserFactory(); + + return new self( + event: $data->event ?? '', + actor: isset($data->actor) ? $userFactory->createFromApiResponse($data->actor) : null, + createdAt: $data->created_at ?? '', + raw: (array) $data, + id: $data->id ?? 0, + nodeId: $data->node_id ?? '', + ); + } +} diff --git a/src/GithubTimelineEventFactory.php b/src/GithubTimelineEventFactory.php new file mode 100644 index 0000000..e6d4506 --- /dev/null +++ b/src/GithubTimelineEventFactory.php @@ -0,0 +1,31 @@ + $events + */ + public function __construct( + private array $events = [] + ) {} + + public function current(): GithubTimelineEvent + { + return $this->events[$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->events[$this->position]); + } + + public function count(): int + { + return count($this->events); + } + + /** + * Get all events as array + * + * @return array + */ + public function toArray(): array + { + return $this->events; + } +} diff --git a/src/ListIssueEventsRequestFactory.php b/src/ListIssueEventsRequestFactory.php new file mode 100644 index 0000000..a1f7665 --- /dev/null +++ b/src/ListIssueEventsRequestFactory.php @@ -0,0 +1,61 @@ +itemsPerPage = $items; + return $this; + } + + public function create(GithubRepository $repo, int $issueNumber): RequestInterface + { + $base = sprintf( + '%s/repos/%s/issues/%d/events', + $this->config->endpoint, + $repo->getFullName(), + $issueNumber + ); + + $query = [ + 'per_page' => (string) $this->itemsPerPage, + 'page' => (string) $this->page, + ]; + $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/ListIssueTimelineRequestFactory.php b/src/ListIssueTimelineRequestFactory.php new file mode 100644 index 0000000..ab1a433 --- /dev/null +++ b/src/ListIssueTimelineRequestFactory.php @@ -0,0 +1,65 @@ +itemsPerPage = $items; + return $this; + } + + public function create(GithubRepository $repo, int $issueNumber): RequestInterface + { + $base = sprintf( + '%s/repos/%s/issues/%d/timeline', + $this->config->endpoint, + $repo->getFullName(), + $issueNumber + ); + + $query = [ + 'per_page' => (string) $this->itemsPerPage, + 'page' => (string) $this->page, + ]; + $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/ListRepositoryEventsRequestFactory.php b/src/ListRepositoryEventsRequestFactory.php new file mode 100644 index 0000000..b8a2343 --- /dev/null +++ b/src/ListRepositoryEventsRequestFactory.php @@ -0,0 +1,56 @@ +itemsPerPage = $items; + return $this; + } + + public function create(GithubRepository $repo): RequestInterface + { + $base = sprintf('%s/repos/%s/events', $this->config->endpoint, $repo->getFullName()); + + $query = [ + 'per_page' => (string) $this->itemsPerPage, + 'page' => (string) $this->page, + ]; + $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/test/unit/GithubApiClientGetIssueEventsTest.php b/test/unit/GithubApiClientGetIssueEventsTest.php new file mode 100644 index 0000000..fe8ee9e --- /dev/null +++ b/test/unit/GithubApiClientGetIssueEventsTest.php @@ -0,0 +1,140 @@ +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 eventBody(int $id, string $event): array + { + return [ + 'id' => $id, + 'node_id' => 'IE_kwDO' . $id, + 'url' => 'https://example.org/events/' . $id, + 'event' => $event, + 'actor' => [ + 'login' => 'octocat', + 'id' => 1, + 'node_id' => 'MDQ6VXNlcjE=', + 'html_url' => 'https://example.org/octocat', + 'avatar_url' => '', + ], + 'created_at' => '2026-06-25T12:00:00Z', + ]; + } + + private function stubResponse(array $events, string $linkHeader = ''): ResponseInterface + { + $response = $this->createMock(ResponseInterface::class); + $body = $this->createMock(StreamInterface::class); + $body->method('__toString')->willReturn((string) json_encode($events)); + $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 testListsEvents(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $httpClient->method('sendRequest')->willReturn($this->stubResponse([ + $this->eventBody(1, 'labeled'), + $this->eventBody(2, 'closed'), + ])); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('horde/example'); + + $events = $client->getIssueEvents($repo, 30); + + $this->assertInstanceOf(GithubIssueEventList::class, $events); + $this->assertCount(2, $events); + $items = $events->toArray(); + $this->assertInstanceOf(GithubIssueEvent::class, $items[0]); + $this->assertSame('labeled', $items[0]->event); + $this->assertSame('octocat', $items[0]->actor?->login); + } + + 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'); + + $events = $client->getIssueEvents($repo, 30); + $this->assertCount(0, $events); + } + + 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->getIssueEvents($repo, 30); + } +} diff --git a/test/unit/GithubApiClientGetIssueTimelineTest.php b/test/unit/GithubApiClientGetIssueTimelineTest.php new file mode 100644 index 0000000..d5bc325 --- /dev/null +++ b/test/unit/GithubApiClientGetIssueTimelineTest.php @@ -0,0 +1,125 @@ +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]; + } + + private function stubResponse(array $items, string $linkHeader = ''): ResponseInterface + { + $response = $this->createMock(ResponseInterface::class); + $body = $this->createMock(StreamInterface::class); + $body->method('__toString')->willReturn((string) json_encode($items)); + $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 testListsTimelineItemsOfDifferentShapes(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $httpClient->method('sendRequest')->willReturn($this->stubResponse([ + [ + 'id' => 1, + 'node_id' => 'TE_1', + 'event' => 'commented', + 'body' => 'nice work', + 'actor' => [ + 'login' => 'octocat', + 'id' => 1, + 'html_url' => 'https://example.org/octocat', + 'avatar_url' => '', + ], + 'created_at' => '2026-06-25T12:00:00Z', + ], + [ + 'event' => 'committed', + 'sha' => 'abc123', + 'message' => 'fix bug', + ], + ])); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('horde/example'); + + $timeline = $client->getIssueTimeline($repo, 30); + + $this->assertInstanceOf(GithubTimelineEventList::class, $timeline); + $this->assertCount(2, $timeline); + $items = $timeline->toArray(); + $this->assertInstanceOf(GithubTimelineEvent::class, $items[0]); + $this->assertSame('commented', $items[0]->event); + $this->assertSame('nice work', $items[0]->raw['body']); + $this->assertSame('committed', $items[1]->event); + $this->assertNull($items[1]->actor); + $this->assertSame('abc123', $items[1]->raw['sha']); + } + + 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->getIssueTimeline($repo, 30); + } +} diff --git a/test/unit/GithubApiClientGetRepositoryEventsTest.php b/test/unit/GithubApiClientGetRepositoryEventsTest.php new file mode 100644 index 0000000..1e92c7f --- /dev/null +++ b/test/unit/GithubApiClientGetRepositoryEventsTest.php @@ -0,0 +1,146 @@ +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 eventBody(string $id, string $type): array + { + return [ + 'id' => $id, + 'type' => $type, + 'actor' => [ + 'login' => 'octocat', + 'id' => 1, + 'avatar_url' => '', + 'url' => 'https://example.org/api/users/octocat', + ], + 'repo' => [ + 'id' => 1, + 'name' => 'horde/example', + 'url' => 'https://example.org/api/repos/horde/example', + ], + 'payload' => ['action' => 'opened'], + 'public' => true, + 'created_at' => '2026-06-25T12:00:00Z', + ]; + } + + private function stubResponse(array $events, string $linkHeader = ''): ResponseInterface + { + $response = $this->createMock(ResponseInterface::class); + $body = $this->createMock(StreamInterface::class); + $body->method('__toString')->willReturn((string) json_encode($events)); + $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 testListsRepositoryEvents(): void + { + [$httpClient, $requestFactory] = $this->makeReadMocks(); + $httpClient->method('sendRequest')->willReturn($this->stubResponse([ + $this->eventBody('1', 'IssuesEvent'), + $this->eventBody('2', 'PushEvent'), + ])); + + $config = new GithubApiConfig(accessToken: 'tok'); + $client = new GithubApiClient($httpClient, $requestFactory, $config); + $repo = GithubRepository::fromFullName('horde/example'); + + $events = $client->getRepositoryEvents($repo); + + $this->assertInstanceOf(GithubRepositoryEventList::class, $events); + $this->assertCount(2, $events); + $items = $events->toArray(); + $this->assertInstanceOf(GithubRepositoryEvent::class, $items[0]); + $this->assertSame('IssuesEvent', $items[0]->type); + $this->assertSame('horde/example', $items[0]->repoName); + $this->assertSame('opened', $items[0]->payload['action']); + $this->assertTrue($items[0]->public); + } + + 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'); + + $events = $client->getRepositoryEvents($repo); + $this->assertCount(0, $events); + } + + 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->getRepositoryEvents($repo); + } +} diff --git a/test/unit/GithubApiClientListIssueCommentsTest.php b/test/unit/GithubApiClientListIssueCommentsTest.php index e0965ee..8526b1b 100644 --- a/test/unit/GithubApiClientListIssueCommentsTest.php +++ b/test/unit/GithubApiClientListIssueCommentsTest.php @@ -78,10 +78,10 @@ private function stubResponse(array $comments, string $linkHeader = ''): Respons $response->method('getStatusCode')->willReturn(200); $response->method('getBody')->willReturn($body); $response->method('getHeaderLine')->willReturnCallback( - static fn (string $name): string => strtolower($name) === 'link' ? $linkHeader : '' + static fn(string $name): string => strtolower($name) === 'link' ? $linkHeader : '' ); $response->method('hasHeader')->willReturnCallback( - static fn (string $name): bool => strtolower($name) === 'link' && $linkHeader !== '' + static fn(string $name): bool => strtolower($name) === 'link' && $linkHeader !== '' ); return $response; } diff --git a/test/unit/GithubApiClientListReleasesTest.php b/test/unit/GithubApiClientListReleasesTest.php index 41d52b5..9be432a 100644 --- a/test/unit/GithubApiClientListReleasesTest.php +++ b/test/unit/GithubApiClientListReleasesTest.php @@ -86,10 +86,10 @@ private function stubResponse(array $releases, string $linkHeader = ''): Respons // 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 : '' + static fn(string $name): string => strtolower($name) === 'link' ? $linkHeader : '' ); $response->method('hasHeader')->willReturnCallback( - static fn (string $name): bool => strtolower($name) === 'link' && $linkHeader !== '' + static fn(string $name): bool => strtolower($name) === 'link' && $linkHeader !== '' ); return $response; }