Repository: python-gitlab/python-gitlab

Issue:
ProjectMergeRequestApprovalRule.save() throws 404
## Description of the problem, including code/CLI snippet
`gl.project.get(proj_id).merge_requests.get(mr_iid).approval_rules.get(rule_id).save()`

This example is an MVP example; actually making changes to the rule object before calling `.save()` doesn't change the behaviour

## Expected Behavior
The function should succeed silently, returning `None`

## Actual Behavior
`gitlab.exceptions.GitlabUpdateError: 404: 404 Not found` is thrown. Trying it with debug mode on, it appears as though the root cause of the issue is that when the CLI invokes `/projects/:id/merge_requests/:merge_request_iid/approval_rules/:approval_rule_id` in the API, `:id` (i.e. project ID) is passed where the URL expects `:approval_rule_id`, as can be seen from this debug output (anonymized to remove sensitive information)

```
>>> rule.save()
DEBUG:urllib3.connectionpool:Resetting dropped connection: mygitlab.example.com
send: b'PUT /api/v4/projects/93/merge_requests/1/approval_rules/93 HTTP/1.1\r\nHost: mygitlab.example.com\r\nUser-Agent: python-gitlab/3.14.0\r\nAccept-Encoding: gzip, deflate\r\nAccept: */*\r\nConnection: keep-alive\r\nPRIVATE-TOKEN: TOKEN\r\nContent-type: application/json\r\nContent-Length: 768\r\n\r\n'
send: b'{"id": "93", "merge_request_iid": "1", "approval_rule_id": 89, "name": "testrule", "approvals_required": 1, "users": ["{\\"id\\": 168, \\"username\\": \\"myusername\\", \\"name\\": \\"My Name\\", \\"state\\": \\"active\\", \\"avatar_url\\": \\"https://secure.gravatar.com/avatar/8306d9f17d1c91970c2447b61c7a9f29?s=80&d=identicon\\", \\"web_url\\": \\"https://mygitlab.example.com/myusername\\", \\"created_at\\": \\"2023-03-29T14:30:13.371Z\\", \\"bio\\": \\"\\", \\"location\\": null, \\"public_email\\": null, \\"skype\\": \\"\\", \\"linkedin\\": \\"\\", \\"twitter\\": \\"\\", \\"website_url\\": \\"\\", \\"organization\\": null, \\"job_title\\": \\"\\", \\"pronouns\\": null, \\"bot\\": false, \\"work_information\\": null, \\"followers\\": 0, \\"following\\": 0, \\"is_followed\\": false, \\"local_time\\": null}"]}'
reply: 'HTTP/1.1 404 Not Found\r\n'
```

## Specifications

  - python-gitlab version: 3.14.0
  - API version you are using (v3/v4): v4
  - Gitlab server version (or gitlab.com): 15.7.2-ee



Discussion:
Looks like we have some old code here:

https://github.com/python-gitlab/python-gitlab/blame/eb54adf2fe7d3c68dcb6021065e51ba33b7bbc04/gitlab/v4/objects/merge_request_approvals.py#L155

We might be able to just get rid of that custom save method, things might be more consistent these days. I don't use Premium features personally, so this needs some digging on why we even have that in the code.

Resolving patch:
diff --git a/gitlab/v4/objects/merge_request_approvals.py b/gitlab/v4/objects/merge_request_approvals.py
--- a/gitlab/v4/objects/merge_request_approvals.py
+++ b/gitlab/v4/objects/merge_request_approvals.py
@@ -1,4 +1,4 @@
-from typing import Any, cast, Dict, List, Optional, TYPE_CHECKING, Union
+from typing import Any, cast, List, Optional, TYPE_CHECKING, Union
 
 from gitlab import exceptions as exc
 from gitlab.base import RESTManager, RESTObject
@@ -132,42 +132,16 @@ def set_approvers(
 
 class ProjectMergeRequestApprovalRule(SaveMixin, ObjectDeleteMixin, RESTObject):
     _repr_attr = "name"
-    id: int
-    approval_rule_id: int
-    merge_request_iid: int
-
-    @exc.on_http_error(exc.GitlabUpdateError)
-    def save(self, **kwargs: Any) -> None:
-        """Save the changes made to the object to the server.
-
-        The object is updated to match what the server returns.
-
-        Args:
-            **kwargs: Extra options to send to the server (e.g. sudo)
-
-        Raise:
-            GitlabAuthenticationError: If authentication is not correct
-            GitlabUpdateError: If the server cannot perform the request
-        """
-        # There is a mismatch between the name of our id attribute and the put
-        # REST API name for the project_id, so we override it here.
-        self.approval_rule_id = self.id
-        self.merge_request_iid = self._parent_attrs["mr_iid"]
-        self.id = self._parent_attrs["project_id"]
-        # save will update self.id with the result from the server, so no need
-        # to overwrite with what it was before we overwrote it.
-        SaveMixin.save(self, **kwargs)
 
 
 class ProjectMergeRequestApprovalRuleManager(CRUDMixin, RESTManager):
-    _path = "/projects/{project_id}/merge_requests/{mr_iid}/approval_rules"
+    _path = "/projects/{project_id}/merge_requests/{merge_request_iid}/approval_rules"
     _obj_cls = ProjectMergeRequestApprovalRule
-    _from_parent_attrs = {"project_id": "project_id", "mr_iid": "iid"}
+    _from_parent_attrs = {"project_id": "project_id", "merge_request_iid": "iid"}
     _update_attrs = RequiredOptional(
         required=(
             "id",
             "merge_request_iid",
-            "approval_rule_id",
             "name",
             "approvals_required",
         ),
@@ -177,7 +151,7 @@ class ProjectMergeRequestApprovalRuleManager(CRUDMixin, RESTManager):
     # groups of project-level rule will be copied. The approvals_required
     # specified will be used.
     _create_attrs = RequiredOptional(
-        required=("id", "merge_request_iid", "name", "approvals_required"),
+        required=("name", "approvals_required"),
         optional=("approval_project_rule_id", "user_ids", "group_ids"),
     )
 
@@ -188,32 +162,6 @@ def get(
             ProjectMergeRequestApprovalRule, super().get(id=id, lazy=lazy, **kwargs)
         )
 
-    def create(
-        self, data: Optional[Dict[str, Any]] = None, **kwargs: Any
-    ) -> RESTObject:
-        """Create a new object.
-
-        Args:
-            data: Parameters to send to the server to create the
-                         resource
-            **kwargs: Extra options to send to the server (e.g. sudo or
-                      'ref_name', 'stage', 'name', 'all')
-
-        Raises:
-            GitlabAuthenticationError: If authentication is not correct
-            GitlabCreateError: If the server cannot perform the request
-
-        Returns:
-            A new instance of the manage object class build with
-                the data sent by the server
-        """
-        if TYPE_CHECKING:
-            assert data is not None
-        new_data = data.copy()
-        new_data["id"] = self._from_parent_attrs["project_id"]
-        new_data["merge_request_iid"] = self._from_parent_attrs["mr_iid"]
-        return CreateMixin.create(self, new_data, **kwargs)
-
 
 class ProjectMergeRequestApprovalState(RESTObject):
     pass


Test patch:
diff --git a/tests/unit/objects/test_project_merge_request_approvals.py b/tests/unit/objects/test_project_merge_request_approvals.py
--- a/tests/unit/objects/test_project_merge_request_approvals.py
+++ b/tests/unit/objects/test_project_merge_request_approvals.py
@@ -10,7 +10,7 @@
 import gitlab
 from gitlab.mixins import UpdateMethod
 
-approval_rule_id = 1
+approval_rule_id = 7
 approval_rule_name = "security"
 approvals_required = 3
 user_ids = [5, 50]
@@ -96,21 +96,21 @@ def resp_mr_approval_rules():
     with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
         rsps.add(
             method=responses.GET,
-            url="http://localhost/api/v4/projects/1/merge_requests/1/approval_rules",
+            url="http://localhost/api/v4/projects/1/merge_requests/3/approval_rules",
             json=mr_ars_content,
             content_type="application/json",
             status=200,
         )
         rsps.add(
             method=responses.GET,
-            url="http://localhost/api/v4/projects/1/merge_requests/1/approval_rules/1",
+            url="http://localhost/api/v4/projects/1/merge_requests/3/approval_rules/7",
             json=mr_ars_content[0],
             content_type="application/json",
             status=200,
         )
         rsps.add(
             method=responses.GET,
-            url="http://localhost/api/v4/projects/1/merge_requests/1/approval_state",
+            url="http://localhost/api/v4/projects/1/merge_requests/3/approval_state",
             json=mr_approval_state_content,
             content_type="application/json",
             status=200,
@@ -122,7 +122,7 @@ def resp_mr_approval_rules():
 
         rsps.add(
             method=responses.POST,
-            url="http://localhost/api/v4/projects/1/merge_requests/1/approval_rules",
+            url="http://localhost/api/v4/projects/1/merge_requests/3/approval_rules",
             json=new_mr_ars_content,
             content_type="application/json",
             status=200,
@@ -139,7 +139,7 @@ def resp_mr_approval_rules():
 
         rsps.add(
             method=responses.PUT,
-            url="http://localhost/api/v4/projects/1/merge_requests/1/approval_rules/1",
+            url="http://localhost/api/v4/projects/1/merge_requests/3/approval_rules/7",
             json=updated_mr_ars_content,
             content_type="application/json",
             status=200,
@@ -152,7 +152,7 @@ def resp_delete_mr_approval_rule():
     with responses.RequestsMock() as rsps:
         rsps.add(
             method=responses.DELETE,
-            url="http://localhost/api/v4/projects/1/merge_requests/1/approval_rules/1",
+            url="http://localhost/api/v4/projects/1/merge_requests/3/approval_rules/7",
             status=204,
         )
         yield rsps
@@ -170,7 +170,7 @@ def test_project_approval_manager_update_method_post(project):
 
 
 def test_list_merge_request_approval_rules(project, resp_mr_approval_rules):
-    approval_rules = project.mergerequests.get(1, lazy=True).approval_rules.list()
+    approval_rules = project.mergerequests.get(3, lazy=True).approval_rules.list()
     assert len(approval_rules) == 1
     assert approval_rules[0].name == approval_rule_name
     assert approval_rules[0].id == approval_rule_id
@@ -178,12 +178,12 @@ def test_list_merge_request_approval_rules(project, resp_mr_approval_rules):
 
 
 def test_delete_merge_request_approval_rule(project, resp_delete_mr_approval_rule):
-    merge_request = project.mergerequests.get(1, lazy=True)
+    merge_request = project.mergerequests.get(3, lazy=True)
     merge_request.approval_rules.delete(approval_rule_id)
 
 
 def test_update_merge_request_approvals_set_approvers(project, resp_mr_approval_rules):
-    approvals = project.mergerequests.get(1, lazy=True).approvals
+    approvals = project.mergerequests.get(3, lazy=True).approvals
     assert isinstance(
         approvals,
         gitlab.v4.objects.merge_request_approvals.ProjectMergeRequestApprovalManager,
@@ -203,7 +203,7 @@ def test_update_merge_request_approvals_set_approvers(project, resp_mr_approval_
 
 
 def test_create_merge_request_approvals_set_approvers(project, resp_mr_approval_rules):
-    approvals = project.mergerequests.get(1, lazy=True).approvals
+    approvals = project.mergerequests.get(3, lazy=True).approvals
     assert isinstance(
         approvals,
         gitlab.v4.objects.merge_request_approvals.ProjectMergeRequestApprovalManager,
@@ -222,7 +222,7 @@ def test_create_merge_request_approvals_set_approvers(project, resp_mr_approval_
 
 
 def test_create_merge_request_approval_rule(project, resp_mr_approval_rules):
-    approval_rules = project.mergerequests.get(1, lazy=True).approval_rules
+    approval_rules = project.mergerequests.get(3, lazy=True).approval_rules
     data = {
         "name": new_approval_rule_name,
         "approvals_required": new_approval_rule_approvals_required,
@@ -238,7 +238,7 @@ def test_create_merge_request_approval_rule(project, resp_mr_approval_rules):
 
 
 def test_update_merge_request_approval_rule(project, resp_mr_approval_rules):
-    approval_rules = project.mergerequests.get(1, lazy=True).approval_rules
+    approval_rules = project.mergerequests.get(3, lazy=True).approval_rules
     ar_1 = approval_rules.list()[0]
     ar_1.user_ids = updated_approval_rule_user_ids
     ar_1.approvals_required = updated_approval_rule_approvals_required
@@ -250,7 +250,7 @@ def test_update_merge_request_approval_rule(project, resp_mr_approval_rules):
 
 
 def test_get_merge_request_approval_rule(project, resp_mr_approval_rules):
-    merge_request = project.mergerequests.get(1, lazy=True)
+    merge_request = project.mergerequests.get(3, lazy=True)
     approval_rule = merge_request.approval_rules.get(approval_rule_id)
     assert isinstance(
         approval_rule,
@@ -261,7 +261,7 @@ def test_get_merge_request_approval_rule(project, resp_mr_approval_rules):
 
 
 def test_get_merge_request_approval_state(project, resp_mr_approval_rules):
-    merge_request = project.mergerequests.get(1, lazy=True)
+    merge_request = project.mergerequests.get(3, lazy=True)
     approval_state = merge_request.approval_state.get()
     assert isinstance(
         approval_state,