diff --git a/docs/gl_objects/mr_approvals.rst b/docs/gl_objects/mr_approvals.rst index 253b68db3..ee0377d38 100644 --- a/docs/gl_objects/mr_approvals.rst +++ b/docs/gl_objects/mr_approvals.rst @@ -18,6 +18,9 @@ References + :class:`gitlab.v4.objects.ProjectMergeRequestApproval` + :class:`gitlab.v4.objects.ProjectMergeRequestApprovalManager` + :attr:`gitlab.v4.objects.ProjectMergeRequest.approvals` + + :class:`gitlab.v4.objects.ProjectMergeRequestApprovalRule` + + :class:`gitlab.v4.objects.ProjectMergeRequestApprovalRuleManager` + + :attr:`gitlab.v4.objects.ProjectMergeRequest.approval_rules` * GitLab API: https://docs.gitlab.com/ee/api/merge_request_approvals.html @@ -50,10 +53,33 @@ Change project-level or MR-level MR approvals settings:: mr_mras.set_approvers(approvals_required = 1) -Change project-level or MR-level MR allowed approvers:: +Change project-level MR allowed approvers:: project.approvals.set_approvers(approver_ids=[105], - approver_group_ids=[653, 654]) + approver_group_ids=[653, 654]) + +Create a new MR-level approval rule or change an existing MR-level approval rule:: mr.approvals.set_approvers(approvals_required = 1, approver_ids=[105], - approver_group_ids=[653, 654]) + approver_group_ids=[653, 654], + approval_rule_name="my MR custom approval rule") + +List MR-level MR approval rules:: + + mr.approval_rules.list() + +Change MR-level MR approval rule:: + + mr_approvalrule.user_ids = [105] + mr_approvalrule.approvals_required = 2 + mr_approvalrule.group_ids = [653, 654] + mr_approvalrule.save() + +Create a MR-level MR approval rule:: + mr.approval_rules.create({ + "name": "my MR custom approval rule", + "approvals_required": 2, + "rule_type": "regular", + "user_ids": [105], + "group_ids": [653, 654], + }) diff --git a/gitlab/tests/objects/test_project_merge_request_approvals.py b/gitlab/tests/objects/test_project_merge_request_approvals.py new file mode 100644 index 000000000..5e9244f9c --- /dev/null +++ b/gitlab/tests/objects/test_project_merge_request_approvals.py @@ -0,0 +1,294 @@ +""" +Gitlab API: https://docs.gitlab.com/ee/api/merge_request_approvals.html +""" + +import pytest +import responses +import copy + + +approval_rule_id = 1 +approval_rule_name = "security" +approvals_required = 3 +user_ids = [5, 50] +group_ids = [5] + +new_approval_rule_name = "new approval rule" +new_approval_rule_user_ids = user_ids +new_approval_rule_approvals_required = 2 + +updated_approval_rule_user_ids = [5] +updated_approval_rule_approvals_required = 1 + + +@pytest.fixture +def resp_snippet(): + merge_request_content = [ + { + "id": 1, + "iid": 1, + "project_id": 1, + "title": "test1", + "description": "fixed login page css paddings", + "state": "merged", + "merged_by": { + "id": 87854, + "name": "Douwe Maan", + "username": "DouweM", + "state": "active", + "avatar_url": "https://gitlab.example.com/uploads/-/system/user/avatar/87854/avatar.png", + "web_url": "https://gitlab.com/DouweM", + }, + "merged_at": "2018-09-07T11:16:17.520Z", + "closed_by": None, + "closed_at": None, + "created_at": "2017-04-29T08:46:00Z", + "updated_at": "2017-04-29T08:46:00Z", + "target_branch": "master", + "source_branch": "test1", + "upvotes": 0, + "downvotes": 0, + "author": { + "id": 1, + "name": "Administrator", + "username": "admin", + "state": "active", + "avatar_url": None, + "web_url": "https://gitlab.example.com/admin", + }, + "assignee": { + "id": 1, + "name": "Administrator", + "username": "admin", + "state": "active", + "avatar_url": None, + "web_url": "https://gitlab.example.com/admin", + }, + "assignees": [ + { + "name": "Miss Monserrate Beier", + "username": "axel.block", + "id": 12, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon", + "web_url": "https://gitlab.example.com/axel.block", + } + ], + "source_project_id": 2, + "target_project_id": 3, + "labels": ["Community contribution", "Manage"], + "work_in_progress": None, + "milestone": { + "id": 5, + "iid": 1, + "project_id": 3, + "title": "v2.0", + "description": "Assumenda aut placeat expedita exercitationem labore sunt enim earum.", + "state": "closed", + "created_at": "2015-02-02T19:49:26.013Z", + "updated_at": "2015-02-02T19:49:26.013Z", + "due_date": "2018-09-22", + "start_date": "2018-08-08", + "web_url": "https://gitlab.example.com/my-group/my-project/milestones/1", + }, + "merge_when_pipeline_succeeds": None, + "merge_status": "can_be_merged", + "sha": "8888888888888888888888888888888888888888", + "merge_commit_sha": None, + "squash_commit_sha": None, + "user_notes_count": 1, + "discussion_locked": None, + "should_remove_source_branch": True, + "force_remove_source_branch": False, + "allow_collaboration": False, + "allow_maintainer_to_push": False, + "web_url": "http://gitlab.example.com/my-group/my-project/merge_requests/1", + "references": { + "short": "!1", + "relative": "my-group/my-project!1", + "full": "my-group/my-project!1", + }, + "time_stats": { + "time_estimate": 0, + "total_time_spent": 0, + "human_time_estimate": None, + "human_total_time_spent": None, + }, + "squash": False, + "task_completion_status": {"count": 0, "completed_count": 0}, + } + ] + mr_ars_content = [ + { + "id": approval_rule_id, + "name": approval_rule_name, + "rule_type": "regular", + "eligible_approvers": [ + { + "id": user_ids[0], + "name": "John Doe", + "username": "jdoe", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/0?s=80&d=identicon", + "web_url": "http://localhost/jdoe", + }, + { + "id": user_ids[1], + "name": "Group Member 1", + "username": "group_member_1", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/0?s=80&d=identicon", + "web_url": "http://localhost/group_member_1", + }, + ], + "approvals_required": approvals_required, + "source_rule": None, + "users": [ + { + "id": 5, + "name": "John Doe", + "username": "jdoe", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/0?s=80&d=identicon", + "web_url": "http://localhost/jdoe", + } + ], + "groups": [ + { + "id": 5, + "name": "group1", + "path": "group1", + "description": "", + "visibility": "public", + "lfs_enabled": False, + "avatar_url": None, + "web_url": "http://localhost/groups/group1", + "request_access_enabled": False, + "full_name": "group1", + "full_path": "group1", + "parent_id": None, + "ldap_cn": None, + "ldap_access": None, + } + ], + "contains_hidden_groups": False, + "overridden": False, + } + ] + + 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", + json=merge_request_content, + content_type="application/json", + status=200, + ) + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/projects/1/merge_requests/1", + json=merge_request_content[0], + content_type="application/json", + status=200, + ) + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/projects/1/merge_requests/1/approval_rules", + json=mr_ars_content, + content_type="application/json", + status=200, + ) + + new_mr_ars_content = dict(mr_ars_content[0]) + new_mr_ars_content["name"] = new_approval_rule_name + new_mr_ars_content["approvals_required"] = new_approval_rule_approvals_required + + rsps.add( + method=responses.POST, + url="http://localhost/api/v4/projects/1/merge_requests/1/approval_rules", + json=new_mr_ars_content, + content_type="application/json", + status=200, + ) + + updated_mr_ars_content = copy.deepcopy(mr_ars_content[0]) + updated_mr_ars_content["eligible_approvers"] = [ + mr_ars_content[0]["eligible_approvers"][0] + ] + + updated_mr_ars_content[ + "approvals_required" + ] = updated_approval_rule_approvals_required + + rsps.add( + method=responses.PUT, + url="http://localhost/api/v4/projects/1/merge_requests/1/approval_rules/1", + json=updated_mr_ars_content, + content_type="application/json", + status=200, + ) + yield rsps + + +def test_list_merge_request_approval_rules(project, resp_snippet): + approval_rules = project.mergerequests.get(1).approval_rules.list() + assert len(approval_rules) == 1 + assert approval_rules[0].name == approval_rule_name + assert approval_rules[0].id == approval_rule_id + + +def test_update_merge_request_approvals_set_approvers(project, resp_snippet): + approvals = project.mergerequests.get(1).approvals + response = approvals.set_approvers( + updated_approval_rule_approvals_required, + approver_ids=updated_approval_rule_user_ids, + approver_group_ids=group_ids, + approval_rule_name=approval_rule_name, + ) + + assert response.approvals_required == updated_approval_rule_approvals_required + assert len(response.eligible_approvers) == len(updated_approval_rule_user_ids) + assert response.eligible_approvers[0]["id"] == updated_approval_rule_user_ids[0] + assert response.name == approval_rule_name + + +def test_create_merge_request_approvals_set_approvers(project, resp_snippet): + approvals = project.mergerequests.get(1).approvals + response = approvals.set_approvers( + new_approval_rule_approvals_required, + approver_ids=new_approval_rule_user_ids, + approver_group_ids=group_ids, + approval_rule_name=new_approval_rule_name, + ) + assert response.approvals_required == new_approval_rule_approvals_required + assert len(response.eligible_approvers) == len(new_approval_rule_user_ids) + assert response.eligible_approvers[0]["id"] == new_approval_rule_user_ids[0] + assert response.name == new_approval_rule_name + + +def test_create_merge_request_approval_rule(project, resp_snippet): + approval_rules = project.mergerequests.get(1).approval_rules + data = { + "name": new_approval_rule_name, + "approvals_required": new_approval_rule_approvals_required, + "rule_type": "regular", + "user_ids": new_approval_rule_user_ids, + "group_ids": group_ids, + } + response = approval_rules.create(data) + assert response.approvals_required == new_approval_rule_approvals_required + assert len(response.eligible_approvers) == len(new_approval_rule_user_ids) + assert response.eligible_approvers[0]["id"] == new_approval_rule_user_ids[0] + assert response.name == new_approval_rule_name + + +def test_update_merge_request_approval_rule(project, resp_snippet): + approval_rules = project.mergerequests.get(1).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 + ar_1.save() + + assert ar_1.approvals_required == updated_approval_rule_approvals_required + assert len(ar_1.eligible_approvers) == len(updated_approval_rule_user_ids) + assert ar_1.eligible_approvers[0]["id"] == updated_approval_rule_user_ids[0] diff --git a/gitlab/v4/objects/__init__.py b/gitlab/v4/objects/__init__.py index 016caece9..80b3c2122 100644 --- a/gitlab/v4/objects/__init__.py +++ b/gitlab/v4/objects/__init__.py @@ -2996,13 +2996,18 @@ class ProjectMergeRequestApprovalManager(GetWithoutIdMixin, UpdateMixin, RESTMan @exc.on_http_error(exc.GitlabUpdateError) def set_approvers( - self, approvals_required, approver_ids=None, approver_group_ids=None, **kwargs + self, + approvals_required, + approver_ids=None, + approver_group_ids=None, + approval_rule_name="name", + **kwargs ): """Change MR-level allowed approvers and approver groups. Args: approvals_required (integer): The number of required approvals for this rule - approver_ids (list): User IDs that can approve MRs + approver_ids (list of integers): User IDs that can approve MRs approver_group_ids (list): Group IDs whose members can approve MRs Raises: @@ -3012,18 +3017,93 @@ def set_approvers( approver_ids = approver_ids or [] approver_group_ids = approver_group_ids or [] - path = "%s/%s/approval_rules" % ( - self._parent.manager.path, - self._parent.get_id(), - ) data = { - "name": "name", + "name": approval_rule_name, "approvals_required": approvals_required, "rule_type": "regular", "user_ids": approver_ids, "group_ids": approver_group_ids, } - self.gitlab.http_post(path, post_data=data, **kwargs) + approval_rules = self._parent.approval_rules + """ update any existing approval rule matching the name""" + existing_approval_rules = approval_rules.list() + for ar in existing_approval_rules: + if ar.name == approval_rule_name: + ar.user_ids = data["user_ids"] + ar.approvals_required = data["approvals_required"] + ar.group_ids = data["group_ids"] + ar.save() + return ar + """ if there was no rule matching the rule name, create a new one""" + return approval_rules.create(data=data) + + +class ProjectMergeRequestApprovalRule(SaveMixin, RESTObject): + _id_attr = "approval_rule_id" + _short_print_attr = "approval_rule" + + @exc.on_http_error(exc.GitlabUpdateError) + def save(self, **kwargs): + """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( + ListMixin, UpdateMixin, CreateMixin, RESTManager +): + _path = "/projects/%(project_id)s/merge_requests/%(mr_iid)s/approval_rules" + _obj_cls = ProjectMergeRequestApprovalRule + _from_parent_attrs = {"project_id": "project_id", "mr_iid": "iid"} + _list_filters = ("name", "rule_type") + _update_attrs = ( + ("id", "merge_request_iid", "approval_rule_id", "name", "approvals_required"), + ("user_ids", "group_ids"), + ) + # Important: When approval_project_rule_id is set, the name, users and groups of + # project-level rule will be copied. The approvals_required specified will be used. """ + _create_attrs = ( + ("id", "merge_request_iid", "name", "approvals_required"), + ("approval_project_rule_id", "user_ids", "group_ids"), + ) + + def create(self, data, **kwargs): + """Create a new object. + + Args: + data (dict): 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: + RESTObject: A new instance of the manage object class build with + the data sent by the server + """ + 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 ProjectMergeRequestAwardEmoji(ObjectDeleteMixin, RESTObject): @@ -3149,6 +3229,7 @@ class ProjectMergeRequest( _managers = ( ("approvals", "ProjectMergeRequestApprovalManager"), + ("approval_rules", "ProjectMergeRequestApprovalRuleManager"), ("awardemojis", "ProjectMergeRequestAwardEmojiManager"), ("diffs", "ProjectMergeRequestDiffManager"), ("discussions", "ProjectMergeRequestDiscussionManager"),