Skip to content

feat(api): add support for the Draft notes API #2728

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Nov 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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