diff --git a/docs/gl_objects/merge_requests.rst b/docs/gl_objects/merge_requests.rst index 95364073d..8264669e6 100644 --- a/docs/gl_objects/merge_requests.rst +++ b/docs/gl_objects/merge_requests.rst @@ -144,6 +144,10 @@ List the changes of a MR:: changes = mr.changes() +List issues related to this merge request:: + + related_issues = mr.related_issues() + List issues that will close on merge:: mr.closes_issues() diff --git a/gitlab/v4/objects/merge_requests.py b/gitlab/v4/objects/merge_requests.py index fb25ab5ae..e29ab2b28 100644 --- a/gitlab/v4/objects/merge_requests.py +++ b/gitlab/v4/objects/merge_requests.py @@ -197,6 +197,35 @@ def cancel_merge_when_pipeline_succeeds(self, **kwargs: Any) -> Dict[str, str]: assert isinstance(server_data, dict) return server_data + @cli.register_custom_action(cls_names="ProjectMergeRequest") + @exc.on_http_error(exc.GitlabListError) + def related_issues(self, **kwargs: Any) -> RESTObjectList: + """List issues related to this merge request." + + Args: + all: If True, return all the items, without pagination + per_page: Number of items to retrieve per request + page: ID of the page to return (starts with page 1) + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabListError: If the list could not be retrieved + + Returns: + List of issues + """ + + path = f"{self.manager.path}/{self.encoded_id}/related_issues" + data_list = self.manager.gitlab.http_list(path, iterator=True, **kwargs) + + if TYPE_CHECKING: + assert isinstance(data_list, gitlab.GitlabList) + + manager = ProjectIssueManager(self.manager.gitlab, parent=self.manager._parent) + + return RESTObjectList(manager, ProjectIssue, data_list) + @cli.register_custom_action(cls_names="ProjectMergeRequest") @exc.on_http_error(exc.GitlabListError) def closes_issues(self, **kwargs: Any) -> RESTObjectList: diff --git a/tests/unit/objects/test_merge_requests.py b/tests/unit/objects/test_merge_requests.py index 6f8a6a7de..400b24b34 100644 --- a/tests/unit/objects/test_merge_requests.py +++ b/tests/unit/objects/test_merge_requests.py @@ -9,8 +9,10 @@ import pytest import responses +from gitlab.base import RESTObjectList from gitlab.v4.objects import ( ProjectDeploymentMergeRequest, + ProjectIssue, ProjectMergeRequest, ProjectMergeRequestReviewerDetail, ) @@ -57,6 +59,78 @@ } ] +related_issues = [ + { + "id": 1, + "iid": 1, + "project_id": 1, + "title": "Fake Title for Merge Requests via API", + "description": "Something here", + "state": "closed", + "created_at": "2024-05-14T04:01:40.042Z", + "updated_at": "2024-06-13T05:29:13.661Z", + "closed_at": "2024-06-13T05:29:13.602Z", + "closed_by": { + "id": 2, + "name": "Sam Bauch", + "username": "kenyatta_oconnell", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/956c92487c6f6f7616b536927e22c9a0?s=80&d=identicon", + "web_url": "http://gitlab.example.com/kenyatta_oconnell", + }, + "labels": [ + "FakeCategory", + "fake:ml", + ], + "assignees": [ + { + "id": 2, + "name": "Sam Bauch", + "username": "kenyatta_oconnell", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/956c92487c6f6f7616b536927e22c9a0?s=80&d=identicon", + "web_url": "http://gitlab.example.com/kenyatta_oconnell", + } + ], + "author": { + "id": 2, + "name": "Sam Bauch", + "username": "kenyatta_oconnell", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/956c92487c6f6f7616b536927e22c9a0?s=80&d=identicon", + "web_url": "http://gitlab.example.com//kenyatta_oconnell", + }, + "type": "ISSUE", + "assignee": { + "id": 4459593, + "username": "fakeuser", + "name": "Fake User", + "state": "active", + "locked": False, + "avatar_url": "https://example.com/uploads/-/system/user/avatar/4459593/avatar.png", + "web_url": "https://example.com/fakeuser", + }, + "user_notes_count": 9, + "merge_requests_count": 0, + "upvotes": 1, + "downvotes": 0, + "due_date": None, + "confidential": False, + "discussion_locked": None, + "issue_type": "issue", + "web_url": "https://example.com/fakeorg/fakeproject/-/issues/461536", + "time_stats": { + "time_estimate": 0, + "total_time_spent": 0, + "human_time_estimate": None, + "human_total_time_spent": None, + }, + "task_completion_status": {"count": 0, "completed_count": 0}, + "weight": None, + "blocking_issues_count": 0, + } +] + @pytest.fixture def resp_list_merge_requests(): @@ -93,6 +167,26 @@ def resp_get_merge_request_reviewers(): yield rsps +@pytest.fixture +def resp_list_merge_requests_related_issues(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/projects/1/merge_requests/1", + json=mr_content, + content_type="application/json", + status=200, + ) + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/projects/1/merge_requests/1/related_issues", + json=related_issues, + content_type="application/json", + status=200, + ) + yield rsps + + def test_list_project_merge_requests(project, resp_list_merge_requests): mrs = project.mergerequests.list() assert isinstance(mrs[0], ProjectMergeRequest) @@ -115,3 +209,13 @@ def test_get_merge_request_reviewers(project, resp_get_merge_request_reviewers): assert mr.reviewers[0]["name"] == reviewers_details[0].user["name"] assert reviewers_details[0].state == "unreviewed" assert reviewers_details[0].created_at == "2022-07-27T17:03:27.684Z" + + +def test_list_related_issues(project, resp_list_merge_requests_related_issues): + mr = project.mergerequests.get(1) + this_mr_related_issues = mr.related_issues() + the_issue = next(iter(this_mr_related_issues)) + assert isinstance(mr, ProjectMergeRequest) + assert isinstance(this_mr_related_issues, RESTObjectList) + assert isinstance(the_issue, ProjectIssue) + assert the_issue.title == related_issues[0]["title"]