Repository: python-gitlab/python-gitlab (language: python)

Issue:
Add support for testing/triggering Project and Group hooks
At the moment there is no support for https://docs.gitlab.com/ee/api/projects.html#trigger-a-test-project-hook

So support would need to be added to `gitlab/v4/objects/hooks.py`

Probably need to add a method `test()` to ~~`ProjectHookManager`~~ `ProjectHook` and ~~`GroupHookManager`~~ `GroupHook`

PRs are welcome.

### Discussed in https://github.com/python-gitlab/python-gitlab/discussions/2923

<div type='discussions-op-text'>

<sup>Originally posted by **vasokot** July 11, 2024</sup>
## Description of the problem, including code/CLI snippet

Need to trigget test of project hook https://docs.gitlab.com/ee/api/projects.html#trigger-a-test-project-hook

How I can do it with python-gitlab?

## Specifications

  - python-gitlab version: 4.7
  - API version you are using (v3/v4): v4
  - Gitlab server version (or gitlab.com): 16.11.6-ee
</div>


Discussion / hints:
@vasokot Created an issue on missing support. If you would like to do a PR for this it would be great.
Correction. Probably need to add the `test()` method to `ProjectHook` and `GroupHook`.

I think the API would probably be like:

```
hook = project.hooks.get(hook_id)
result = hook.test(trigger="the_trigger_value")
```

Fix patch:
diff --git a/docs/gl_objects/groups.rst b/docs/gl_objects/groups.rst
index 479cbd27e..1a921f87f 100644
--- a/docs/gl_objects/groups.rst
+++ b/docs/gl_objects/groups.rst
@@ -89,7 +89,7 @@ Remove a group::
     group.delete()
 
 Restore a Group marked for deletion (Premium only):::
-    
+
     group.restore()
 
 
@@ -368,9 +368,9 @@ SAML group links
 
 Add a SAML group link to an existing GitLab group::
 
-    saml_link = group.saml_group_links.create({ 
-        "saml_group_name": "<your_saml_group_name>", 
-        "access_level": <chosen_access_level> 
+    saml_link = group.saml_group_links.create({
+        "saml_group_name": "<your_saml_group_name>",
+        "access_level": <chosen_access_level>
     })
 
 List a group's SAML group links::
@@ -419,6 +419,10 @@ Update a group hook::
     hook.push_events = 0
     hook.save()
 
+Test a group hook::
+
+    hook.test("push_events")
+
 Delete a group hook::
 
     group.hooks.delete(hook_id)
diff --git a/docs/gl_objects/projects.rst b/docs/gl_objects/projects.rst
index ba024ce05..5697fd206 100644
--- a/docs/gl_objects/projects.rst
+++ b/docs/gl_objects/projects.rst
@@ -353,7 +353,7 @@ Import the project using file stored on a remote URL::
     output = gl.projects.remote_import(
         url="https://whatever.com/url/file.tar.gz",
         path="my_new_remote_project",
-        name="My New Remote Project", 
+        name="My New Remote Project",
         namespace="my-group",
         override_params={'visibility': 'private'},
     )
@@ -367,7 +367,7 @@ Import the project using file stored on AWS S3::
         file_key="aws-file-key",
         access_key_id="aws-access-key-id",
         secret_access_key="secret-access-key",
-        name="My New Remote Project", 
+        name="My New Remote Project",
         namespace="my-group",
         override_params={'visibility': 'private'},
     )
@@ -449,7 +449,7 @@ Get file details from headers, without fetching its entire content::
     print(headers["X-Gitlab-Size"])
 
 Get a raw file::
-    
+
     raw_content = project.files.raw(file_path='README.rst', ref='main')
     print(raw_content)
     with open('/tmp/raw-download.txt', 'wb') as f:
@@ -689,6 +689,10 @@ Update a project hook::
     hook.push_events = 0
     hook.save()
 
+Test a project hook::
+
+    hook.test("push_events")
+
 Delete a project hook::
 
     project.hooks.delete(hook_id)
diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py
index 1ff67b9c7..35f7dc11c 100644
--- a/gitlab/exceptions.py
+++ b/gitlab/exceptions.py
@@ -316,6 +316,10 @@ class GitlabDeploymentApprovalError(GitlabOperationError):
     pass
 
 
+class GitlabHookTestError(GitlabOperationError):
+    pass
+
+
 # For an explanation of how these type-hints work see:
 # https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators
 #
@@ -370,6 +374,7 @@ def wrapped_f(*args: Any, **kwargs: Any) -> Any:
     "GitlabGetError",
     "GitlabGroupTransferError",
     "GitlabHeadError",
+    "GitlabHookTestError",
     "GitlabHousekeepingError",
     "GitlabHttpError",
     "GitlabImportError",
diff --git a/gitlab/v4/objects/hooks.py b/gitlab/v4/objects/hooks.py
index aa0ff0368..798f92e4d 100644
--- a/gitlab/v4/objects/hooks.py
+++ b/gitlab/v4/objects/hooks.py
@@ -1,5 +1,6 @@
 from typing import Any, cast, Union
 
+from gitlab import exceptions as exc
 from gitlab.base import RESTManager, RESTObject
 from gitlab.mixins import CRUDMixin, NoUpdateMixin, ObjectDeleteMixin, SaveMixin
 from gitlab.types import RequiredOptional
@@ -31,6 +32,20 @@ def get(self, id: Union[str, int], lazy: bool = False, **kwargs: Any) -> Hook:
 class ProjectHook(SaveMixin, ObjectDeleteMixin, RESTObject):
     _repr_attr = "url"
 
+    @exc.on_http_error(exc.GitlabHookTestError)
+    def test(self, trigger: str) -> None:
+        """
+        Test a Project Hook
+
+        Args:
+            trigger: Type of trigger event to test
+
+        Raises:
+            GitlabHookTestError: If the hook test attempt failed
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/test/{trigger}"
+        self.manager.gitlab.http_post(path)
+
 
 class ProjectHookManager(CRUDMixin, RESTManager):
     _path = "/projects/{project_id}/hooks"
@@ -78,6 +93,20 @@ def get(
 class GroupHook(SaveMixin, ObjectDeleteMixin, RESTObject):
     _repr_attr = "url"
 
+    @exc.on_http_error(exc.GitlabHookTestError)
+    def test(self, trigger: str) -> None:
+        """
+        Test a Group Hook
+
+        Args:
+            trigger: Type of trigger event to test
+
+        Raises:
+            GitlabHookTestError: If the hook test attempt failed
+        """
+        path = f"{self.manager.path}/{self.encoded_id}/test/{trigger}"
+        self.manager.gitlab.http_post(path)
+
 
 class GroupHookManager(CRUDMixin, RESTManager):
     _path = "/groups/{group_id}/hooks"


Test patch:
diff --git a/tests/unit/objects/test_hooks.py b/tests/unit/objects/test_hooks.py
index 0f9dbe282..550ea2ccc 100644
--- a/tests/unit/objects/test_hooks.py
+++ b/tests/unit/objects/test_hooks.py
@@ -9,6 +9,7 @@
 import pytest
 import responses
 
+import gitlab
 from gitlab.v4.objects import GroupHook, Hook, ProjectHook
 
 hooks_content = [
@@ -89,6 +90,58 @@ def resp_hook_update():
         yield rsps
 
 
+@pytest.fixture
+def resp_hook_test():
+    with responses.RequestsMock() as rsps:
+        hook_pattern = re.compile(
+            r"http://localhost/api/v4/((groups|projects)/1/|)hooks/1"
+        )
+        test_pattern = re.compile(
+            r"http://localhost/api/v4/((groups|projects)/1/|)hooks/1/test/[a-z_]+"
+        )
+        rsps.add(
+            method=responses.GET,
+            url=hook_pattern,
+            json=hook_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.POST,
+            url=test_pattern,
+            json={"message": "201 Created"},
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_hook_test_error():
+    with responses.RequestsMock() as rsps:
+        hook_pattern = re.compile(
+            r"http://localhost/api/v4/((groups|projects)/1/|)hooks/1"
+        )
+        test_pattern = re.compile(
+            r"http://localhost/api/v4/((groups|projects)/1/|)hooks/1/test/[a-z_]+"
+        )
+        rsps.add(
+            method=responses.GET,
+            url=hook_pattern,
+            json=hook_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.POST,
+            url=test_pattern,
+            json={"message": "<html>error</html>"},
+            content_type="application/json",
+            status=422,
+        )
+        yield rsps
+
+
 @pytest.fixture
 def resp_hook_delete():
     with responses.RequestsMock() as rsps:
@@ -174,6 +227,17 @@ def test_delete_group_hook(group, resp_hook_delete):
     group.hooks.delete(1)
 
 
+def test_test_group_hook(group, resp_hook_test):
+    hook = group.hooks.get(1)
+    hook.test("push_events")
+
+
+def test_test_error_group_hook(group, resp_hook_test_error):
+    hook = group.hooks.get(1)
+    with pytest.raises(gitlab.exceptions.GitlabHookTestError):
+        hook.test("push_events")
+
+
 def test_list_project_hooks(project, resp_hooks_list):
     hooks = project.hooks.list()
     assert hooks[0].id == 1