Repository: python-gitlab/python-gitlab

Issue:
Group Hooks API not supported
## Description of the problem, including code/CLI snippet
The webhook API endpoints for groups appears to be missing from python-gitlab. Only system hooks and project hooks are implemented. Luckily the api is almost identical to that of project hooks, so implementation should be pretty straightforward.

Missing API: https://docs.gitlab.com/ee/api/groups.html#hooks
Project Hooks API counterpart: https://docs.gitlab.com/ee/api/projects.html#hooks 

## Expected Behavior
To be able to list, get, add, edit and delete group hooks.

## Actual Behavior
No API available.

## Specifications

  - python-gitlab version: v2.7.1
  - API version you are using (v3/v4): v4
  - Gitlab server version (or gitlab.com): 13.11.3-ee



Discussion:

Resolving patch:
diff --git a/gitlab/v4/objects/groups.py b/gitlab/v4/objects/groups.py
--- a/gitlab/v4/objects/groups.py
+++ b/gitlab/v4/objects/groups.py
@@ -13,6 +13,7 @@
 from .deploy_tokens import GroupDeployTokenManager  # noqa: F401
 from .epics import GroupEpicManager  # noqa: F401
 from .export_import import GroupExportManager, GroupImportManager  # noqa: F401
+from .hooks import GroupHookManager  # noqa: F401
 from .issues import GroupIssueManager  # noqa: F401
 from .labels import GroupLabelManager  # noqa: F401
 from .members import (  # noqa: F401
@@ -52,6 +53,7 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject):
         ("descendant_groups", "GroupDescendantGroupManager"),
         ("exports", "GroupExportManager"),
         ("epics", "GroupEpicManager"),
+        ("hooks", "GroupHookManager"),
         ("imports", "GroupImportManager"),
         ("issues", "GroupIssueManager"),
         ("issues_statistics", "GroupIssuesStatisticsManager"),
diff --git a/gitlab/v4/objects/hooks.py b/gitlab/v4/objects/hooks.py
--- a/gitlab/v4/objects/hooks.py
+++ b/gitlab/v4/objects/hooks.py
@@ -6,6 +6,8 @@
     "HookManager",
     "ProjectHook",
     "ProjectHookManager",
+    "GroupHook",
+    "GroupHookManager",
 ]
 
 
@@ -60,3 +62,53 @@ class ProjectHookManager(CRUDMixin, RESTManager):
             "token",
         ),
     )
+
+
+class GroupHook(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _short_print_attr = "url"
+
+
+class GroupHookManager(CRUDMixin, RESTManager):
+    _path = "/groups/%(group_id)s/hooks"
+    _obj_cls = GroupHook
+    _from_parent_attrs = {"group_id": "id"}
+    _create_attrs = RequiredOptional(
+        required=("url",),
+        optional=(
+            "push_events",
+            "issues_events",
+            "confidential_issues_events",
+            "merge_requests_events",
+            "tag_push_events",
+            "note_events",
+            "confidential_note_events",
+            "job_events",
+            "pipeline_events",
+            "wiki_page_events",
+            "deployment_events",
+            "releases_events",
+            "subgroup_events",
+            "enable_ssl_verification",
+            "token",
+        ),
+    )
+    _update_attrs = RequiredOptional(
+        required=("url",),
+        optional=(
+            "push_events",
+            "issues_events",
+            "confidential_issues_events",
+            "merge_requests_events",
+            "tag_push_events",
+            "note_events",
+            "confidential_note_events",
+            "job_events",
+            "pipeline_events",
+            "wiki_page_events",
+            "deployment_events",
+            "releases_events",
+            "subgroup_events",
+            "enable_ssl_verification",
+            "token",
+        ),
+    )


Test patch:
diff --git a/tests/functional/api/test_groups.py b/tests/functional/api/test_groups.py
--- a/tests/functional/api/test_groups.py
+++ b/tests/functional/api/test_groups.py
@@ -209,3 +209,16 @@ def test_group_wiki(group):
     wiki.save()
     wiki.delete()
     assert len(group.wikis.list()) == 0
+
+
+@pytest.mark.skip(reason="EE feature")
+def test_group_hooks(group):
+    hook = group.hooks.create({"url": "http://hook.url"})
+    assert len(group.hooks.list()) == 1
+
+    hook.note_events = True
+    hook.save()
+
+    hook = group.hooks.get(hook.id)
+    assert hook.note_events is True
+    hook.delete()
diff --git a/tests/unit/objects/test_hooks.py b/tests/unit/objects/test_hooks.py
--- a/tests/unit/objects/test_hooks.py
+++ b/tests/unit/objects/test_hooks.py
@@ -1,29 +1,209 @@
 """
 GitLab API: https://docs.gitlab.com/ce/api/system_hooks.html
+GitLab API: https://docs.gitlab.com/ce/api/groups.html#hooks
+GitLab API: https://docs.gitlab.com/ee/api/projects.html#hooks
 """
+
+import re
+
 import pytest
 import responses
 
-from gitlab.v4.objects import Hook
+from gitlab.v4.objects import GroupHook, Hook, ProjectHook
+
+hooks_content = [
+    {
+        "id": 1,
+        "url": "testurl",
+        "push_events": True,
+        "tag_push_events": True,
+    },
+    {
+        "id": 2,
+        "url": "testurl_second",
+        "push_events": False,
+        "tag_push_events": False,
+    },
+]
+
+hook_content = hooks_content[0]
+
+
+@pytest.fixture
+def resp_hooks_list():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(r"http://localhost/api/v4/((groups|projects)/1/|)hooks"),
+            json=hooks_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_hook_get():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(r"http://localhost/api/v4/((groups|projects)/1/|)hooks/1"),
+            json=hook_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_hook_create():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url=re.compile(r"http://localhost/api/v4/((groups|projects)/1/|)hooks"),
+            json=hook_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
 
 
 @pytest.fixture
-def resp_get_hook():
-    content = {"url": "testurl", "id": 1}
+def resp_hook_update():
+    with responses.RequestsMock() as rsps:
+        pattern = re.compile(r"http://localhost/api/v4/((groups|projects)/1/|)hooks/1")
+        rsps.add(
+            method=responses.GET,
+            url=pattern,
+            json=hook_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.PUT,
+            url=pattern,
+            json=hook_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
 
+@pytest.fixture
+def resp_hook_delete():
     with responses.RequestsMock() as rsps:
+        pattern = re.compile(r"http://localhost/api/v4/((groups|projects)/1/|)hooks/1")
         rsps.add(
             method=responses.GET,
-            url="http://localhost/api/v4/hooks/1",
-            json=content,
+            url=pattern,
+            json=hook_content,
             content_type="application/json",
             status=200,
         )
+        rsps.add(
+            method=responses.DELETE,
+            url=pattern,
+            status=204,
+        )
         yield rsps
 
 
-def test_hooks(gl, resp_get_hook):
+def test_list_system_hooks(gl, resp_hooks_list):
+    hooks = gl.hooks.list()
+    assert hooks[0].id == 1
+    assert hooks[0].url == "testurl"
+    assert hooks[1].id == 2
+    assert hooks[1].url == "testurl_second"
+
+
+def test_get_system_hook(gl, resp_hook_get):
     data = gl.hooks.get(1)
     assert isinstance(data, Hook)
     assert data.url == "testurl"
     assert data.id == 1
+
+
+def test_create_system_hook(gl, resp_hook_create):
+    hook = gl.hooks.create(hook_content)
+    assert hook.url == "testurl"
+    assert hook.push_events is True
+    assert hook.tag_push_events is True
+
+
+# there is no update method for system hooks
+
+
+def test_delete_system_hook(gl, resp_hook_delete):
+    hook = gl.hooks.get(1)
+    hook.delete()
+    gl.hooks.delete(1)
+
+
+def test_list_group_hooks(group, resp_hooks_list):
+    hooks = group.hooks.list()
+    assert hooks[0].id == 1
+    assert hooks[0].url == "testurl"
+    assert hooks[1].id == 2
+    assert hooks[1].url == "testurl_second"
+
+
+def test_get_group_hook(group, resp_hook_get):
+    data = group.hooks.get(1)
+    assert isinstance(data, GroupHook)
+    assert data.url == "testurl"
+    assert data.id == 1
+
+
+def test_create_group_hook(group, resp_hook_create):
+    hook = group.hooks.create(hook_content)
+    assert hook.url == "testurl"
+    assert hook.push_events is True
+    assert hook.tag_push_events is True
+
+
+def test_update_group_hook(group, resp_hook_update):
+    hook = group.hooks.get(1)
+    assert hook.id == 1
+    hook.url = "testurl_more"
+    hook.save()
+
+
+def test_delete_group_hook(group, resp_hook_delete):
+    hook = group.hooks.get(1)
+    hook.delete()
+    group.hooks.delete(1)
+
+
+def test_list_project_hooks(project, resp_hooks_list):
+    hooks = project.hooks.list()
+    assert hooks[0].id == 1
+    assert hooks[0].url == "testurl"
+    assert hooks[1].id == 2
+    assert hooks[1].url == "testurl_second"
+
+
+def test_get_project_hook(project, resp_hook_get):
+    data = project.hooks.get(1)
+    assert isinstance(data, ProjectHook)
+    assert data.url == "testurl"
+    assert data.id == 1
+
+
+def test_create_project_hook(project, resp_hook_create):
+    hook = project.hooks.create(hook_content)
+    assert hook.url == "testurl"
+    assert hook.push_events is True
+    assert hook.tag_push_events is True
+
+
+def test_update_project_hook(project, resp_hook_update):
+    hook = project.hooks.get(1)
+    assert hook.id == 1
+    hook.url = "testurl_more"
+    hook.save()
+
+
+def test_delete_project_hook(project, resp_hook_delete):
+    hook = project.hooks.get(1)
+    hook.delete()
+    project.hooks.delete(1)
diff --git a/tests/unit/objects/test_projects.py b/tests/unit/objects/test_projects.py
--- a/tests/unit/objects/test_projects.py
+++ b/tests/unit/objects/test_projects.py
@@ -177,31 +177,6 @@ def test_delete_shared_project_link(gl):
     pass
 
 
-@pytest.mark.skip(reason="missing test")
-def test_list_project_hooks(gl):
-    pass
-
-
-@pytest.mark.skip(reason="missing test")
-def test_get_project_hook(gl):
-    pass
-
-
-@pytest.mark.skip(reason="missing test")
-def test_create_project_hook(gl):
-    pass
-
-
-@pytest.mark.skip(reason="missing test")
-def test_update_project_hook(gl):
-    pass
-
-
-@pytest.mark.skip(reason="missing test")
-def test_delete_project_hook(gl):
-    pass
-
-
 @pytest.mark.skip(reason="missing test")
 def test_create_forked_from_relationship(gl):
     pass