pjdev-gitlab 5.1.13__tar.gz → 5.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/PKG-INFO +64 -2
  2. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/README.md +62 -0
  3. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/__about__.py +1 -1
  4. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/__init__.py +1 -0
  5. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/issues_service.py +26 -0
  6. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/merge_requests_service.py +16 -10
  7. pjdev_gitlab-5.2.0/src/pjdev_gitlab/notes_service.py +294 -0
  8. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/test_report_pjdev-gitlab.txt +114 -98
  9. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_issues_service.py +21 -0
  10. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_merge_requests_service.py +23 -0
  11. pjdev_gitlab-5.2.0/tests/tests_for_notes_service.py +250 -0
  12. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/.gitignore +0 -0
  13. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/LICENSE.txt +0 -0
  14. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/pyproject.toml +0 -0
  15. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_issues/SKILL.md +0 -0
  16. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +0 -0
  17. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +0 -0
  18. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +0 -0
  19. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_work_items/SKILL.md +0 -0
  20. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/api_utilities.py +0 -0
  21. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/config_service.py +0 -0
  22. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/git_sync_service.py +0 -0
  23. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/models.py +0 -0
  24. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/oauth_cli.py +0 -0
  25. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/oauth_service.py +0 -0
  26. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/packages_service.py +0 -0
  27. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/py.typed +0 -0
  28. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/repo_files_service.py +0 -0
  29. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/sync_cli.py +0 -0
  30. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/work_items_service.py +0 -0
  31. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/test.sh +0 -0
  32. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/__init__.py +0 -0
  33. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/conftest.py +0 -0
  34. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_api_utilities.py +0 -0
  35. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_git_sync_service.py +0 -0
  36. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_models.py +0 -0
  37. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_oauth_cli.py +0 -0
  38. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_oauth_service.py +0 -0
  39. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_packages_service.py +0 -0
  40. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_repo_files_service.py +0 -0
  41. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_sync_cli.py +0 -0
  42. {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_work_items_service.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: pjdev-gitlab
3
- Version: 5.1.13
3
+ Version: 5.2.0
4
4
  Project-URL: Documentation, https://gitlab.purplejay.io/keystone/python/-/tree/main/pjdev-gitlab/README.md
5
5
  Project-URL: Issues, https://gitlab.purplejay.io/keystone/python/-/issues
6
6
  Project-URL: Source, https://gitlab.purplejay.io/keystone/python
@@ -192,6 +192,68 @@ asyncio.run(main())
192
192
 
193
193
  Run it: `op run --env-file=.env.op -- python my_script.py`.
194
194
 
195
+ ### Notes (comments) and replies
196
+
197
+ `notes_service` reads and replies to comments on **both** issues and merge
198
+ requests — they share the same endpoint shape, so one `Noteable` parameter
199
+ selects which.
200
+
201
+ GitLab only lets you reply to a *discussion* (a thread), never to a note id, so
202
+ `reply_to_note` resolves the note to its containing thread for you:
203
+
204
+ ```python
205
+ import asyncio
206
+ from pjdev_gitlab import config_service, notes_service
207
+ from pjdev_gitlab.notes_service import Noteable, QuickActionOnlyError
208
+
209
+ async def main() -> None:
210
+ config_service.init()
211
+
212
+ # Fetch. Notes come back newest-first; pass sort="asc" to read in order.
213
+ notes = await notes_service.list_notes(
214
+ "my-group/my-project", Noteable.merge_request, 166,
215
+ include_system=False, # drop "assigned to @x", "changed milestone", ...
216
+ sort="asc",
217
+ )
218
+
219
+ # Threads, when you need a discussion id to answer into.
220
+ threads = await notes_service.list_discussions(
221
+ "my-group/my-project", Noteable.merge_request, 166
222
+ )
223
+
224
+ # Reply, addressing the note you're answering.
225
+ await notes_service.reply_to_note(
226
+ "my-group/my-project", Noteable.merge_request, 166,
227
+ note_id=notes[0].id,
228
+ body="Good catch — fixed in the next push.",
229
+ )
230
+
231
+ # Or straight into a known thread.
232
+ await notes_service.reply_to_discussion(
233
+ "my-group/my-project", Noteable.issue, 730,
234
+ discussion_id=threads[0].id,
235
+ body="Verified on TEST1.",
236
+ )
237
+
238
+ asyncio.run(main())
239
+ ```
240
+
241
+ `list_merge_request_notes` / `list_issue_notes` remain as thin aliases for
242
+ `list_notes`.
243
+
244
+ A body made up of **only** quick actions (e.g. `/milestone %"v2.0.6"`) creates no
245
+ note — GitLab runs the command and returns the commands it executed instead. The
246
+ reply helpers raise `QuickActionOnlyError` for that case, carrying the outcome so
247
+ you can confirm the side effect landed:
248
+
249
+ ```python
250
+ try:
251
+ await notes_service.reply_to_discussion(..., body='/milestone %"v2.0.6"')
252
+ except QuickActionOnlyError as exc:
253
+ print(exc.summary) # ['Set milestone to %"v2.0.6".']
254
+ print(exc.commands_changes) # {'milestone': {...}}
255
+ ```
256
+
195
257
  ### WorkItems (GraphQL)
196
258
 
197
259
  `workItem` is GitLab's unified replacement for the legacy `Issue` type — use
@@ -164,6 +164,68 @@ asyncio.run(main())
164
164
 
165
165
  Run it: `op run --env-file=.env.op -- python my_script.py`.
166
166
 
167
+ ### Notes (comments) and replies
168
+
169
+ `notes_service` reads and replies to comments on **both** issues and merge
170
+ requests — they share the same endpoint shape, so one `Noteable` parameter
171
+ selects which.
172
+
173
+ GitLab only lets you reply to a *discussion* (a thread), never to a note id, so
174
+ `reply_to_note` resolves the note to its containing thread for you:
175
+
176
+ ```python
177
+ import asyncio
178
+ from pjdev_gitlab import config_service, notes_service
179
+ from pjdev_gitlab.notes_service import Noteable, QuickActionOnlyError
180
+
181
+ async def main() -> None:
182
+ config_service.init()
183
+
184
+ # Fetch. Notes come back newest-first; pass sort="asc" to read in order.
185
+ notes = await notes_service.list_notes(
186
+ "my-group/my-project", Noteable.merge_request, 166,
187
+ include_system=False, # drop "assigned to @x", "changed milestone", ...
188
+ sort="asc",
189
+ )
190
+
191
+ # Threads, when you need a discussion id to answer into.
192
+ threads = await notes_service.list_discussions(
193
+ "my-group/my-project", Noteable.merge_request, 166
194
+ )
195
+
196
+ # Reply, addressing the note you're answering.
197
+ await notes_service.reply_to_note(
198
+ "my-group/my-project", Noteable.merge_request, 166,
199
+ note_id=notes[0].id,
200
+ body="Good catch — fixed in the next push.",
201
+ )
202
+
203
+ # Or straight into a known thread.
204
+ await notes_service.reply_to_discussion(
205
+ "my-group/my-project", Noteable.issue, 730,
206
+ discussion_id=threads[0].id,
207
+ body="Verified on TEST1.",
208
+ )
209
+
210
+ asyncio.run(main())
211
+ ```
212
+
213
+ `list_merge_request_notes` / `list_issue_notes` remain as thin aliases for
214
+ `list_notes`.
215
+
216
+ A body made up of **only** quick actions (e.g. `/milestone %"v2.0.6"`) creates no
217
+ note — GitLab runs the command and returns the commands it executed instead. The
218
+ reply helpers raise `QuickActionOnlyError` for that case, carrying the outcome so
219
+ you can confirm the side effect landed:
220
+
221
+ ```python
222
+ try:
223
+ await notes_service.reply_to_discussion(..., body='/milestone %"v2.0.6"')
224
+ except QuickActionOnlyError as exc:
225
+ print(exc.summary) # ['Set milestone to %"v2.0.6".']
226
+ print(exc.commands_changes) # {'milestone': {...}}
227
+ ```
228
+
167
229
  ### WorkItems (GraphQL)
168
230
 
169
231
  `workItem` is GitLab's unified replacement for the legacy `Issue` type — use
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2026-present Chris O'Neill <chris@purplejay.io>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
- __version__ = "5.1.13"
4
+ __version__ = "5.2.0"
@@ -12,6 +12,7 @@ __all__ = [
12
12
  "issues_service",
13
13
  "merge_requests_service",
14
14
  "models",
15
+ "notes_service",
15
16
  "oauth_cli",
16
17
  "oauth_service",
17
18
  "packages_service",
@@ -6,6 +6,7 @@ from typing import Any, Dict, List, Literal, Optional
6
6
  from httpx import AsyncClient
7
7
  from loguru import logger
8
8
 
9
+ from pjdev_gitlab import notes_service
9
10
  from pjdev_gitlab.api_utilities import (
10
11
  async_retry_http,
11
12
  encode_path_segment,
@@ -227,6 +228,31 @@ async def comment_on_issue(
227
228
  return await _exec(client)
228
229
 
229
230
 
231
+ async def list_issue_notes(
232
+ project_id: ProjectId,
233
+ issue_iid: int,
234
+ *,
235
+ include_system: bool = True,
236
+ page_size: int = 100,
237
+ client: Optional[AsyncClient] = None,
238
+ ) -> List[Note]:
239
+ """Fetch every note on an issue.
240
+
241
+ Thin alias for :func:`pjdev_gitlab.notes_service.list_notes`; see that module
242
+ for threads, single-note reads, and replying. Deliberately undecorated -- the
243
+ delegate already retries, and stacking the decorator would square the attempt
244
+ count.
245
+ """
246
+ return await notes_service.list_notes(
247
+ project_id,
248
+ notes_service.Noteable.issue,
249
+ issue_iid,
250
+ include_system=include_system,
251
+ page_size=page_size,
252
+ client=client,
253
+ )
254
+
255
+
230
256
  @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
231
257
  async def start_issue_discussion(
232
258
  project_id: ProjectId,
@@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional
2
2
 
3
3
  from httpx import AsyncClient
4
4
 
5
+ from pjdev_gitlab import notes_service
5
6
  from pjdev_gitlab.api_utilities import (
6
7
  async_retry_http,
7
8
  encode_path_segment,
@@ -181,24 +182,29 @@ async def comment_on_merge_request_diff(
181
182
  return await _exec(client)
182
183
 
183
184
 
184
- @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
185
185
  async def list_merge_request_notes(
186
186
  project_id: ProjectId,
187
187
  mr_iid: int,
188
188
  *,
189
+ include_system: bool = True,
189
190
  page_size: int = 100,
190
191
  client: Optional[AsyncClient] = None,
191
192
  ) -> List[Note]:
192
- url = f"{_mr_base(project_id, mr_iid)}/notes"
193
+ """Fetch every note on a merge request.
193
194
 
194
- async def _exec(_client: AsyncClient) -> List[Note]:
195
- rows = await paginate(_client, url, params={}, page_size=page_size)
196
- return [Note.model_validate(row) for row in rows]
197
-
198
- if client is None:
199
- async with http_client() as _client:
200
- return await _exec(_client)
201
- return await _exec(client)
195
+ Thin alias for :func:`pjdev_gitlab.notes_service.list_notes`; see that module
196
+ for threads, single-note reads, and replying. Deliberately undecorated -- the
197
+ delegate already retries, and stacking the decorator would square the attempt
198
+ count.
199
+ """
200
+ return await notes_service.list_notes(
201
+ project_id,
202
+ notes_service.Noteable.merge_request,
203
+ mr_iid,
204
+ include_system=include_system,
205
+ page_size=page_size,
206
+ client=client,
207
+ )
202
208
 
203
209
 
204
210
  @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
@@ -0,0 +1,294 @@
1
+ """Read and reply to notes (comments) on issues and merge requests.
2
+
3
+ GitLab exposes comments two ways over the same underlying data:
4
+
5
+ * **notes** -- the flat list of individual comments on a noteable.
6
+ * **discussions** -- those same notes grouped into threads.
7
+
8
+ Replying is only possible against a *discussion*, never a note id, so the reply
9
+ helpers here resolve a note to its containing thread first. That resolution is
10
+ the whole reason this module exists: ``list_..._notes`` alone gives you no way
11
+ to answer anybody.
12
+
13
+ Both issues and merge requests use identical endpoint shapes, differing only in
14
+ one path segment, so a single implementation serves both via :class:`Noteable`.
15
+ Epics and snippets also support this API but are not wired up here.
16
+ """
17
+
18
+ import enum
19
+ from typing import Any, Dict, List, Literal, Optional
20
+
21
+ from httpx import AsyncClient
22
+
23
+ from pjdev_gitlab.api_utilities import (
24
+ async_retry_http,
25
+ encode_path_segment,
26
+ http_client,
27
+ paginate,
28
+ )
29
+ from pjdev_gitlab.models import Discussion, Note, ProjectId
30
+
31
+ _IGNORE_4XX = [400, 401, 403, 404]
32
+
33
+
34
+ class Noteable(enum.StrEnum):
35
+ """What a note is attached to. The value is the API path segment."""
36
+
37
+ issue = "issues"
38
+ merge_request = "merge_requests"
39
+
40
+
41
+ NoteSort = Literal["asc", "desc"]
42
+ NoteOrderBy = Literal["created_at", "updated_at"]
43
+
44
+
45
+ class QuickActionOnlyError(RuntimeError):
46
+ """Raised when a posted body contained *only* quick actions.
47
+
48
+ GitLab executes the commands but creates no note, so the response carries
49
+ ``commands_changes``/``summary`` instead of the usual note fields and there
50
+ is nothing to return. The executed commands are exposed on
51
+ :attr:`commands_changes` so callers can confirm the side effect landed.
52
+
53
+ Without this the caller sees a bare pydantic "field required: id" error and
54
+ no indication that the action actually succeeded.
55
+ """
56
+
57
+ def __init__(self, payload: Dict[str, Any]) -> None:
58
+ self.commands_changes: Dict[str, Any] = payload.get("commands_changes") or {}
59
+ self.summary: List[str] = payload.get("summary") or []
60
+ detail = "; ".join(self.summary) if self.summary else str(self.commands_changes)
61
+ super().__init__(
62
+ "body contained only quick actions, so GitLab created no note "
63
+ f"({detail or 'no commands reported'})"
64
+ )
65
+
66
+
67
+ def _noteable_url(project_id: ProjectId, noteable: Noteable, iid: int) -> str:
68
+ return f"/projects/{encode_path_segment(project_id)}/{noteable.value}/{iid}"
69
+
70
+
71
+ def _as_note(payload: Dict[str, Any]) -> Note:
72
+ """Validate a note-creation response, distinguishing the no-note case."""
73
+ if "id" not in payload:
74
+ raise QuickActionOnlyError(payload)
75
+ return Note.model_validate(payload)
76
+
77
+
78
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
79
+ async def list_notes(
80
+ project_id: ProjectId,
81
+ noteable: Noteable,
82
+ iid: int,
83
+ *,
84
+ sort: Optional[NoteSort] = None,
85
+ order_by: Optional[NoteOrderBy] = None,
86
+ include_system: bool = True,
87
+ page_size: int = 100,
88
+ client: Optional[AsyncClient] = None,
89
+ ) -> List[Note]:
90
+ """Fetch every note on an issue or merge request.
91
+
92
+ ``include_system=False`` drops GitLab's own bookkeeping notes ("changed
93
+ milestone to ...", "assigned to ..."), leaving only what people wrote. That
94
+ filter is applied client-side -- the API has no parameter for it.
95
+
96
+ Notes come back **newest first**: GitLab defaults to ``order_by=created_at``
97
+ with ``sort=desc``, so ``result[0]`` is the most recent comment and
98
+ ``result[-1]`` the oldest. Pass ``sort="asc"`` to read a conversation in the
99
+ order it happened. :func:`list_discussions` is unaffected -- notes within a
100
+ thread are always chronological.
101
+ """
102
+ url = f"{_noteable_url(project_id, noteable, iid)}/notes"
103
+ params: Dict[str, Any] = {}
104
+ if sort is not None:
105
+ params["sort"] = sort
106
+ if order_by is not None:
107
+ params["order_by"] = order_by
108
+
109
+ async def _exec(_client: AsyncClient) -> List[Note]:
110
+ rows = await paginate(_client, url, params=params, page_size=page_size)
111
+ notes = [Note.model_validate(row) for row in rows]
112
+ if not include_system:
113
+ notes = [n for n in notes if not n.system]
114
+ return notes
115
+
116
+ if client is None:
117
+ async with http_client() as _client:
118
+ return await _exec(_client)
119
+ return await _exec(client)
120
+
121
+
122
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
123
+ async def get_note(
124
+ project_id: ProjectId,
125
+ noteable: Noteable,
126
+ iid: int,
127
+ note_id: int,
128
+ client: Optional[AsyncClient] = None,
129
+ ) -> Note:
130
+ """Fetch a single note by id."""
131
+ url = f"{_noteable_url(project_id, noteable, iid)}/notes/{note_id}"
132
+
133
+ async def _exec(_client: AsyncClient) -> Note:
134
+ r = await _client.get(url)
135
+ r.raise_for_status()
136
+ return Note.model_validate(r.json())
137
+
138
+ if client is None:
139
+ async with http_client() as _client:
140
+ return await _exec(_client)
141
+ return await _exec(client)
142
+
143
+
144
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
145
+ async def list_discussions(
146
+ project_id: ProjectId,
147
+ noteable: Noteable,
148
+ iid: int,
149
+ *,
150
+ include_system: bool = True,
151
+ page_size: int = 100,
152
+ client: Optional[AsyncClient] = None,
153
+ ) -> List[Discussion]:
154
+ """Fetch the notes grouped into threads.
155
+
156
+ Use this rather than :func:`list_notes` when you need to reply, since only a
157
+ discussion has an id you can post back to.
158
+
159
+ ``include_system=False`` strips system notes from each thread and drops
160
+ threads left empty as a result.
161
+ """
162
+ url = f"{_noteable_url(project_id, noteable, iid)}/discussions"
163
+
164
+ async def _exec(_client: AsyncClient) -> List[Discussion]:
165
+ rows = await paginate(_client, url, params={}, page_size=page_size)
166
+ discussions = [Discussion.model_validate(row) for row in rows]
167
+ if not include_system:
168
+ kept: List[Discussion] = []
169
+ for discussion in discussions:
170
+ discussion.notes = [n for n in discussion.notes if not n.system]
171
+ if discussion.notes:
172
+ kept.append(discussion)
173
+ discussions = kept
174
+ return discussions
175
+
176
+ if client is None:
177
+ async with http_client() as _client:
178
+ return await _exec(_client)
179
+ return await _exec(client)
180
+
181
+
182
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
183
+ async def get_discussion(
184
+ project_id: ProjectId,
185
+ noteable: Noteable,
186
+ iid: int,
187
+ discussion_id: str,
188
+ client: Optional[AsyncClient] = None,
189
+ ) -> Discussion:
190
+ """Fetch a single thread by its discussion id."""
191
+ url = f"{_noteable_url(project_id, noteable, iid)}/discussions/{discussion_id}"
192
+
193
+ async def _exec(_client: AsyncClient) -> Discussion:
194
+ r = await _client.get(url)
195
+ r.raise_for_status()
196
+ return Discussion.model_validate(r.json())
197
+
198
+ if client is None:
199
+ async with http_client() as _client:
200
+ return await _exec(_client)
201
+ return await _exec(client)
202
+
203
+
204
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
205
+ async def _post_note(
206
+ url: str,
207
+ body: str,
208
+ client: Optional[AsyncClient] = None,
209
+ ) -> Dict[str, Any]:
210
+ """POST a note body and return the raw payload.
211
+
212
+ Parsing is deliberately left to the caller, outside this retry boundary:
213
+ ``async_retry_http`` treats any exception as a failed attempt and re-raises
214
+ it inside an ``ExceptionGroup``, which would bury
215
+ :class:`QuickActionOnlyError` in the very noise it exists to prevent.
216
+ """
217
+
218
+ async def _exec(_client: AsyncClient) -> Dict[str, Any]:
219
+ r = await _client.post(url, json={"body": body})
220
+ r.raise_for_status()
221
+ return r.json()
222
+
223
+ if client is None:
224
+ async with http_client() as _client:
225
+ return await _exec(_client)
226
+ return await _exec(client)
227
+
228
+
229
+ async def reply_to_discussion(
230
+ project_id: ProjectId,
231
+ noteable: Noteable,
232
+ iid: int,
233
+ discussion_id: str,
234
+ body: str,
235
+ *,
236
+ client: Optional[AsyncClient] = None,
237
+ ) -> Note:
238
+ """Append a note to an existing thread.
239
+
240
+ Raises :class:`QuickActionOnlyError` if ``body`` is nothing but quick
241
+ actions, since GitLab then creates no note to return.
242
+ """
243
+ url = f"{_noteable_url(project_id, noteable, iid)}/discussions/{discussion_id}/notes"
244
+ return _as_note(await _post_note(url, body, client))
245
+
246
+
247
+ async def find_discussion_for_note(
248
+ project_id: ProjectId,
249
+ noteable: Noteable,
250
+ iid: int,
251
+ note_id: int,
252
+ *,
253
+ client: Optional[AsyncClient] = None,
254
+ ) -> Optional[Discussion]:
255
+ """Locate the thread containing ``note_id``, or ``None`` if it isn't there.
256
+
257
+ GitLab has no endpoint mapping a note back to its discussion, so this scans
258
+ the thread list.
259
+ """
260
+ discussions = await list_discussions(project_id, noteable, iid, client=client)
261
+ for discussion in discussions:
262
+ if any(note.id == note_id for note in discussion.notes):
263
+ return discussion
264
+ return None
265
+
266
+
267
+ async def reply_to_note(
268
+ project_id: ProjectId,
269
+ noteable: Noteable,
270
+ iid: int,
271
+ note_id: int,
272
+ body: str,
273
+ *,
274
+ client: Optional[AsyncClient] = None,
275
+ ) -> Note:
276
+ """Reply to a specific note, addressing it by note id rather than thread id.
277
+
278
+ Resolves the note to its containing thread and appends there, so the reply
279
+ lands under the comment being answered. Replying to a standalone comment
280
+ turns it into a thread, which is GitLab's own behaviour.
281
+
282
+ Raises ``LookupError`` if no note with that id is on the noteable.
283
+ """
284
+ discussion = await find_discussion_for_note(
285
+ project_id, noteable, iid, note_id, client=client
286
+ )
287
+ if discussion is None:
288
+ raise LookupError(
289
+ f"note {note_id} was not found on {noteable.value} {iid} "
290
+ f"in project {project_id}"
291
+ )
292
+ return await reply_to_discussion(
293
+ project_id, noteable, iid, discussion.id, body, client=client
294
+ )
@@ -3,105 +3,121 @@ platform linux -- Python 3.12.3, pytest-9.1.1, pluggy-1.6.0 -- /venv/bin/python3
3
3
  cachedir: .pytest_cache
4
4
  rootdir: /builds/keystone/python/pjdev-gitlab
5
5
  configfile: pyproject.toml
6
- plugins: asyncio-1.4.0, anyio-4.14.2, respx-0.23.1
6
+ plugins: anyio-4.14.2, respx-0.23.1, asyncio-1.4.0
7
7
  asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
8
- collecting ... collected 96 items
8
+ collecting ... collected 112 items
9
9
 
10
- tests/tests_for_api_utilities.py::test_build_auth_defaults_to_private_token PASSED [ 1%]
11
- tests/tests_for_api_utilities.py::test_build_auth_selects_bearer_for_oauth PASSED [ 2%]
12
- tests/tests_for_api_utilities.py::test_bearer_scheme_sends_authorization_header PASSED [ 3%]
13
- tests/tests_for_api_utilities.py::test_encode_path_segment PASSED [ 4%]
14
- tests/tests_for_api_utilities.py::test_paginate_consumes_x_next_page PASSED [ 5%]
15
- tests/tests_for_api_utilities.py::test_async_retry_http_aborts_on_ignored_status PASSED [ 6%]
16
- tests/tests_for_api_utilities.py::test_async_retry_http_retries_on_5xx PASSED [ 7%]
17
- tests/tests_for_api_utilities.py::test_upload_to_project_returns_markdown PASSED [ 8%]
18
- tests/tests_for_git_sync_service.py::test_build_remote_url[https://gitlab.example.com-g/s/n-https://gitlab.example.com/g/s/n.git] PASSED [ 9%]
19
- tests/tests_for_git_sync_service.py::test_build_remote_url[https://gitlab.example.com/-/g/n/-https://gitlab.example.com/g/n.git] PASSED [ 10%]
20
- tests/tests_for_git_sync_service.py::test_build_remote_url[https://gitlab.example.com-g/n.git-https://gitlab.example.com/g/n.git] PASSED [ 11%]
21
- tests/tests_for_git_sync_service.py::test_build_remote_url_empty_repo_errors PASSED [ 12%]
22
- tests/tests_for_git_sync_service.py::test_default_target_dir[developers/claude-skills-claude-skills] PASSED [ 13%]
23
- tests/tests_for_git_sync_service.py::test_default_target_dir[g/s/name.git-name] PASSED [ 14%]
24
- tests/tests_for_git_sync_service.py::test_default_target_dir[solo-solo] PASSED [ 15%]
25
- tests/tests_for_git_sync_service.py::test_clone_when_target_missing PASSED [ 16%]
26
- tests/tests_for_git_sync_service.py::test_clone_passes_token_via_askpass_env_only PASSED [ 17%]
27
- tests/tests_for_git_sync_service.py::test_clone_with_branch PASSED [ 18%]
28
- tests/tests_for_git_sync_service.py::test_update_when_target_is_repo PASSED [ 19%]
29
- tests/tests_for_git_sync_service.py::test_update_with_existing_branch_checks_out_and_ff PASSED [ 20%]
30
- tests/tests_for_git_sync_service.py::test_update_with_new_branch_creates_from_fetch_head PASSED [ 21%]
31
- tests/tests_for_git_sync_service.py::test_update_adds_origin_when_absent PASSED [ 22%]
32
- tests/tests_for_git_sync_service.py::test_update_detached_head_errors PASSED [ 23%]
33
- tests/tests_for_git_sync_service.py::test_clone_failure_removes_partial_target PASSED [ 25%]
34
- tests/tests_for_git_sync_service.py::test_target_exists_but_not_a_repo_errors PASSED [ 26%]
35
- tests/tests_for_git_sync_service.py::test_missing_token_errors PASSED [ 27%]
36
- tests/tests_for_git_sync_service.py::test_ff_only_failure_propagates PASSED [ 28%]
37
- tests/tests_for_issues_service.py::test_search_issues_paginates PASSED [ 29%]
38
- tests/tests_for_issues_service.py::test_create_issue_with_image_uploads_first_then_creates PASSED [ 30%]
39
- tests/tests_for_issues_service.py::test_set_issue_state PASSED [ 31%]
40
- tests/tests_for_issues_service.py::test_set_issue_labels_modes PASSED [ 32%]
41
- tests/tests_for_issues_service.py::test_aggregate_issues_by_label PASSED [ 33%]
42
- tests/tests_for_issues_service.py::test_comment_on_issue PASSED [ 34%]
43
- tests/tests_for_merge_requests_service.py::test_get_merge_request PASSED [ 35%]
44
- tests/tests_for_merge_requests_service.py::test_comment_on_merge_request PASSED [ 36%]
45
- tests/tests_for_merge_requests_service.py::test_inline_diff_comment_payload_shape PASSED [ 37%]
46
- tests/tests_for_merge_requests_service.py::test_approve_and_unapprove PASSED [ 38%]
47
- tests/tests_for_models.py::test_config_api_base_url_strips_trailing_slash PASSED [ 39%]
48
- tests/tests_for_models.py::test_issue_extra_fields_ignored PASSED [ 40%]
49
- tests/tests_for_models.py::test_merge_request_basic PASSED [ 41%]
50
- tests/tests_for_models.py::test_config_env_loading PASSED [ 42%]
51
- tests/tests_for_oauth_cli.py::test_missing_client_id_errors PASSED [ 43%]
52
- tests/tests_for_oauth_cli.py::test_success_prints_only_token PASSED [ 44%]
53
- tests/tests_for_oauth_cli.py::test_reads_config_from_env PASSED [ 45%]
54
- tests/tests_for_oauth_cli.py::test_bad_redirect_port_errors PASSED [ 46%]
55
- tests/tests_for_oauth_cli.py::test_oauth_error_returns_one PASSED [ 47%]
56
- tests/tests_for_oauth_service.py::test_make_pkce_challenge_is_s256_of_verifier PASSED [ 48%]
57
- tests/tests_for_oauth_service.py::test_cache_path_is_per_host_and_private PASSED [ 50%]
58
- tests/tests_for_oauth_service.py::test_token_if_valid_expiry PASSED [ 51%]
59
- tests/tests_for_oauth_service.py::test_store_token_response_roundtrip PASSED [ 52%]
60
- tests/tests_for_oauth_service.py::test_store_token_response_requires_access_token PASSED [ 53%]
61
- tests/tests_for_oauth_service.py::test_refresh_access_token_success PASSED [ 54%]
62
- tests/tests_for_oauth_service.py::test_refresh_access_token_returns_none_on_error PASSED [ 55%]
63
- tests/tests_for_oauth_service.py::test_get_access_token_reuses_valid_cache PASSED [ 56%]
64
- tests/tests_for_oauth_service.py::test_get_access_token_refreshes_expired_cache PASSED [ 57%]
65
- tests/tests_for_oauth_service.py::test_get_access_token_requires_client_id PASSED [ 58%]
66
- tests/tests_for_packages_service.py::test_upload_generic_package_url_shape PASSED [ 59%]
67
- tests/tests_for_packages_service.py::test_download_generic_package PASSED [ 60%]
68
- tests/tests_for_packages_service.py::test_list_packages PASSED [ 61%]
69
- tests/tests_for_repo_files_service.py::test_get_file_url_encoded PASSED [ 62%]
70
- tests/tests_for_repo_files_service.py::test_download_file_writes_to_dest PASSED [ 63%]
71
- tests/tests_for_repo_files_service.py::test_download_directory_walks_tree PASSED [ 64%]
72
- tests/tests_for_sync_cli.py::test_missing_repo_errors PASSED [ 65%]
73
- tests/tests_for_sync_cli.py::test_missing_client_id_errors PASSED [ 66%]
74
- tests/tests_for_sync_cli.py::test_success_prints_path_and_forwards_args PASSED [ 67%]
75
- tests/tests_for_sync_cli.py::test_reads_config_from_env PASSED [ 68%]
76
- tests/tests_for_sync_cli.py::test_oauth_error_returns_one PASSED [ 69%]
77
- tests/tests_for_sync_cli.py::test_sync_error_returns_one PASSED [ 70%]
78
- tests/tests_for_sync_cli.py::test_bad_redirect_port_errors PASSED [ 71%]
79
- tests/tests_for_work_items_service.py::test_make_work_item_gid_passthrough_and_coerce PASSED [ 72%]
80
- tests/tests_for_work_items_service.py::test_work_item_from_node_flattens_widgets PASSED [ 73%]
81
- tests/tests_for_work_items_service.py::test_graphql_raises_on_errors PASSED [ 75%]
82
- tests/tests_for_work_items_service.py::test_paginate_graphql_follows_end_cursor PASSED [ 76%]
83
- tests/tests_for_work_items_service.py::test_get_work_item_by_global_id PASSED [ 77%]
84
- tests/tests_for_work_items_service.py::test_get_work_item_by_project_path_and_iid_uses_namespace_lookup PASSED [ 78%]
85
- tests/tests_for_work_items_service.py::test_get_work_item_omits_the_status_widget_by_default PASSED [ 79%]
86
- tests/tests_for_work_items_service.py::test_get_work_item_include_status_selects_the_widget_and_lifts_it PASSED [ 80%]
87
- tests/tests_for_work_items_service.py::test_get_work_item_missing_raises PASSED [ 81%]
88
- tests/tests_for_work_items_service.py::test_get_work_item_status_by_project_path_and_iid PASSED [ 82%]
89
- tests/tests_for_work_items_service.py::test_get_work_item_status_by_global_id_skips_the_iid_lookup PASSED [ 83%]
90
- tests/tests_for_work_items_service.py::test_get_work_item_status_returns_none_when_no_status_is_assigned PASSED [ 84%]
91
- tests/tests_for_work_items_service.py::test_get_work_item_status_missing_item_raises PASSED [ 85%]
92
- tests/tests_for_work_items_service.py::test_search_work_items_paginates_and_translates_state PASSED [ 86%]
93
- tests/tests_for_work_items_service.py::test_search_work_items_group_scope_uses_group_field PASSED [ 87%]
94
- tests/tests_for_work_items_service.py::test_search_work_items_include_status_selects_the_widget PASSED [ 88%]
95
- tests/tests_for_work_items_service.py::test_search_work_items_requires_one_scope PASSED [ 89%]
96
- tests/tests_for_work_items_service.py::test_set_work_item_state_closes_via_workItemUpdate PASSED [ 90%]
97
- tests/tests_for_work_items_service.py::test_set_work_item_state_mutation_errors_raise PASSED [ 91%]
98
- tests/tests_for_work_items_service.py::test_list_work_item_notes_filters_system_notes PASSED [ 92%]
99
- tests/tests_for_work_items_service.py::test_comment_on_work_item_creates_note PASSED [ 93%]
100
- tests/tests_for_work_items_service.py::test_reply_to_work_item_discussion_threads_via_discussion_id PASSED [ 94%]
101
- tests/tests_for_work_items_service.py::test_set_work_item_labels_add_passes_addLabelIds PASSED [ 95%]
102
- tests/tests_for_work_items_service.py::test_set_work_item_labels_remove_passes_removeLabelIds PASSED [ 96%]
103
- tests/tests_for_work_items_service.py::test_list_labels_for_project_includes_ancestor_groups PASSED [ 97%]
104
- tests/tests_for_work_items_service.py::test_create_label_passes_project_path_color_and_description PASSED [ 98%]
10
+ tests/tests_for_api_utilities.py::test_build_auth_defaults_to_private_token PASSED [ 0%]
11
+ tests/tests_for_api_utilities.py::test_build_auth_selects_bearer_for_oauth PASSED [ 1%]
12
+ tests/tests_for_api_utilities.py::test_bearer_scheme_sends_authorization_header PASSED [ 2%]
13
+ tests/tests_for_api_utilities.py::test_encode_path_segment PASSED [ 3%]
14
+ tests/tests_for_api_utilities.py::test_paginate_consumes_x_next_page PASSED [ 4%]
15
+ tests/tests_for_api_utilities.py::test_async_retry_http_aborts_on_ignored_status PASSED [ 5%]
16
+ tests/tests_for_api_utilities.py::test_async_retry_http_retries_on_5xx PASSED [ 6%]
17
+ tests/tests_for_api_utilities.py::test_upload_to_project_returns_markdown PASSED [ 7%]
18
+ tests/tests_for_git_sync_service.py::test_build_remote_url[https://gitlab.example.com-g/s/n-https://gitlab.example.com/g/s/n.git] PASSED [ 8%]
19
+ tests/tests_for_git_sync_service.py::test_build_remote_url[https://gitlab.example.com/-/g/n/-https://gitlab.example.com/g/n.git] PASSED [ 8%]
20
+ tests/tests_for_git_sync_service.py::test_build_remote_url[https://gitlab.example.com-g/n.git-https://gitlab.example.com/g/n.git] PASSED [ 9%]
21
+ tests/tests_for_git_sync_service.py::test_build_remote_url_empty_repo_errors PASSED [ 10%]
22
+ tests/tests_for_git_sync_service.py::test_default_target_dir[developers/claude-skills-claude-skills] PASSED [ 11%]
23
+ tests/tests_for_git_sync_service.py::test_default_target_dir[g/s/name.git-name] PASSED [ 12%]
24
+ tests/tests_for_git_sync_service.py::test_default_target_dir[solo-solo] PASSED [ 13%]
25
+ tests/tests_for_git_sync_service.py::test_clone_when_target_missing PASSED [ 14%]
26
+ tests/tests_for_git_sync_service.py::test_clone_passes_token_via_askpass_env_only PASSED [ 15%]
27
+ tests/tests_for_git_sync_service.py::test_clone_with_branch PASSED [ 16%]
28
+ tests/tests_for_git_sync_service.py::test_update_when_target_is_repo PASSED [ 16%]
29
+ tests/tests_for_git_sync_service.py::test_update_with_existing_branch_checks_out_and_ff PASSED [ 17%]
30
+ tests/tests_for_git_sync_service.py::test_update_with_new_branch_creates_from_fetch_head PASSED [ 18%]
31
+ tests/tests_for_git_sync_service.py::test_update_adds_origin_when_absent PASSED [ 19%]
32
+ tests/tests_for_git_sync_service.py::test_update_detached_head_errors PASSED [ 20%]
33
+ tests/tests_for_git_sync_service.py::test_clone_failure_removes_partial_target PASSED [ 21%]
34
+ tests/tests_for_git_sync_service.py::test_target_exists_but_not_a_repo_errors PASSED [ 22%]
35
+ tests/tests_for_git_sync_service.py::test_missing_token_errors PASSED [ 23%]
36
+ tests/tests_for_git_sync_service.py::test_ff_only_failure_propagates PASSED [ 24%]
37
+ tests/tests_for_issues_service.py::test_search_issues_paginates PASSED [ 25%]
38
+ tests/tests_for_issues_service.py::test_create_issue_with_image_uploads_first_then_creates PASSED [ 25%]
39
+ tests/tests_for_issues_service.py::test_set_issue_state PASSED [ 26%]
40
+ tests/tests_for_issues_service.py::test_set_issue_labels_modes PASSED [ 27%]
41
+ tests/tests_for_issues_service.py::test_aggregate_issues_by_label PASSED [ 28%]
42
+ tests/tests_for_issues_service.py::test_comment_on_issue PASSED [ 29%]
43
+ tests/tests_for_issues_service.py::test_list_issue_notes_delegates_to_notes_service PASSED [ 30%]
44
+ tests/tests_for_merge_requests_service.py::test_get_merge_request PASSED [ 31%]
45
+ tests/tests_for_merge_requests_service.py::test_comment_on_merge_request PASSED [ 32%]
46
+ tests/tests_for_merge_requests_service.py::test_inline_diff_comment_payload_shape PASSED [ 33%]
47
+ tests/tests_for_merge_requests_service.py::test_approve_and_unapprove PASSED [ 33%]
48
+ tests/tests_for_merge_requests_service.py::test_list_merge_request_notes_delegates_to_notes_service PASSED [ 34%]
49
+ tests/tests_for_models.py::test_config_api_base_url_strips_trailing_slash PASSED [ 35%]
50
+ tests/tests_for_models.py::test_issue_extra_fields_ignored PASSED [ 36%]
51
+ tests/tests_for_models.py::test_merge_request_basic PASSED [ 37%]
52
+ tests/tests_for_models.py::test_config_env_loading PASSED [ 38%]
53
+ tests/tests_for_notes_service.py::test_list_notes_for_merge_request PASSED [ 39%]
54
+ tests/tests_for_notes_service.py::test_list_notes_for_issue_uses_the_issue_path PASSED [ 40%]
55
+ tests/tests_for_notes_service.py::test_list_notes_can_drop_system_notes PASSED [ 41%]
56
+ tests/tests_for_notes_service.py::test_list_notes_forwards_sort_and_order_by PASSED [ 41%]
57
+ tests/tests_for_notes_service.py::test_get_note PASSED [ 42%]
58
+ tests/tests_for_notes_service.py::test_list_discussions_groups_notes_into_threads PASSED [ 43%]
59
+ tests/tests_for_notes_service.py::test_list_discussions_dropping_system_notes_drops_emptied_threads PASSED [ 44%]
60
+ tests/tests_for_notes_service.py::test_get_discussion PASSED [ 45%]
61
+ tests/tests_for_notes_service.py::test_reply_to_discussion_posts_to_the_thread PASSED [ 46%]
62
+ tests/tests_for_notes_service.py::test_reply_to_note_resolves_the_containing_thread PASSED [ 47%]
63
+ tests/tests_for_notes_service.py::test_reply_to_note_raises_when_the_note_is_not_there PASSED [ 48%]
64
+ tests/tests_for_notes_service.py::test_find_discussion_for_note_returns_none_when_absent PASSED [ 49%]
65
+ tests/tests_for_notes_service.py::test_quick_action_only_reply_raises_a_useful_error PASSED [ 50%]
66
+ tests/tests_for_notes_service.py::test_notes_paginate_across_pages PASSED [ 50%]
67
+ tests/tests_for_oauth_cli.py::test_missing_client_id_errors PASSED [ 51%]
68
+ tests/tests_for_oauth_cli.py::test_success_prints_only_token PASSED [ 52%]
69
+ tests/tests_for_oauth_cli.py::test_reads_config_from_env PASSED [ 53%]
70
+ tests/tests_for_oauth_cli.py::test_bad_redirect_port_errors PASSED [ 54%]
71
+ tests/tests_for_oauth_cli.py::test_oauth_error_returns_one PASSED [ 55%]
72
+ tests/tests_for_oauth_service.py::test_make_pkce_challenge_is_s256_of_verifier PASSED [ 56%]
73
+ tests/tests_for_oauth_service.py::test_cache_path_is_per_host_and_private PASSED [ 57%]
74
+ tests/tests_for_oauth_service.py::test_token_if_valid_expiry PASSED [ 58%]
75
+ tests/tests_for_oauth_service.py::test_store_token_response_roundtrip PASSED [ 58%]
76
+ tests/tests_for_oauth_service.py::test_store_token_response_requires_access_token PASSED [ 59%]
77
+ tests/tests_for_oauth_service.py::test_refresh_access_token_success PASSED [ 60%]
78
+ tests/tests_for_oauth_service.py::test_refresh_access_token_returns_none_on_error PASSED [ 61%]
79
+ tests/tests_for_oauth_service.py::test_get_access_token_reuses_valid_cache PASSED [ 62%]
80
+ tests/tests_for_oauth_service.py::test_get_access_token_refreshes_expired_cache PASSED [ 63%]
81
+ tests/tests_for_oauth_service.py::test_get_access_token_requires_client_id PASSED [ 64%]
82
+ tests/tests_for_packages_service.py::test_upload_generic_package_url_shape PASSED [ 65%]
83
+ tests/tests_for_packages_service.py::test_download_generic_package PASSED [ 66%]
84
+ tests/tests_for_packages_service.py::test_list_packages PASSED [ 66%]
85
+ tests/tests_for_repo_files_service.py::test_get_file_url_encoded PASSED [ 67%]
86
+ tests/tests_for_repo_files_service.py::test_download_file_writes_to_dest PASSED [ 68%]
87
+ tests/tests_for_repo_files_service.py::test_download_directory_walks_tree PASSED [ 69%]
88
+ tests/tests_for_sync_cli.py::test_missing_repo_errors PASSED [ 70%]
89
+ tests/tests_for_sync_cli.py::test_missing_client_id_errors PASSED [ 71%]
90
+ tests/tests_for_sync_cli.py::test_success_prints_path_and_forwards_args PASSED [ 72%]
91
+ tests/tests_for_sync_cli.py::test_reads_config_from_env PASSED [ 73%]
92
+ tests/tests_for_sync_cli.py::test_oauth_error_returns_one PASSED [ 74%]
93
+ tests/tests_for_sync_cli.py::test_sync_error_returns_one PASSED [ 75%]
94
+ tests/tests_for_sync_cli.py::test_bad_redirect_port_errors PASSED [ 75%]
95
+ tests/tests_for_work_items_service.py::test_make_work_item_gid_passthrough_and_coerce PASSED [ 76%]
96
+ tests/tests_for_work_items_service.py::test_work_item_from_node_flattens_widgets PASSED [ 77%]
97
+ tests/tests_for_work_items_service.py::test_graphql_raises_on_errors PASSED [ 78%]
98
+ tests/tests_for_work_items_service.py::test_paginate_graphql_follows_end_cursor PASSED [ 79%]
99
+ tests/tests_for_work_items_service.py::test_get_work_item_by_global_id PASSED [ 80%]
100
+ tests/tests_for_work_items_service.py::test_get_work_item_by_project_path_and_iid_uses_namespace_lookup PASSED [ 81%]
101
+ tests/tests_for_work_items_service.py::test_get_work_item_omits_the_status_widget_by_default PASSED [ 82%]
102
+ tests/tests_for_work_items_service.py::test_get_work_item_include_status_selects_the_widget_and_lifts_it PASSED [ 83%]
103
+ tests/tests_for_work_items_service.py::test_get_work_item_missing_raises PASSED [ 83%]
104
+ tests/tests_for_work_items_service.py::test_get_work_item_status_by_project_path_and_iid PASSED [ 84%]
105
+ tests/tests_for_work_items_service.py::test_get_work_item_status_by_global_id_skips_the_iid_lookup PASSED [ 85%]
106
+ tests/tests_for_work_items_service.py::test_get_work_item_status_returns_none_when_no_status_is_assigned PASSED [ 86%]
107
+ tests/tests_for_work_items_service.py::test_get_work_item_status_missing_item_raises PASSED [ 87%]
108
+ tests/tests_for_work_items_service.py::test_search_work_items_paginates_and_translates_state PASSED [ 88%]
109
+ tests/tests_for_work_items_service.py::test_search_work_items_group_scope_uses_group_field PASSED [ 89%]
110
+ tests/tests_for_work_items_service.py::test_search_work_items_include_status_selects_the_widget PASSED [ 90%]
111
+ tests/tests_for_work_items_service.py::test_search_work_items_requires_one_scope PASSED [ 91%]
112
+ tests/tests_for_work_items_service.py::test_set_work_item_state_closes_via_workItemUpdate PASSED [ 91%]
113
+ tests/tests_for_work_items_service.py::test_set_work_item_state_mutation_errors_raise PASSED [ 92%]
114
+ tests/tests_for_work_items_service.py::test_list_work_item_notes_filters_system_notes PASSED [ 93%]
115
+ tests/tests_for_work_items_service.py::test_comment_on_work_item_creates_note PASSED [ 94%]
116
+ tests/tests_for_work_items_service.py::test_reply_to_work_item_discussion_threads_via_discussion_id PASSED [ 95%]
117
+ tests/tests_for_work_items_service.py::test_set_work_item_labels_add_passes_addLabelIds PASSED [ 96%]
118
+ tests/tests_for_work_items_service.py::test_set_work_item_labels_remove_passes_removeLabelIds PASSED [ 97%]
119
+ tests/tests_for_work_items_service.py::test_list_labels_for_project_includes_ancestor_groups PASSED [ 98%]
120
+ tests/tests_for_work_items_service.py::test_create_label_passes_project_path_color_and_description PASSED [ 99%]
105
121
  tests/tests_for_work_items_service.py::test_create_label_group_scope_passes_group_path PASSED [100%]
106
122
 
107
- ============================== 96 passed in 1.91s ==============================
123
+ ============================= 112 passed in 2.66s ==============================
@@ -146,3 +146,24 @@ async def test_comment_on_issue():
146
146
  assert note.body == "/close"
147
147
  sent = json.loads(route.calls.last.request.content.decode())
148
148
  assert sent == {"body": "/close"}
149
+
150
+
151
+ @respx.mock
152
+ async def test_list_issue_notes_delegates_to_notes_service():
153
+ respx.get(
154
+ "https://gitlab.example.com/api/v4/projects/group%2Fproj/issues/42/notes"
155
+ ).mock(
156
+ return_value=httpx.Response(
157
+ 200,
158
+ json=[
159
+ {"id": 1, "body": "changed the title", "system": True},
160
+ {"id": 2, "body": "reproduced on staging"},
161
+ ],
162
+ )
163
+ )
164
+
165
+ notes = await issues_service.list_issue_notes("group/proj", 42)
166
+ assert [n.id for n in notes] == [1, 2]
167
+
168
+ human = await issues_service.list_issue_notes("group/proj", 42, include_system=False)
169
+ assert [n.body for n in human] == ["reproduced on staging"]
@@ -91,3 +91,26 @@ async def test_approve_and_unapprove():
91
91
 
92
92
  assert approve.called
93
93
  assert unapprove.called
94
+
95
+
96
+ @respx.mock
97
+ async def test_list_merge_request_notes_delegates_to_notes_service():
98
+ respx.get(
99
+ "https://gitlab.example.com/api/v4/projects/group%2Fproj/merge_requests/88/notes"
100
+ ).mock(
101
+ return_value=httpx.Response(
102
+ 200,
103
+ json=[
104
+ {"id": 1, "body": "assigned to @x", "system": True},
105
+ {"id": 2, "body": "looks good"},
106
+ ],
107
+ )
108
+ )
109
+
110
+ notes = await merge_requests_service.list_merge_request_notes("group/proj", 88)
111
+ assert [n.id for n in notes] == [1, 2]
112
+
113
+ human = await merge_requests_service.list_merge_request_notes(
114
+ "group/proj", 88, include_system=False
115
+ )
116
+ assert [n.id for n in human] == [2]
@@ -0,0 +1,250 @@
1
+ import json
2
+
3
+ import httpx
4
+ import pytest
5
+ import respx
6
+
7
+ from pjdev_gitlab import notes_service
8
+ from pjdev_gitlab.notes_service import Noteable, QuickActionOnlyError
9
+
10
+ BASE = "https://gitlab.example.com/api/v4/projects/group%2Fproj"
11
+
12
+
13
+ def _note(note_id: int, body: str, *, system: bool = False):
14
+ return {
15
+ "id": note_id,
16
+ "body": body,
17
+ "system": system,
18
+ "author": {"id": 3, "username": "dev"},
19
+ }
20
+
21
+
22
+ @respx.mock
23
+ async def test_list_notes_for_merge_request():
24
+ respx.get(f"{BASE}/merge_requests/88/notes").mock(
25
+ return_value=httpx.Response(200, json=[_note(1, "first"), _note(2, "second")])
26
+ )
27
+
28
+ notes = await notes_service.list_notes("group/proj", Noteable.merge_request, 88)
29
+ assert [n.id for n in notes] == [1, 2]
30
+ assert notes[0].author.username == "dev"
31
+
32
+
33
+ @respx.mock
34
+ async def test_list_notes_for_issue_uses_the_issue_path():
35
+ route = respx.get(f"{BASE}/issues/12/notes").mock(
36
+ return_value=httpx.Response(200, json=[_note(1, "hi")])
37
+ )
38
+
39
+ notes = await notes_service.list_notes("group/proj", Noteable.issue, 12)
40
+ assert [n.body for n in notes] == ["hi"]
41
+ assert route.called
42
+
43
+
44
+ @respx.mock
45
+ async def test_list_notes_can_drop_system_notes():
46
+ respx.get(f"{BASE}/merge_requests/88/notes").mock(
47
+ return_value=httpx.Response(
48
+ 200,
49
+ json=[
50
+ _note(1, "changed milestone to %v2.0.6", system=True),
51
+ _note(2, "actual human comment"),
52
+ ],
53
+ )
54
+ )
55
+
56
+ everything = await notes_service.list_notes("group/proj", Noteable.merge_request, 88)
57
+ assert len(everything) == 2
58
+
59
+ human_only = await notes_service.list_notes(
60
+ "group/proj", Noteable.merge_request, 88, include_system=False
61
+ )
62
+ assert [n.id for n in human_only] == [2]
63
+
64
+
65
+ @respx.mock
66
+ async def test_list_notes_forwards_sort_and_order_by():
67
+ route = respx.get(f"{BASE}/merge_requests/88/notes").mock(
68
+ return_value=httpx.Response(200, json=[])
69
+ )
70
+
71
+ await notes_service.list_notes(
72
+ "group/proj", Noteable.merge_request, 88, sort="desc", order_by="updated_at"
73
+ )
74
+ params = route.calls.last.request.url.params
75
+ assert params["sort"] == "desc"
76
+ assert params["order_by"] == "updated_at"
77
+
78
+
79
+ @respx.mock
80
+ async def test_get_note():
81
+ respx.get(f"{BASE}/merge_requests/88/notes/42").mock(
82
+ return_value=httpx.Response(200, json=_note(42, "just this one"))
83
+ )
84
+
85
+ note = await notes_service.get_note("group/proj", Noteable.merge_request, 88, 42)
86
+ assert note.id == 42
87
+ assert note.body == "just this one"
88
+
89
+
90
+ @respx.mock
91
+ async def test_list_discussions_groups_notes_into_threads():
92
+ respx.get(f"{BASE}/merge_requests/88/discussions").mock(
93
+ return_value=httpx.Response(
94
+ 200,
95
+ json=[
96
+ {
97
+ "id": "abc123",
98
+ "individual_note": False,
99
+ "notes": [_note(1, "question"), _note(2, "answer")],
100
+ },
101
+ {"id": "def456", "individual_note": True, "notes": [_note(3, "aside")]},
102
+ ],
103
+ )
104
+ )
105
+
106
+ discussions = await notes_service.list_discussions(
107
+ "group/proj", Noteable.merge_request, 88
108
+ )
109
+ assert [d.id for d in discussions] == ["abc123", "def456"]
110
+ assert [n.id for n in discussions[0].notes] == [1, 2]
111
+
112
+
113
+ @respx.mock
114
+ async def test_list_discussions_dropping_system_notes_drops_emptied_threads():
115
+ respx.get(f"{BASE}/merge_requests/88/discussions").mock(
116
+ return_value=httpx.Response(
117
+ 200,
118
+ json=[
119
+ {"id": "sys", "notes": [_note(1, "assigned to @x", system=True)]},
120
+ {
121
+ "id": "mixed",
122
+ "notes": [
123
+ _note(2, "changed title", system=True),
124
+ _note(3, "real comment"),
125
+ ],
126
+ },
127
+ ],
128
+ )
129
+ )
130
+
131
+ discussions = await notes_service.list_discussions(
132
+ "group/proj", Noteable.merge_request, 88, include_system=False
133
+ )
134
+ # "sys" held nothing but a system note, so the whole thread goes.
135
+ assert [d.id for d in discussions] == ["mixed"]
136
+ assert [n.id for n in discussions[0].notes] == [3]
137
+
138
+
139
+ @respx.mock
140
+ async def test_get_discussion():
141
+ respx.get(f"{BASE}/issues/12/discussions/abc123").mock(
142
+ return_value=httpx.Response(
143
+ 200, json={"id": "abc123", "notes": [_note(1, "thread start")]}
144
+ )
145
+ )
146
+
147
+ discussion = await notes_service.get_discussion(
148
+ "group/proj", Noteable.issue, 12, "abc123"
149
+ )
150
+ assert discussion.id == "abc123"
151
+ assert discussion.notes[0].body == "thread start"
152
+
153
+
154
+ @respx.mock
155
+ async def test_reply_to_discussion_posts_to_the_thread():
156
+ route = respx.post(f"{BASE}/merge_requests/88/discussions/abc123/notes").mock(
157
+ return_value=httpx.Response(201, json=_note(9, "good catch"))
158
+ )
159
+
160
+ note = await notes_service.reply_to_discussion(
161
+ "group/proj", Noteable.merge_request, 88, "abc123", "good catch"
162
+ )
163
+ assert note.id == 9
164
+ assert json.loads(route.calls.last.request.content.decode()) == {"body": "good catch"}
165
+
166
+
167
+ @respx.mock
168
+ async def test_reply_to_note_resolves_the_containing_thread():
169
+ respx.get(f"{BASE}/merge_requests/88/discussions").mock(
170
+ return_value=httpx.Response(
171
+ 200,
172
+ json=[
173
+ {"id": "other", "notes": [_note(1, "unrelated")]},
174
+ {"id": "target", "notes": [_note(2, "a"), _note(7, "reply to me")]},
175
+ ],
176
+ )
177
+ )
178
+ route = respx.post(f"{BASE}/merge_requests/88/discussions/target/notes").mock(
179
+ return_value=httpx.Response(201, json=_note(10, "on it"))
180
+ )
181
+
182
+ note = await notes_service.reply_to_note(
183
+ "group/proj", Noteable.merge_request, 88, 7, "on it"
184
+ )
185
+ assert note.id == 10
186
+ # posted to the thread that actually held note 7, not the first one listed
187
+ assert route.called
188
+
189
+
190
+ @respx.mock
191
+ async def test_reply_to_note_raises_when_the_note_is_not_there():
192
+ respx.get(f"{BASE}/issues/12/discussions").mock(
193
+ return_value=httpx.Response(200, json=[{"id": "x", "notes": [_note(1, "a")]}])
194
+ )
195
+
196
+ with pytest.raises(LookupError, match="note 999 was not found"):
197
+ await notes_service.reply_to_note("group/proj", Noteable.issue, 12, 999, "hello")
198
+
199
+
200
+ @respx.mock
201
+ async def test_find_discussion_for_note_returns_none_when_absent():
202
+ respx.get(f"{BASE}/issues/12/discussions").mock(
203
+ return_value=httpx.Response(200, json=[{"id": "x", "notes": [_note(1, "a")]}])
204
+ )
205
+
206
+ found = await notes_service.find_discussion_for_note(
207
+ "group/proj", Noteable.issue, 12, 999
208
+ )
209
+ assert found is None
210
+
211
+
212
+ @respx.mock
213
+ async def test_quick_action_only_reply_raises_a_useful_error():
214
+ """GitLab runs the command but creates no note, so there is no id to parse.
215
+
216
+ The bare pydantic failure this replaces gave no hint that the action had in
217
+ fact succeeded.
218
+ """
219
+ respx.post(f"{BASE}/merge_requests/88/discussions/abc123/notes").mock(
220
+ return_value=httpx.Response(
221
+ 201,
222
+ json={
223
+ "commands_changes": {"milestone": {"id": 302, "title": "v2.0.6"}},
224
+ "summary": ['Set milestone to %"v2.0.6".'],
225
+ },
226
+ )
227
+ )
228
+
229
+ with pytest.raises(QuickActionOnlyError) as excinfo:
230
+ await notes_service.reply_to_discussion(
231
+ "group/proj", Noteable.merge_request, 88, "abc123", '/milestone %"v2.0.6"'
232
+ )
233
+
234
+ err = excinfo.value
235
+ assert err.commands_changes["milestone"]["title"] == "v2.0.6"
236
+ assert err.summary == ['Set milestone to %"v2.0.6".']
237
+ assert "only quick actions" in str(err)
238
+
239
+
240
+ @respx.mock
241
+ async def test_notes_paginate_across_pages():
242
+ respx.get(f"{BASE}/merge_requests/88/notes").mock(
243
+ side_effect=[
244
+ httpx.Response(200, json=[_note(1, "p1")], headers={"X-Next-Page": "2"}),
245
+ httpx.Response(200, json=[_note(2, "p2")], headers={"X-Next-Page": ""}),
246
+ ]
247
+ )
248
+
249
+ notes = await notes_service.list_notes("group/proj", Noteable.merge_request, 88)
250
+ assert [n.id for n in notes] == [1, 2]
File without changes
File without changes
File without changes