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.
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/PKG-INFO +64 -2
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/README.md +62 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/__about__.py +1 -1
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/__init__.py +1 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/issues_service.py +26 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/merge_requests_service.py +16 -10
- pjdev_gitlab-5.2.0/src/pjdev_gitlab/notes_service.py +294 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/test_report_pjdev-gitlab.txt +114 -98
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_issues_service.py +21 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_merge_requests_service.py +23 -0
- pjdev_gitlab-5.2.0/tests/tests_for_notes_service.py +250 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/.gitignore +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/LICENSE.txt +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/pyproject.toml +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_issues/SKILL.md +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_work_items/SKILL.md +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/api_utilities.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/config_service.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/git_sync_service.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/models.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/oauth_cli.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/oauth_service.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/packages_service.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/py.typed +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/repo_files_service.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/sync_cli.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/src/pjdev_gitlab/work_items_service.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/test.sh +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/__init__.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/conftest.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_api_utilities.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_git_sync_service.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_models.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_oauth_cli.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_oauth_service.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_packages_service.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_repo_files_service.py +0 -0
- {pjdev_gitlab-5.1.13 → pjdev_gitlab-5.2.0}/tests/tests_for_sync_cli.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: pjdev-gitlab
|
|
3
|
-
Version: 5.
|
|
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
|
|
@@ -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
|
-
|
|
193
|
+
"""Fetch every note on a merge request.
|
|
193
194
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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:
|
|
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
|
|
8
|
+
collecting ... collected 112 items
|
|
9
9
|
|
|
10
|
-
tests/tests_for_api_utilities.py::test_build_auth_defaults_to_private_token PASSED [
|
|
11
|
-
tests/tests_for_api_utilities.py::test_build_auth_selects_bearer_for_oauth PASSED [
|
|
12
|
-
tests/tests_for_api_utilities.py::test_bearer_scheme_sends_authorization_header PASSED [
|
|
13
|
-
tests/tests_for_api_utilities.py::test_encode_path_segment PASSED [
|
|
14
|
-
tests/tests_for_api_utilities.py::test_paginate_consumes_x_next_page PASSED [
|
|
15
|
-
tests/tests_for_api_utilities.py::test_async_retry_http_aborts_on_ignored_status PASSED [
|
|
16
|
-
tests/tests_for_api_utilities.py::test_async_retry_http_retries_on_5xx PASSED [
|
|
17
|
-
tests/tests_for_api_utilities.py::test_upload_to_project_returns_markdown PASSED [
|
|
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 [
|
|
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 [
|
|
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 [
|
|
21
|
-
tests/tests_for_git_sync_service.py::test_build_remote_url_empty_repo_errors PASSED [
|
|
22
|
-
tests/tests_for_git_sync_service.py::test_default_target_dir[developers/claude-skills-claude-skills] PASSED [
|
|
23
|
-
tests/tests_for_git_sync_service.py::test_default_target_dir[g/s/name.git-name] PASSED [
|
|
24
|
-
tests/tests_for_git_sync_service.py::test_default_target_dir[solo-solo] PASSED [
|
|
25
|
-
tests/tests_for_git_sync_service.py::test_clone_when_target_missing PASSED [
|
|
26
|
-
tests/tests_for_git_sync_service.py::test_clone_passes_token_via_askpass_env_only PASSED [
|
|
27
|
-
tests/tests_for_git_sync_service.py::test_clone_with_branch PASSED [
|
|
28
|
-
tests/tests_for_git_sync_service.py::test_update_when_target_is_repo PASSED [
|
|
29
|
-
tests/tests_for_git_sync_service.py::test_update_with_existing_branch_checks_out_and_ff PASSED [
|
|
30
|
-
tests/tests_for_git_sync_service.py::test_update_with_new_branch_creates_from_fetch_head PASSED [
|
|
31
|
-
tests/tests_for_git_sync_service.py::test_update_adds_origin_when_absent PASSED [
|
|
32
|
-
tests/tests_for_git_sync_service.py::test_update_detached_head_errors PASSED [
|
|
33
|
-
tests/tests_for_git_sync_service.py::test_clone_failure_removes_partial_target PASSED [
|
|
34
|
-
tests/tests_for_git_sync_service.py::test_target_exists_but_not_a_repo_errors PASSED [
|
|
35
|
-
tests/tests_for_git_sync_service.py::test_missing_token_errors PASSED [
|
|
36
|
-
tests/tests_for_git_sync_service.py::test_ff_only_failure_propagates PASSED [
|
|
37
|
-
tests/tests_for_issues_service.py::test_search_issues_paginates PASSED [
|
|
38
|
-
tests/tests_for_issues_service.py::test_create_issue_with_image_uploads_first_then_creates PASSED [
|
|
39
|
-
tests/tests_for_issues_service.py::test_set_issue_state PASSED [
|
|
40
|
-
tests/tests_for_issues_service.py::test_set_issue_labels_modes PASSED [
|
|
41
|
-
tests/tests_for_issues_service.py::test_aggregate_issues_by_label PASSED [
|
|
42
|
-
tests/tests_for_issues_service.py::test_comment_on_issue PASSED [
|
|
43
|
-
tests/
|
|
44
|
-
tests/tests_for_merge_requests_service.py::
|
|
45
|
-
tests/tests_for_merge_requests_service.py::
|
|
46
|
-
tests/tests_for_merge_requests_service.py::
|
|
47
|
-
tests/
|
|
48
|
-
tests/
|
|
49
|
-
tests/tests_for_models.py::
|
|
50
|
-
tests/tests_for_models.py::
|
|
51
|
-
tests/
|
|
52
|
-
tests/
|
|
53
|
-
tests/
|
|
54
|
-
tests/
|
|
55
|
-
tests/
|
|
56
|
-
tests/
|
|
57
|
-
tests/
|
|
58
|
-
tests/
|
|
59
|
-
tests/
|
|
60
|
-
tests/
|
|
61
|
-
tests/
|
|
62
|
-
tests/
|
|
63
|
-
tests/
|
|
64
|
-
tests/
|
|
65
|
-
tests/
|
|
66
|
-
tests/
|
|
67
|
-
tests/
|
|
68
|
-
tests/
|
|
69
|
-
tests/
|
|
70
|
-
tests/
|
|
71
|
-
tests/
|
|
72
|
-
tests/
|
|
73
|
-
tests/
|
|
74
|
-
tests/
|
|
75
|
-
tests/
|
|
76
|
-
tests/
|
|
77
|
-
tests/
|
|
78
|
-
tests/
|
|
79
|
-
tests/
|
|
80
|
-
tests/
|
|
81
|
-
tests/
|
|
82
|
-
tests/
|
|
83
|
-
tests/
|
|
84
|
-
tests/
|
|
85
|
-
tests/
|
|
86
|
-
tests/
|
|
87
|
-
tests/
|
|
88
|
-
tests/
|
|
89
|
-
tests/
|
|
90
|
-
tests/
|
|
91
|
-
tests/
|
|
92
|
-
tests/
|
|
93
|
-
tests/
|
|
94
|
-
tests/
|
|
95
|
-
tests/tests_for_work_items_service.py::
|
|
96
|
-
tests/tests_for_work_items_service.py::
|
|
97
|
-
tests/tests_for_work_items_service.py::
|
|
98
|
-
tests/tests_for_work_items_service.py::
|
|
99
|
-
tests/tests_for_work_items_service.py::
|
|
100
|
-
tests/tests_for_work_items_service.py::
|
|
101
|
-
tests/tests_for_work_items_service.py::
|
|
102
|
-
tests/tests_for_work_items_service.py::
|
|
103
|
-
tests/tests_for_work_items_service.py::
|
|
104
|
-
tests/tests_for_work_items_service.py::
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|