From a15e75643260eada067b7f0a52131359f58a9d09 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Wed, 6 May 2026 16:30:38 +0100 Subject: [PATCH 1/2] Add group hook endpoints --- CHANGELOG.md | 1 + src/Api/GroupsHooks.php | 124 ++++++++++++++ src/Client.php | 6 + tests/Api/GroupsHooksTest.php | 306 ++++++++++++++++++++++++++++++++++ 4 files changed, 437 insertions(+) create mode 100644 src/Api/GroupsHooks.php create mode 100644 tests/Api/GroupsHooksTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ab09057..b288d4ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Add support for `visibility` in `Groups::all` * Add support for personal access tokens * Add support for project integrations endpoints +* Add support for group hook endpoints * Add support for `job_inputs` and `job_variables_attributes` in `Jobs::play` * Add support for filters in `Projects::projectAccessTokens` * Add support for `Projects::rotateProjectAccessToken` diff --git a/src/Api/GroupsHooks.php b/src/Api/GroupsHooks.php new file mode 100644 index 00000000..0a654f06 --- /dev/null +++ b/src/Api/GroupsHooks.php @@ -0,0 +1,124 @@ + + * (c) Graham Campbell + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gitlab\Api; + +class GroupsHooks extends AbstractApi +{ + /** + * @param array $parameters { + * + * @var int $page page number + * @var int $per_page number of items to list per page + * } + */ + public function all(int|string $group_id, array $parameters = []): mixed + { + $resolver = $this->createOptionsResolver(); + + return $this->get('groups/'.self::encodePath($group_id).'/hooks', $resolver->resolve($parameters)); + } + + public function show(int|string $group_id, int $hook_id): mixed + { + return $this->get('groups/'.self::encodePath($group_id).'/hooks/'.self::encodePath($hook_id)); + } + + /** + * Create a group hook. + * + * Hook parameters vary across GitLab versions and are passed through. + * + * @see https://docs.gitlab.com/api/group_webhooks/#create-a-group-hook + * + * @param array $parameters + */ + public function create(int|string $group_id, string $url, array $parameters = []): mixed + { + $parameters['url'] = $url; + + return $this->post('groups/'.self::encodePath($group_id).'/hooks', $parameters); + } + + /** + * Update a group hook. + * + * Hook parameters vary across GitLab versions and are passed through. + * + * @see https://docs.gitlab.com/api/group_webhooks/#update-a-group-hook + * + * @param array $parameters + */ + public function update(int|string $group_id, int $hook_id, array $parameters): mixed + { + return $this->put('groups/'.self::encodePath($group_id).'/hooks/'.self::encodePath($hook_id), $parameters); + } + + public function remove(int|string $group_id, int $hook_id): mixed + { + return $this->delete('groups/'.self::encodePath($group_id).'/hooks/'.self::encodePath($hook_id)); + } + + /** + * @param array $parameters { + * + * @var int $page page number + * @var int $per_page number of items to list per page + * @var int|string $status response status code or status category + * } + */ + public function events(int|string $group_id, int $hook_id, array $parameters = []): mixed + { + $resolver = $this->createOptionsResolver(); + $resolver->setDefined('status') + ->setAllowedTypes('status', ['int', 'string']) + ; + + return $this->get('groups/'.self::encodePath($group_id).'/hooks/'.self::encodePath($hook_id).'/events', $resolver->resolve($parameters)); + } + + public function resendEvent(int|string $group_id, int $hook_id, int $hook_event_id): mixed + { + return $this->post('groups/'.self::encodePath($group_id).'/hooks/'.self::encodePath($hook_id).'/events/'.self::encodePath($hook_event_id).'/resend'); + } + + public function test(int|string $group_id, int $hook_id, string $trigger): mixed + { + return $this->post('groups/'.self::encodePath($group_id).'/hooks/'.self::encodePath($hook_id).'/test/'.self::encodePath($trigger)); + } + + public function setCustomHeader(int|string $group_id, int $hook_id, string $key, string $value): mixed + { + return $this->put('groups/'.self::encodePath($group_id).'/hooks/'.self::encodePath($hook_id).'/custom_headers/'.self::encodePath($key), [ + 'value' => $value, + ]); + } + + public function deleteCustomHeader(int|string $group_id, int $hook_id, string $key): mixed + { + return $this->delete('groups/'.self::encodePath($group_id).'/hooks/'.self::encodePath($hook_id).'/custom_headers/'.self::encodePath($key)); + } + + public function setUrlVariable(int|string $group_id, int $hook_id, string $key, string $value): mixed + { + return $this->put('groups/'.self::encodePath($group_id).'/hooks/'.self::encodePath($hook_id).'/url_variables/'.self::encodePath($key), [ + 'value' => $value, + ]); + } + + public function deleteUrlVariable(int|string $group_id, int $hook_id, string $key): mixed + { + return $this->delete('groups/'.self::encodePath($group_id).'/hooks/'.self::encodePath($hook_id).'/url_variables/'.self::encodePath($key)); + } +} diff --git a/src/Client.php b/src/Client.php index bcca7dbd..c4acc103 100644 --- a/src/Client.php +++ b/src/Client.php @@ -21,6 +21,7 @@ use Gitlab\Api\Groups; use Gitlab\Api\GroupsBoards; use Gitlab\Api\GroupsEpics; +use Gitlab\Api\GroupsHooks; use Gitlab\Api\GroupsMilestones; use Gitlab\Api\Integrations; use Gitlab\Api\IssueBoards; @@ -168,6 +169,11 @@ public function groupsEpics(): GroupsEpics return new GroupsEpics($this); } + public function groupsHooks(): GroupsHooks + { + return new GroupsHooks($this); + } + public function groupsMilestones(): GroupsMilestones { return new GroupsMilestones($this); diff --git a/tests/Api/GroupsHooksTest.php b/tests/Api/GroupsHooksTest.php new file mode 100644 index 00000000..e84c77ed --- /dev/null +++ b/tests/Api/GroupsHooksTest.php @@ -0,0 +1,306 @@ + + * (c) Graham Campbell + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gitlab\Tests\Api; + +use Gitlab\Api\GroupsHooks; +use PHPUnit\Framework\Attributes\Test; + +class GroupsHooksTest extends TestCase +{ + #[Test] + public function shouldGetAllHooks(): void + { + $expectedArray = [ + ['id' => 1, 'url' => 'https://example.com/webhook-trigger/1'], + ['id' => 2, 'url' => 'https://example.com/webhook-trigger/2'], + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('groups/1/hooks', []) + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->all(1)); + } + + #[Test] + public function shouldGetAllHooksWithPagination(): void + { + $expectedArray = [ + ['id' => 1, 'url' => 'https://example.com/webhook-trigger/1'], + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('groups/1/hooks', ['page' => 2, 'per_page' => 50]) + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->all(1, ['page' => 2, 'per_page' => 50])); + } + + #[Test] + public function shouldGetAllHooksForStringGroupPath(): void + { + $expectedArray = [ + ['id' => 1, 'url' => 'https://example.com/webhook-trigger/1'], + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('groups/foo%2Fbar/hooks', []) + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->all('foo/bar')); + } + + #[Test] + public function shouldShowHook(): void + { + $expectedArray = ['id' => 2, 'url' => 'https://example.com/webhook-trigger/2']; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('groups/1/hooks/2') + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->show(1, 2)); + } + + #[Test] + public function shouldCreateHook(): void + { + $expectedArray = ['id' => 3, 'url' => 'https://example.com/webhook-trigger/3']; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('post') + ->with('groups/1/hooks', ['push_events' => true, 'url' => $expectedArray['url']]) + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->create(1, $expectedArray['url'], ['push_events' => true])); + } + + #[Test] + public function shouldCreateHookWithOnlyUrl(): void + { + $expectedArray = ['id' => 3, 'url' => 'https://example.com/webhook-trigger/3']; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('post') + ->with('groups/1/hooks', ['url' => $expectedArray['url']]) + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->create(1, $expectedArray['url'])); + } + + #[Test] + public function shouldUpdateHook(): void + { + $expectedArray = ['id' => 2, 'url' => 'https://example.com/webhook-trigger-rename/2']; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('put') + ->with('groups/1/hooks/2', ['url' => $expectedArray['url'], 'name' => 'Test Webhook']) + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->update(1, 2, ['url' => $expectedArray['url'], 'name' => 'Test Webhook'])); + } + + #[Test] + public function shouldRemoveHook(): void + { + $expectedBool = true; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('delete') + ->with('groups/1/hooks/2') + ->willReturn($expectedBool) + ; + + $this->assertEquals($expectedBool, $api->remove(1, 2)); + } + + #[Test] + public function shouldGetEvents(): void + { + $expectedArray = [ + [ + 'id' => 1, + 'url' => 'https://example.com/webhook-trigger/2', + 'trigger' => 'push_hooks', + ], + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('groups/1/hooks/2/events', []) + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->events(1, 2)); + } + + #[Test] + public function shouldGetEventsWithStringStatus(): void + { + $expectedArray = [ + ['id' => 1, 'response_status' => '500'], + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('groups/1/hooks/2/events', ['page' => 2, 'per_page' => 15, 'status' => 'server_failure']) + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->events(1, 2, [ + 'page' => 2, + 'per_page' => 15, + 'status' => 'server_failure', + ])); + } + + #[Test] + public function shouldGetEventsWithIntegerStatus(): void + { + $expectedArray = [ + ['id' => 1, 'response_status' => '200'], + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('groups/1/hooks/2/events', ['status' => 200]) + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->events(1, 2, ['status' => 200])); + } + + #[Test] + public function shouldResendEvent(): void + { + $expectedArray = [ + 'response_status' => 200, + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('post') + ->with('groups/1/hooks/2/events/3/resend') + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->resendEvent(1, 2, 3)); + } + + #[Test] + public function shouldTestHook(): void + { + $expectedArray = [ + 'message' => '201 Created', + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('post') + ->with('groups/1/hooks/2/test/push_events') + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->test(1, 2, 'push_events')); + } + + #[Test] + public function shouldSetCustomHeader(): void + { + $expected = ''; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('put') + ->with('groups/1/hooks/2/custom_headers/X-Webhook-Token', ['value' => 'secret']) + ->willReturn($expected) + ; + + $this->assertEquals($expected, $api->setCustomHeader(1, 2, 'X-Webhook-Token', 'secret')); + } + + #[Test] + public function shouldDeleteCustomHeader(): void + { + $expectedBool = true; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('delete') + ->with('groups/1/hooks/2/custom_headers/X-Webhook-Token') + ->willReturn($expectedBool) + ; + + $this->assertEquals($expectedBool, $api->deleteCustomHeader(1, 2, 'X-Webhook-Token')); + } + + #[Test] + public function shouldSetUrlVariable(): void + { + $expected = ''; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('put') + ->with('groups/1/hooks/2/url_variables/environment', ['value' => 'staging']) + ->willReturn($expected) + ; + + $this->assertEquals($expected, $api->setUrlVariable(1, 2, 'environment', 'staging')); + } + + #[Test] + public function shouldDeleteUrlVariable(): void + { + $expectedBool = true; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('delete') + ->with('groups/1/hooks/2/url_variables/environment') + ->willReturn($expectedBool) + ; + + $this->assertEquals($expectedBool, $api->deleteUrlVariable(1, 2, 'environment')); + } + + protected function getApiClass(): string + { + return GroupsHooks::class; + } +} From 994ad47c23e5469005f2158a9c630da00309daf3 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Wed, 6 May 2026 16:34:11 +0100 Subject: [PATCH 2/2] Remove manual group hook pagination --- src/Api/GroupsHooks.php | 19 +++++-------------- tests/Api/GroupsHooksTest.php | 25 +++---------------------- 2 files changed, 8 insertions(+), 36 deletions(-) diff --git a/src/Api/GroupsHooks.php b/src/Api/GroupsHooks.php index 0a654f06..d388bd1e 100644 --- a/src/Api/GroupsHooks.php +++ b/src/Api/GroupsHooks.php @@ -14,20 +14,13 @@ namespace Gitlab\Api; +use Symfony\Component\OptionsResolver\OptionsResolver; + class GroupsHooks extends AbstractApi { - /** - * @param array $parameters { - * - * @var int $page page number - * @var int $per_page number of items to list per page - * } - */ - public function all(int|string $group_id, array $parameters = []): mixed + public function all(int|string $group_id): mixed { - $resolver = $this->createOptionsResolver(); - - return $this->get('groups/'.self::encodePath($group_id).'/hooks', $resolver->resolve($parameters)); + return $this->get('groups/'.self::encodePath($group_id).'/hooks'); } public function show(int|string $group_id, int $hook_id): mixed @@ -73,14 +66,12 @@ public function remove(int|string $group_id, int $hook_id): mixed /** * @param array $parameters { * - * @var int $page page number - * @var int $per_page number of items to list per page * @var int|string $status response status code or status category * } */ public function events(int|string $group_id, int $hook_id, array $parameters = []): mixed { - $resolver = $this->createOptionsResolver(); + $resolver = new OptionsResolver(); $resolver->setDefined('status') ->setAllowedTypes('status', ['int', 'string']) ; diff --git a/tests/Api/GroupsHooksTest.php b/tests/Api/GroupsHooksTest.php index e84c77ed..d0a2e6f7 100644 --- a/tests/Api/GroupsHooksTest.php +++ b/tests/Api/GroupsHooksTest.php @@ -30,30 +30,13 @@ public function shouldGetAllHooks(): void $api = $this->getApiMock(); $api->expects($this->once()) ->method('get') - ->with('groups/1/hooks', []) + ->with('groups/1/hooks') ->willReturn($expectedArray) ; $this->assertEquals($expectedArray, $api->all(1)); } - #[Test] - public function shouldGetAllHooksWithPagination(): void - { - $expectedArray = [ - ['id' => 1, 'url' => 'https://example.com/webhook-trigger/1'], - ]; - - $api = $this->getApiMock(); - $api->expects($this->once()) - ->method('get') - ->with('groups/1/hooks', ['page' => 2, 'per_page' => 50]) - ->willReturn($expectedArray) - ; - - $this->assertEquals($expectedArray, $api->all(1, ['page' => 2, 'per_page' => 50])); - } - #[Test] public function shouldGetAllHooksForStringGroupPath(): void { @@ -64,7 +47,7 @@ public function shouldGetAllHooksForStringGroupPath(): void $api = $this->getApiMock(); $api->expects($this->once()) ->method('get') - ->with('groups/foo%2Fbar/hooks', []) + ->with('groups/foo%2Fbar/hooks') ->willReturn($expectedArray) ; @@ -177,13 +160,11 @@ public function shouldGetEventsWithStringStatus(): void $api = $this->getApiMock(); $api->expects($this->once()) ->method('get') - ->with('groups/1/hooks/2/events', ['page' => 2, 'per_page' => 15, 'status' => 'server_failure']) + ->with('groups/1/hooks/2/events', ['status' => 'server_failure']) ->willReturn($expectedArray) ; $this->assertEquals($expectedArray, $api->events(1, 2, [ - 'page' => 2, - 'per_page' => 15, 'status' => 'server_failure', ])); }