From e71be4e001f4d6646964d6cab2120150f558b209 Mon Sep 17 00:00:00 2001 From: Nejc Habjan Date: Wed, 22 Nov 2023 15:46:54 +0100 Subject: [PATCH 1/2] feat(api): add support for the Draft notes API --- docs/api-objects.rst | 1 + docs/gl_objects/draft_notes.rst | 58 ++++++++ gitlab/v4/objects/__init__.py | 1 + gitlab/v4/objects/draft_notes.py | 43 ++++++ gitlab/v4/objects/merge_requests.py | 2 + tests/unit/objects/test_draft_notes.py | 175 +++++++++++++++++++++++++ 6 files changed, 280 insertions(+) create mode 100644 docs/gl_objects/draft_notes.rst create mode 100644 gitlab/v4/objects/draft_notes.py create mode 100644 tests/unit/objects/test_draft_notes.py diff --git a/docs/api-objects.rst b/docs/api-objects.rst index 6dce02d25..f00ebfe11 100644 --- a/docs/api-objects.rst +++ b/docs/api-objects.rst @@ -19,6 +19,7 @@ API examples gl_objects/deploy_tokens gl_objects/deployments gl_objects/discussions + gl_objects/draft_notes gl_objects/environments gl_objects/events gl_objects/epics diff --git a/docs/gl_objects/draft_notes.rst b/docs/gl_objects/draft_notes.rst new file mode 100644 index 000000000..d56ededde --- /dev/null +++ b/docs/gl_objects/draft_notes.rst @@ -0,0 +1,58 @@ +.. _draft-notes: + +########### +Draft Notes +########### + +Draft notes are pending, unpublished comments on merge requests. +They can be either start a discussion, or be associated with an existing discussion as a reply. +They are viewable only by the author until they are published. + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectMergeRequestDraftNote` + + :class:`gitlab.v4.objects.ProjectMergeRequestDraftNoteManager` + + :attr:`gitlab.v4.objects.ProjectMergeRequest.draft_notes` + + +* GitLab API: https://docs.gitlab.com/ee/api/draft_notes.html + +Examples +-------- + +List all draft notes for a merge request:: + + draft_notes = merge_request.draft_notes.list() + +Get a draft note for a merge request by ID:: + + draft_note = merge_request.draft_notes.get(note_id) + +.. warning:: + + When creating or updating draft notes, you can provide a complex nested ``position`` argument as a dictionary. + Please consult the upstream API documentation linked above for the exact up-to-date attributes. + +Create a draft note for a merge request:: + + draft_note = merge_request.draft_notes.create({'note': 'note content'}) + +Update an existing draft note:: + + draft_note.note = 'updated note content' + draft_note.save() + +Delete an existing draft note:: + + draft_note.delete() + +Publish an existing draft note:: + + draft_note.publish() + +Publish all existing draft notes for a merge request in bulk:: + + merge_request.draft_notes.bulk_publish() diff --git a/gitlab/v4/objects/__init__.py b/gitlab/v4/objects/__init__.py index 6534f6c25..be916c528 100644 --- a/gitlab/v4/objects/__init__.py +++ b/gitlab/v4/objects/__init__.py @@ -18,6 +18,7 @@ from .deploy_tokens import * from .deployments import * from .discussions import * +from .draft_notes import * from .environments import * from .epics import * from .events import * diff --git a/gitlab/v4/objects/draft_notes.py b/gitlab/v4/objects/draft_notes.py new file mode 100644 index 000000000..8d7f68902 --- /dev/null +++ b/gitlab/v4/objects/draft_notes.py @@ -0,0 +1,43 @@ +from typing import Any, cast, Union + +from gitlab.base import RESTManager, RESTObject +from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin +from gitlab.types import RequiredOptional + +__all__ = [ + "ProjectMergeRequestDraftNote", + "ProjectMergeRequestDraftNoteManager", +] + + +class ProjectMergeRequestDraftNote(ObjectDeleteMixin, SaveMixin, RESTObject): + def publish(self, **kwargs: Any) -> None: + path = f"{self.manager.path}/{self.encoded_id}/publish" + self.manager.gitlab.http_put(path, **kwargs) + + +class ProjectMergeRequestDraftNoteManager(CRUDMixin, RESTManager): + _path = "/projects/{project_id}/merge_requests/{mr_iid}/draft_notes" + _obj_cls = ProjectMergeRequestDraftNote + _from_parent_attrs = {"project_id": "project_id", "mr_iid": "iid"} + _create_attrs = RequiredOptional( + required=("note",), + optional=( + "commit_id", + "in_reply_to_discussion_id", + "position", + "resolve_discussion", + ), + ) + _update_attrs = RequiredOptional(optional=("position",)) + + def get( + self, id: Union[str, int], lazy: bool = False, **kwargs: Any + ) -> ProjectMergeRequestDraftNote: + return cast( + ProjectMergeRequestDraftNote, super().get(id=id, lazy=lazy, **kwargs) + ) + + def bulk_publish(self, **kwargs: Any) -> None: + path = f"{self.path}/bulk_publish" + self.gitlab.http_post(path, **kwargs) diff --git a/gitlab/v4/objects/merge_requests.py b/gitlab/v4/objects/merge_requests.py index d4c393322..e58d4c794 100644 --- a/gitlab/v4/objects/merge_requests.py +++ b/gitlab/v4/objects/merge_requests.py @@ -28,6 +28,7 @@ from .award_emojis import ProjectMergeRequestAwardEmojiManager # noqa: F401 from .commits import ProjectCommit, ProjectCommitManager from .discussions import ProjectMergeRequestDiscussionManager # noqa: F401 +from .draft_notes import ProjectMergeRequestDraftNoteManager from .events import ( # noqa: F401 ProjectMergeRequestResourceLabelEventManager, ProjectMergeRequestResourceMilestoneEventManager, @@ -157,6 +158,7 @@ class ProjectMergeRequest( awardemojis: ProjectMergeRequestAwardEmojiManager diffs: "ProjectMergeRequestDiffManager" discussions: ProjectMergeRequestDiscussionManager + draft_notes: ProjectMergeRequestDraftNoteManager notes: ProjectMergeRequestNoteManager pipelines: ProjectMergeRequestPipelineManager resourcelabelevents: ProjectMergeRequestResourceLabelEventManager diff --git a/tests/unit/objects/test_draft_notes.py b/tests/unit/objects/test_draft_notes.py new file mode 100644 index 000000000..d37cfc004 --- /dev/null +++ b/tests/unit/objects/test_draft_notes.py @@ -0,0 +1,175 @@ +""" +GitLab API: https://docs.gitlab.com/ee/api/draft_notes.html +""" +from copy import deepcopy + +import pytest +import responses + +from gitlab.v4.objects import ProjectMergeRequestDraftNote + +draft_note_content = { + "id": 1, + "author_id": 23, + "merge_request_id": 1, + "resolve_discussion": False, + "discussion_id": None, + "note": "Example title", + "commit_id": None, + "line_code": None, + "position": { + "base_sha": None, + "start_sha": None, + "head_sha": None, + "old_path": None, + "new_path": None, + "position_type": "text", + "old_line": None, + "new_line": None, + "line_range": None, + }, +} + + +@pytest.fixture() +def resp_list_merge_request_draft_notes(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes", + json=[draft_note_content], + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture() +def resp_get_merge_request_draft_note(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes/1", + json=draft_note_content, + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture() +def resp_create_merge_request_draft_note(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.POST, + url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes", + json=draft_note_content, + content_type="application/json", + status=201, + ) + yield rsps + + +@pytest.fixture() +def resp_update_merge_request_draft_note(): + updated_content = deepcopy(draft_note_content) + updated_content["note"] = "New title" + + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.PUT, + url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes/1", + json=updated_content, + content_type="application/json", + status=201, + ) + yield rsps + + +@pytest.fixture() +def resp_delete_merge_request_draft_note(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.DELETE, + url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes/1", + json=draft_note_content, + content_type="application/json", + status=201, + ) + yield rsps + + +@pytest.fixture() +def resp_publish_merge_request_draft_note(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.PUT, + url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes/1/publish", + status=204, + ) + yield rsps + + +@pytest.fixture() +def resp_bulk_publish_merge_request_draft_notes(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.POST, + url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes/bulk_publish", + status=204, + ) + yield rsps + + +def test_list_merge_requests_draft_notes( + project_merge_request, resp_list_merge_request_draft_notes +): + draft_notes = project_merge_request.draft_notes.list() + assert len(draft_notes) == 1 + assert isinstance(draft_notes[0], ProjectMergeRequestDraftNote) + assert draft_notes[0].note == draft_note_content["note"] + + +def test_get_merge_requests_draft_note( + project_merge_request, resp_get_merge_request_draft_note +): + draft_note = project_merge_request.draft_notes.get(1) + assert isinstance(draft_note, ProjectMergeRequestDraftNote) + assert draft_note.note == draft_note_content["note"] + + +def test_create_merge_requests_draft_note( + project_merge_request, resp_create_merge_request_draft_note +): + draft_note = project_merge_request.draft_notes.create({"note": "Example title"}) + assert isinstance(draft_note, ProjectMergeRequestDraftNote) + assert draft_note.note == draft_note_content["note"] + + +def test_update_merge_requests_draft_note( + project_merge_request, resp_update_merge_request_draft_note +): + draft_note = project_merge_request.draft_notes.get(1, lazy=True) + draft_note.note = "New title" + draft_note.save() + assert draft_note.note == "New title" + + +def test_delete_merge_requests_draft_note( + project_merge_request, resp_delete_merge_request_draft_note +): + draft_note = project_merge_request.draft_notes.get(1, lazy=True) + draft_note.delete() + + +def test_publish_merge_requests_draft_note( + project_merge_request, resp_publish_merge_request_draft_note +): + draft_note = project_merge_request.draft_notes.get(1, lazy=True) + draft_note.publish() + + +def test_bulk_publish_merge_requests_draft_notes( + project_merge_request, resp_bulk_publish_merge_request_draft_notes +): + project_merge_request.draft_notes.bulk_publish() From a4b28b0cea48e2915239ef5b43c449eb2ccc4597 Mon Sep 17 00:00:00 2001 From: Nejc Habjan Date: Wed, 22 Nov 2023 16:18:49 +0100 Subject: [PATCH 2/2] fix(client): handle empty 204 reponses in PUT requests --- gitlab/client.py | 2 ++ tests/unit/test_gitlab_http_methods.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/gitlab/client.py b/gitlab/client.py index e2586ad80..1f55dcfad 100644 --- a/gitlab/client.py +++ b/gitlab/client.py @@ -1061,6 +1061,8 @@ def http_put( raw=raw, **kwargs, ) + if result.status_code in gitlab.const.NO_JSON_RESPONSE_CODES: + return result try: json_result = result.json() if TYPE_CHECKING: diff --git a/tests/unit/test_gitlab_http_methods.py b/tests/unit/test_gitlab_http_methods.py index fe3c02e82..5df0a8a8a 100644 --- a/tests/unit/test_gitlab_http_methods.py +++ b/tests/unit/test_gitlab_http_methods.py @@ -766,6 +766,21 @@ def test_put_request_404(gl): assert responses.assert_call_count(url, 1) is True +@responses.activate +def test_put_request_204(gl): + url = "http://localhost/api/v4/projects" + responses.add( + method=responses.PUT, + url=url, + status=204, + match=helpers.MATCH_EMPTY_QUERY_PARAMS, + ) + + result = gl.http_put("/projects") + assert isinstance(result, requests.Response) + assert responses.assert_call_count(url, 1) is True + + @responses.activate def test_put_request_invalid_data(gl): url = "http://localhost/api/v4/projects"