Repository: python-gitlab/python-gitlab

Issue:
Manage push rules on a group
## Description of the problem, including code/CLI snippet

It appears that a groups object doesn't have any methods to read or modify push rules

https://docs.gitlab.com/ee/api/groups.html#push-rules

## Expected Behavior

Expected similar functionality to the [projects.pushrules](https://python-gitlab.readthedocs.io/en/stable/gl_objects/projects.html#id21) methods, to be able to read and modify push rules at the group level that projects underneath the group will inherit.

## Actual Behavior

No pushrules methods exist in a groups object

## Specifications

  - python-gitlab version: python-gitlab==2.5.0
  - API version you are using (v3/v4): v4
  - Gitlab server version (or gitlab.com): gitlab.com



Discussion:
I decided to try to implement this myself and in doing so I realize that the the GitLab `groups/:id/push_rule` API is not returning the `reject_unsigned_commits` attribute that I'm actually interested in. I will open a bug report with GitLab and also create a pull request for my changes to add GitLab groups pushrules functionality.
https://gitlab.com/gitlab-org/gitlab/-/issues/297559

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
@@ -33,6 +33,7 @@
 from .notification_settings import GroupNotificationSettingsManager  # noqa: F401
 from .packages import GroupPackageManager  # noqa: F401
 from .projects import GroupProjectManager  # noqa: F401
+from .push_rules import GroupPushRulesManager
 from .runners import GroupRunnerManager  # noqa: F401
 from .statistics import GroupIssuesStatisticsManager  # noqa: F401
 from .variables import GroupVariableManager  # noqa: F401
@@ -75,6 +76,7 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject):
     notificationsettings: GroupNotificationSettingsManager
     packages: GroupPackageManager
     projects: GroupProjectManager
+    pushrules: GroupPushRulesManager
     runners: GroupRunnerManager
     subgroups: "GroupSubgroupManager"
     variables: GroupVariableManager
diff --git a/gitlab/v4/objects/push_rules.py b/gitlab/v4/objects/push_rules.py
--- a/gitlab/v4/objects/push_rules.py
+++ b/gitlab/v4/objects/push_rules.py
@@ -12,6 +12,8 @@
 from gitlab.types import RequiredOptional
 
 __all__ = [
+    "GroupPushRules",
+    "GroupPushRulesManager",
     "ProjectPushRules",
     "ProjectPushRulesManager",
 ]
@@ -27,16 +29,64 @@ class ProjectPushRulesManager(
     _path = "/projects/{project_id}/push_rule"
     _obj_cls = ProjectPushRules
     _from_parent_attrs = {"project_id": "id"}
+    _create_attrs = RequiredOptional(
+        optional=(
+            "author_email_regex",
+            "branch_name_regex",
+            "commit_committer_check",
+            "commit_message_negative_regex",
+            "commit_message_regex",
+            "deny_delete_tag",
+            "file_name_regex",
+            "max_file_size",
+            "member_check",
+            "prevent_secrets",
+            "reject_unsigned_commits",
+        ),
+    )
+    _update_attrs = RequiredOptional(
+        optional=(
+            "author_email_regex",
+            "branch_name_regex",
+            "commit_committer_check",
+            "commit_message_negative_regex",
+            "commit_message_regex",
+            "deny_delete_tag",
+            "file_name_regex",
+            "max_file_size",
+            "member_check",
+            "prevent_secrets",
+            "reject_unsigned_commits",
+        ),
+    )
+
+    def get(self, **kwargs: Any) -> ProjectPushRules:
+        return cast(ProjectPushRules, super().get(**kwargs))
+
+
+class GroupPushRules(SaveMixin, ObjectDeleteMixin, RESTObject):
+    _id_attr = None
+
+
+class GroupPushRulesManager(
+    GetWithoutIdMixin, CreateMixin, UpdateMixin, DeleteMixin, RESTManager
+):
+    _path = "/groups/{group_id}/push_rule"
+    _obj_cls = GroupPushRules
+    _from_parent_attrs = {"group_id": "id"}
     _create_attrs = RequiredOptional(
         optional=(
             "deny_delete_tag",
             "member_check",
             "prevent_secrets",
             "commit_message_regex",
+            "commit_message_negative_regex",
             "branch_name_regex",
             "author_email_regex",
             "file_name_regex",
             "max_file_size",
+            "commit_committer_check",
+            "reject_unsigned_commits",
         ),
     )
     _update_attrs = RequiredOptional(
@@ -45,12 +95,15 @@ class ProjectPushRulesManager(
             "member_check",
             "prevent_secrets",
             "commit_message_regex",
+            "commit_message_negative_regex",
             "branch_name_regex",
             "author_email_regex",
             "file_name_regex",
             "max_file_size",
+            "commit_committer_check",
+            "reject_unsigned_commits",
         ),
     )
 
-    def get(self, **kwargs: Any) -> ProjectPushRules:
-        return cast(ProjectPushRules, super().get(**kwargs))
+    def get(self, **kwargs: Any) -> GroupPushRules:
+        return cast(GroupPushRules, super().get(**kwargs))


Test patch:
diff --git a/tests/unit/objects/test_groups.py b/tests/unit/objects/test_groups.py
--- a/tests/unit/objects/test_groups.py
+++ b/tests/unit/objects/test_groups.py
@@ -38,6 +38,19 @@
         "created_at": "2020-01-15T12:36:29.590Z",
     },
 ]
+push_rules_content = {
+    "id": 2,
+    "created_at": "2020-08-17T19:09:19.580Z",
+    "commit_message_regex": "[a-zA-Z]",
+    "commit_message_negative_regex": "[x+]",
+    "branch_name_regex": "[a-z]",
+    "deny_delete_tag": True,
+    "member_check": True,
+    "prevent_secrets": True,
+    "author_email_regex": "^[A-Za-z0-9.]+@gitlab.com$",
+    "file_name_regex": "(exe)$",
+    "max_file_size": 100,
+}
 
 
 @pytest.fixture
@@ -111,6 +124,72 @@ def resp_transfer_group():
         yield rsps
 
 
+@pytest.fixture
+def resp_list_push_rules_group():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/push_rule",
+            json=push_rules_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_create_push_rules_group():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.POST,
+            url="http://localhost/api/v4/groups/1/push_rule",
+            json=push_rules_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_update_push_rules_group():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/push_rule",
+            json=push_rules_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.PUT,
+            url="http://localhost/api/v4/groups/1/push_rule",
+            json=push_rules_content,
+            content_type="application/json",
+            status=201,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_push_rules_group(no_content):
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/groups/1/push_rule",
+            json=push_rules_content,
+            content_type="application/json",
+            status=200,
+        )
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/groups/1/push_rule",
+            json=no_content,
+            content_type="application/json",
+            status=204,
+        )
+        yield rsps
+
+
 def test_get_group(gl, resp_groups):
     data = gl.groups.get(1)
     assert isinstance(data, gitlab.v4.objects.Group)
@@ -173,3 +252,27 @@ def test_refresh_group_import_status(group, resp_groups):
 def test_transfer_group(gl, resp_transfer_group):
     group = gl.groups.get(1, lazy=True)
     group.transfer("test-namespace")
+
+
+def test_list_group_push_rules(group, resp_list_push_rules_group):
+    pr = group.pushrules.get()
+    assert pr
+    assert pr.deny_delete_tag
+
+
+def test_create_group_push_rule(group, resp_create_push_rules_group):
+    group.pushrules.create({"deny_delete_tag": True})
+
+
+def test_update_group_push_rule(
+    group,
+    resp_update_push_rules_group,
+):
+    pr = group.pushrules.get()
+    pr.deny_delete_tag = False
+    pr.save()
+
+
+def test_delete_group_push_rule(group, resp_delete_push_rules_group):
+    pr = group.pushrules.get()
+    pr.delete()