Skip to content

Commit ebf9d82

Browse files
authored
feat(api): add support for the Draft notes API (#2728)
* feat(api): add support for the Draft notes API * fix(client): handle empty 204 reponses in PUT requests
1 parent 428b4fd commit ebf9d82

File tree

8 files changed

+297
-0
lines changed

8 files changed

+297
-0
lines changed

docs/api-objects.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ API examples
1919
gl_objects/deploy_tokens
2020
gl_objects/deployments
2121
gl_objects/discussions
22+
gl_objects/draft_notes
2223
gl_objects/environments
2324
gl_objects/events
2425
gl_objects/epics

docs/gl_objects/draft_notes.rst

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
.. _draft-notes:
2+
3+
###########
4+
Draft Notes
5+
###########
6+
7+
Draft notes are pending, unpublished comments on merge requests.
8+
They can be either start a discussion, or be associated with an existing discussion as a reply.
9+
They are viewable only by the author until they are published.
10+
11+
Reference
12+
---------
13+
14+
* v4 API:
15+
16+
+ :class:`gitlab.v4.objects.ProjectMergeRequestDraftNote`
17+
+ :class:`gitlab.v4.objects.ProjectMergeRequestDraftNoteManager`
18+
+ :attr:`gitlab.v4.objects.ProjectMergeRequest.draft_notes`
19+
20+
21+
* GitLab API: https://docs.gitlab.com/ee/api/draft_notes.html
22+
23+
Examples
24+
--------
25+
26+
List all draft notes for a merge request::
27+
28+
draft_notes = merge_request.draft_notes.list()
29+
30+
Get a draft note for a merge request by ID::
31+
32+
draft_note = merge_request.draft_notes.get(note_id)
33+
34+
.. warning::
35+
36+
When creating or updating draft notes, you can provide a complex nested ``position`` argument as a dictionary.
37+
Please consult the upstream API documentation linked above for the exact up-to-date attributes.
38+
39+
Create a draft note for a merge request::
40+
41+
draft_note = merge_request.draft_notes.create({'note': 'note content'})
42+
43+
Update an existing draft note::
44+
45+
draft_note.note = 'updated note content'
46+
draft_note.save()
47+
48+
Delete an existing draft note::
49+
50+
draft_note.delete()
51+
52+
Publish an existing draft note::
53+
54+
draft_note.publish()
55+
56+
Publish all existing draft notes for a merge request in bulk::
57+
58+
merge_request.draft_notes.bulk_publish()

gitlab/client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,6 +1061,8 @@ def http_put(
10611061
raw=raw,
10621062
**kwargs,
10631063
)
1064+
if result.status_code in gitlab.const.NO_JSON_RESPONSE_CODES:
1065+
return result
10641066
try:
10651067
json_result = result.json()
10661068
if TYPE_CHECKING:

gitlab/v4/objects/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from .deploy_tokens import *
1919
from .deployments import *
2020
from .discussions import *
21+
from .draft_notes import *
2122
from .environments import *
2223
from .epics import *
2324
from .events import *

gitlab/v4/objects/draft_notes.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from typing import Any, cast, Union
2+
3+
from gitlab.base import RESTManager, RESTObject
4+
from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin
5+
from gitlab.types import RequiredOptional
6+
7+
__all__ = [
8+
"ProjectMergeRequestDraftNote",
9+
"ProjectMergeRequestDraftNoteManager",
10+
]
11+
12+
13+
class ProjectMergeRequestDraftNote(ObjectDeleteMixin, SaveMixin, RESTObject):
14+
def publish(self, **kwargs: Any) -> None:
15+
path = f"{self.manager.path}/{self.encoded_id}/publish"
16+
self.manager.gitlab.http_put(path, **kwargs)
17+
18+
19+
class ProjectMergeRequestDraftNoteManager(CRUDMixin, RESTManager):
20+
_path = "/projects/{project_id}/merge_requests/{mr_iid}/draft_notes"
21+
_obj_cls = ProjectMergeRequestDraftNote
22+
_from_parent_attrs = {"project_id": "project_id", "mr_iid": "iid"}
23+
_create_attrs = RequiredOptional(
24+
required=("note",),
25+
optional=(
26+
"commit_id",
27+
"in_reply_to_discussion_id",
28+
"position",
29+
"resolve_discussion",
30+
),
31+
)
32+
_update_attrs = RequiredOptional(optional=("position",))
33+
34+
def get(
35+
self, id: Union[str, int], lazy: bool = False, **kwargs: Any
36+
) -> ProjectMergeRequestDraftNote:
37+
return cast(
38+
ProjectMergeRequestDraftNote, super().get(id=id, lazy=lazy, **kwargs)
39+
)
40+
41+
def bulk_publish(self, **kwargs: Any) -> None:
42+
path = f"{self.path}/bulk_publish"
43+
self.gitlab.http_post(path, **kwargs)

gitlab/v4/objects/merge_requests.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from .award_emojis import ProjectMergeRequestAwardEmojiManager # noqa: F401
2929
from .commits import ProjectCommit, ProjectCommitManager
3030
from .discussions import ProjectMergeRequestDiscussionManager # noqa: F401
31+
from .draft_notes import ProjectMergeRequestDraftNoteManager
3132
from .events import ( # noqa: F401
3233
ProjectMergeRequestResourceLabelEventManager,
3334
ProjectMergeRequestResourceMilestoneEventManager,
@@ -157,6 +158,7 @@ class ProjectMergeRequest(
157158
awardemojis: ProjectMergeRequestAwardEmojiManager
158159
diffs: "ProjectMergeRequestDiffManager"
159160
discussions: ProjectMergeRequestDiscussionManager
161+
draft_notes: ProjectMergeRequestDraftNoteManager
160162
notes: ProjectMergeRequestNoteManager
161163
pipelines: ProjectMergeRequestPipelineManager
162164
resourcelabelevents: ProjectMergeRequestResourceLabelEventManager
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""
2+
GitLab API: https://docs.gitlab.com/ee/api/draft_notes.html
3+
"""
4+
from copy import deepcopy
5+
6+
import pytest
7+
import responses
8+
9+
from gitlab.v4.objects import ProjectMergeRequestDraftNote
10+
11+
draft_note_content = {
12+
"id": 1,
13+
"author_id": 23,
14+
"merge_request_id": 1,
15+
"resolve_discussion": False,
16+
"discussion_id": None,
17+
"note": "Example title",
18+
"commit_id": None,
19+
"line_code": None,
20+
"position": {
21+
"base_sha": None,
22+
"start_sha": None,
23+
"head_sha": None,
24+
"old_path": None,
25+
"new_path": None,
26+
"position_type": "text",
27+
"old_line": None,
28+
"new_line": None,
29+
"line_range": None,
30+
},
31+
}
32+
33+
34+
@pytest.fixture()
35+
def resp_list_merge_request_draft_notes():
36+
with responses.RequestsMock() as rsps:
37+
rsps.add(
38+
method=responses.GET,
39+
url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes",
40+
json=[draft_note_content],
41+
content_type="application/json",
42+
status=200,
43+
)
44+
yield rsps
45+
46+
47+
@pytest.fixture()
48+
def resp_get_merge_request_draft_note():
49+
with responses.RequestsMock() as rsps:
50+
rsps.add(
51+
method=responses.GET,
52+
url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes/1",
53+
json=draft_note_content,
54+
content_type="application/json",
55+
status=200,
56+
)
57+
yield rsps
58+
59+
60+
@pytest.fixture()
61+
def resp_create_merge_request_draft_note():
62+
with responses.RequestsMock() as rsps:
63+
rsps.add(
64+
method=responses.POST,
65+
url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes",
66+
json=draft_note_content,
67+
content_type="application/json",
68+
status=201,
69+
)
70+
yield rsps
71+
72+
73+
@pytest.fixture()
74+
def resp_update_merge_request_draft_note():
75+
updated_content = deepcopy(draft_note_content)
76+
updated_content["note"] = "New title"
77+
78+
with responses.RequestsMock() as rsps:
79+
rsps.add(
80+
method=responses.PUT,
81+
url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes/1",
82+
json=updated_content,
83+
content_type="application/json",
84+
status=201,
85+
)
86+
yield rsps
87+
88+
89+
@pytest.fixture()
90+
def resp_delete_merge_request_draft_note():
91+
with responses.RequestsMock() as rsps:
92+
rsps.add(
93+
method=responses.DELETE,
94+
url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes/1",
95+
json=draft_note_content,
96+
content_type="application/json",
97+
status=201,
98+
)
99+
yield rsps
100+
101+
102+
@pytest.fixture()
103+
def resp_publish_merge_request_draft_note():
104+
with responses.RequestsMock() as rsps:
105+
rsps.add(
106+
method=responses.PUT,
107+
url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes/1/publish",
108+
status=204,
109+
)
110+
yield rsps
111+
112+
113+
@pytest.fixture()
114+
def resp_bulk_publish_merge_request_draft_notes():
115+
with responses.RequestsMock() as rsps:
116+
rsps.add(
117+
method=responses.POST,
118+
url="http://localhost/api/v4/projects/1/merge_requests/1/draft_notes/bulk_publish",
119+
status=204,
120+
)
121+
yield rsps
122+
123+
124+
def test_list_merge_requests_draft_notes(
125+
project_merge_request, resp_list_merge_request_draft_notes
126+
):
127+
draft_notes = project_merge_request.draft_notes.list()
128+
assert len(draft_notes) == 1
129+
assert isinstance(draft_notes[0], ProjectMergeRequestDraftNote)
130+
assert draft_notes[0].note == draft_note_content["note"]
131+
132+
133+
def test_get_merge_requests_draft_note(
134+
project_merge_request, resp_get_merge_request_draft_note
135+
):
136+
draft_note = project_merge_request.draft_notes.get(1)
137+
assert isinstance(draft_note, ProjectMergeRequestDraftNote)
138+
assert draft_note.note == draft_note_content["note"]
139+
140+
141+
def test_create_merge_requests_draft_note(
142+
project_merge_request, resp_create_merge_request_draft_note
143+
):
144+
draft_note = project_merge_request.draft_notes.create({"note": "Example title"})
145+
assert isinstance(draft_note, ProjectMergeRequestDraftNote)
146+
assert draft_note.note == draft_note_content["note"]
147+
148+
149+
def test_update_merge_requests_draft_note(
150+
project_merge_request, resp_update_merge_request_draft_note
151+
):
152+
draft_note = project_merge_request.draft_notes.get(1, lazy=True)
153+
draft_note.note = "New title"
154+
draft_note.save()
155+
assert draft_note.note == "New title"
156+
157+
158+
def test_delete_merge_requests_draft_note(
159+
project_merge_request, resp_delete_merge_request_draft_note
160+
):
161+
draft_note = project_merge_request.draft_notes.get(1, lazy=True)
162+
draft_note.delete()
163+
164+
165+
def test_publish_merge_requests_draft_note(
166+
project_merge_request, resp_publish_merge_request_draft_note
167+
):
168+
draft_note = project_merge_request.draft_notes.get(1, lazy=True)
169+
draft_note.publish()
170+
171+
172+
def test_bulk_publish_merge_requests_draft_notes(
173+
project_merge_request, resp_bulk_publish_merge_request_draft_notes
174+
):
175+
project_merge_request.draft_notes.bulk_publish()

tests/unit/test_gitlab_http_methods.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,21 @@ def test_put_request_404(gl):
766766
assert responses.assert_call_count(url, 1) is True
767767

768768

769+
@responses.activate
770+
def test_put_request_204(gl):
771+
url = "http://localhost/api/v4/projects"
772+
responses.add(
773+
method=responses.PUT,
774+
url=url,
775+
status=204,
776+
match=helpers.MATCH_EMPTY_QUERY_PARAMS,
777+
)
778+
779+
result = gl.http_put("/projects")
780+
assert isinstance(result, requests.Response)
781+
assert responses.assert_call_count(url, 1) is True
782+
783+
769784
@responses.activate
770785
def test_put_request_invalid_data(gl):
771786
url = "http://localhost/api/v4/projects"

0 commit comments

Comments
 (0)