Skip to content

Commit

Permalink
feat(api): add support for the Draft notes API (#2728)
Browse files Browse the repository at this point in the history
* feat(api): add support for the Draft notes API

* fix(client): handle empty 204 reponses in PUT requests
  • Loading branch information
nejch committed Nov 28, 2023
1 parent 428b4fd commit ebf9d82
Show file tree
Hide file tree
Showing 8 changed files with 297 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/api-objects.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions docs/gl_objects/draft_notes.rst
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 2 additions & 0 deletions gitlab/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions gitlab/v4/objects/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand Down
43 changes: 43 additions & 0 deletions gitlab/v4/objects/draft_notes.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions gitlab/v4/objects/merge_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -157,6 +158,7 @@ class ProjectMergeRequest(
awardemojis: ProjectMergeRequestAwardEmojiManager
diffs: "ProjectMergeRequestDiffManager"
discussions: ProjectMergeRequestDiscussionManager
draft_notes: ProjectMergeRequestDraftNoteManager
notes: ProjectMergeRequestNoteManager
pipelines: ProjectMergeRequestPipelineManager
resourcelabelevents: ProjectMergeRequestResourceLabelEventManager
Expand Down
175 changes: 175 additions & 0 deletions tests/unit/objects/test_draft_notes.py
Original file line number Diff line number Diff line change
@@ -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()
15 changes: 15 additions & 0 deletions tests/unit/test_gitlab_http_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit ebf9d82

Please sign in to comment.