pjdev-gitlab 5.1.12__tar.gz → 5.1.13__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.12 → pjdev_gitlab-5.1.13}/PKG-INFO +26 -1
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/README.md +25 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_work_items/SKILL.md +34 -1
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/__about__.py +1 -1
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/models.py +34 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/work_items_service.py +118 -6
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/test_report_pjdev-gitlab.txt +80 -73
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_work_items_service.py +188 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/.gitignore +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/LICENSE.txt +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/pyproject.toml +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_issues/SKILL.md +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/__init__.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/api_utilities.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/config_service.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/git_sync_service.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/issues_service.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/merge_requests_service.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/oauth_cli.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/oauth_service.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/packages_service.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/py.typed +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/repo_files_service.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/sync_cli.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/test.sh +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/__init__.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/conftest.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_api_utilities.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_git_sync_service.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_issues_service.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_merge_requests_service.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_models.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_oauth_cli.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_oauth_service.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_packages_service.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_repo_files_service.py +0 -0
- {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_sync_cli.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pjdev-gitlab
|
|
3
|
-
Version: 5.1.
|
|
3
|
+
Version: 5.1.13
|
|
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
|
|
@@ -234,6 +234,31 @@ async def main() -> None:
|
|
|
234
234
|
asyncio.run(main())
|
|
235
235
|
```
|
|
236
236
|
|
|
237
|
+
#### Custom statuses
|
|
238
|
+
|
|
239
|
+
The workflow status an instance defines for itself (`Ready`, `In progress`, ...)
|
|
240
|
+
is neither a label nor the open/closed `state`, and REST does not expose it.
|
|
241
|
+
Reading it is opt-in, because GitLab ships the status widget as an experiment
|
|
242
|
+
(**17.11+**) and selecting it against an older instance fails the whole query:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
# Just the status — one small query. Preferred on a hot path.
|
|
246
|
+
status = await work_items_service.get_work_item_status(
|
|
247
|
+
42, project_path="my-group/my-project"
|
|
248
|
+
)
|
|
249
|
+
print(status.name if status else "no status set")
|
|
250
|
+
|
|
251
|
+
# Or folded into a read you were making anyway.
|
|
252
|
+
item = await work_items_service.get_work_item(
|
|
253
|
+
42, project_path="my-group/my-project", include_status=True
|
|
254
|
+
)
|
|
255
|
+
items = await work_items_service.search_work_items(
|
|
256
|
+
project_path="my-group/my-project", include_status=True
|
|
257
|
+
)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Status names are configured per namespace — match them case-insensitively.
|
|
261
|
+
|
|
237
262
|
## Bundled agent skills
|
|
238
263
|
|
|
239
264
|
Skill files for AI agents ship under `.agents/skills/` inside the installed package, following the [library-skills.io](https://library-skills.io/create/) convention. Topics: issues, workItems, merge requests, repository files, generic packages.
|
|
@@ -206,6 +206,31 @@ async def main() -> None:
|
|
|
206
206
|
asyncio.run(main())
|
|
207
207
|
```
|
|
208
208
|
|
|
209
|
+
#### Custom statuses
|
|
210
|
+
|
|
211
|
+
The workflow status an instance defines for itself (`Ready`, `In progress`, ...)
|
|
212
|
+
is neither a label nor the open/closed `state`, and REST does not expose it.
|
|
213
|
+
Reading it is opt-in, because GitLab ships the status widget as an experiment
|
|
214
|
+
(**17.11+**) and selecting it against an older instance fails the whole query:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
# Just the status — one small query. Preferred on a hot path.
|
|
218
|
+
status = await work_items_service.get_work_item_status(
|
|
219
|
+
42, project_path="my-group/my-project"
|
|
220
|
+
)
|
|
221
|
+
print(status.name if status else "no status set")
|
|
222
|
+
|
|
223
|
+
# Or folded into a read you were making anyway.
|
|
224
|
+
item = await work_items_service.get_work_item(
|
|
225
|
+
42, project_path="my-group/my-project", include_status=True
|
|
226
|
+
)
|
|
227
|
+
items = await work_items_service.search_work_items(
|
|
228
|
+
project_path="my-group/my-project", include_status=True
|
|
229
|
+
)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Status names are configured per namespace — match them case-insensitively.
|
|
233
|
+
|
|
209
234
|
## Bundled agent skills
|
|
210
235
|
|
|
211
236
|
Skill files for AI agents ship under `.agents/skills/` inside the installed package, following the [library-skills.io](https://library-skills.io/create/) convention. Topics: issues, workItems, merge requests, repository files, generic packages.
|
|
@@ -87,7 +87,7 @@ across_group = await work_items_service.search_work_items(
|
|
|
87
87
|
)
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
##
|
|
90
|
+
## Open/closed state
|
|
91
91
|
|
|
92
92
|
```python
|
|
93
93
|
from pjdev_gitlab.models import WorkItemStateEvent
|
|
@@ -100,6 +100,39 @@ await work_items_service.set_work_item_state(
|
|
|
100
100
|
)
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
+
## Custom status
|
|
104
|
+
|
|
105
|
+
Separate from open/closed: the workflow status an instance defines for itself
|
|
106
|
+
(`Ready`, `In progress`, `Code Complete`, ...). It is **not** a label, and the
|
|
107
|
+
REST API does not expose it at all — GraphQL's status widget is the only way to
|
|
108
|
+
read it.
|
|
109
|
+
|
|
110
|
+
Reading it is opt-in, because the widget is a GitLab *experiment* introduced in
|
|
111
|
+
**17.11**: selecting it against an older instance fails the entire query, so it
|
|
112
|
+
is never requested unless asked for.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
# Just the status — one small query, no discussions dragged along.
|
|
116
|
+
# This is the right call on a hot path (e.g. per webhook event).
|
|
117
|
+
status = await work_items_service.get_work_item_status(42, project_path="group/proj")
|
|
118
|
+
print(status.name if status else "no status set")
|
|
119
|
+
|
|
120
|
+
# Or alongside everything else, when you were fetching the item anyway.
|
|
121
|
+
item = await work_items_service.get_work_item(
|
|
122
|
+
42, project_path="group/proj", include_status=True
|
|
123
|
+
)
|
|
124
|
+
print(item.status.name if item.status else "no status set")
|
|
125
|
+
|
|
126
|
+
# Also available on searches.
|
|
127
|
+
items = await work_items_service.search_work_items(
|
|
128
|
+
project_path="group/proj", include_status=True
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`None` means the item has no status assigned. Status names are configured per
|
|
133
|
+
namespace, so **match them case-insensitively** rather than hard-coding an enum —
|
|
134
|
+
`"Code Complete"` on one instance may be `"code complete"` on another.
|
|
135
|
+
|
|
103
136
|
## Comments and threaded replies
|
|
104
137
|
|
|
105
138
|
```python
|
|
@@ -265,6 +265,32 @@ class WorkItemIteration(GraphQLBase):
|
|
|
265
265
|
web_url: Optional[str] = None
|
|
266
266
|
|
|
267
267
|
|
|
268
|
+
class WorkItemStatus(GraphQLBase):
|
|
269
|
+
"""A custom work-item status (``Ready``, ``In progress``, ``Code Complete``, ...).
|
|
270
|
+
|
|
271
|
+
Distinct from :class:`WorkItemState`, which is only ``OPEN``/``CLOSED``.
|
|
272
|
+
Statuses are defined per namespace, so the names are whatever the instance's
|
|
273
|
+
administrators configured — match them case-insensitively rather than
|
|
274
|
+
hard-coding an enum.
|
|
275
|
+
|
|
276
|
+
GitLab exposes this as an **experiment** (``WorkItemWidgetStatus``,
|
|
277
|
+
introduced in 17.11), which is why the widget is opt-in throughout
|
|
278
|
+
:mod:`pjdev_gitlab.work_items_service` — see ``include_status``. ``category``
|
|
279
|
+
and ``description`` are 18.1+ and are deliberately not selected, so the
|
|
280
|
+
queries here stay valid against 17.11.
|
|
281
|
+
|
|
282
|
+
Every field is optional: an instance may return a status object before it has
|
|
283
|
+
been fully configured, and a parse failure here would take down the whole
|
|
284
|
+
work-item read.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
id: Optional[str] = None
|
|
288
|
+
name: Optional[str] = None
|
|
289
|
+
icon_name: Optional[str] = None
|
|
290
|
+
color: Optional[str] = None
|
|
291
|
+
position: Optional[int] = None
|
|
292
|
+
|
|
293
|
+
|
|
268
294
|
class WorkItemNote(GraphQLBase):
|
|
269
295
|
id: str
|
|
270
296
|
body: str
|
|
@@ -316,6 +342,9 @@ class WorkItem(GraphQLBase):
|
|
|
316
342
|
start_date: Optional[str] = None
|
|
317
343
|
due_date: Optional[str] = None
|
|
318
344
|
discussions: List[WorkItemDiscussion] = []
|
|
345
|
+
status: Optional[WorkItemStatus] = None
|
|
346
|
+
"""Only populated when the read requested ``include_status=True``; ``None``
|
|
347
|
+
otherwise, which is indistinguishable from "this item has no status set"."""
|
|
319
348
|
|
|
320
349
|
widgets: List[Dict[str, Any]] = []
|
|
321
350
|
|
|
@@ -345,6 +374,11 @@ class WorkItem(GraphQLBase):
|
|
|
345
374
|
elif widget_type == "START_AND_DUE_DATE":
|
|
346
375
|
flat["start_date"] = widget.get("startDate")
|
|
347
376
|
flat["due_date"] = widget.get("dueDate")
|
|
377
|
+
elif widget_type == "STATUS":
|
|
378
|
+
# Only present when the query opted in via include_status; a work
|
|
379
|
+
# item with no status assigned still sends the widget, with a
|
|
380
|
+
# null status.
|
|
381
|
+
flat["status"] = widget.get("status")
|
|
348
382
|
elif widget_type == "NOTES":
|
|
349
383
|
nodes = (widget.get("discussions") or {}).get("nodes") or []
|
|
350
384
|
flat["discussions"] = [
|
|
@@ -31,6 +31,7 @@ from pjdev_gitlab.models import (
|
|
|
31
31
|
WorkItemNote,
|
|
32
32
|
WorkItemState,
|
|
33
33
|
WorkItemStateEvent,
|
|
34
|
+
WorkItemStatus,
|
|
34
35
|
is_global_id,
|
|
35
36
|
make_label_gid,
|
|
36
37
|
make_work_item_gid,
|
|
@@ -79,7 +80,18 @@ fragment LabelFields on Label {
|
|
|
79
80
|
}
|
|
80
81
|
"""
|
|
81
82
|
|
|
82
|
-
|
|
83
|
+
# The status widget is a GitLab *experiment* (17.11+). Selecting an inline
|
|
84
|
+
# fragment on a type the instance does not know about fails the whole query, so
|
|
85
|
+
# this is spliced in only when a caller asks for it — an older instance keeps
|
|
86
|
+
# working for every other field. `category`/`description` are 18.1+ and are left
|
|
87
|
+
# out for the same reason.
|
|
88
|
+
_STATUS_WIDGET_SELECTION = """
|
|
89
|
+
... on WorkItemWidgetStatus {
|
|
90
|
+
status { id name iconName color position }
|
|
91
|
+
}
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
_WORK_ITEM_FRAGMENT_TEMPLATE = (
|
|
83
95
|
_USER_FRAGMENT
|
|
84
96
|
+ _NOTE_FRAGMENT
|
|
85
97
|
+ _LABEL_FRAGMENT
|
|
@@ -130,12 +142,20 @@ fragment WorkItemFields on WorkItem {
|
|
|
130
142
|
}
|
|
131
143
|
}
|
|
132
144
|
}
|
|
145
|
+
__STATUS_WIDGET__
|
|
133
146
|
}
|
|
134
147
|
}
|
|
135
148
|
"""
|
|
136
149
|
)
|
|
137
150
|
|
|
138
151
|
|
|
152
|
+
def _work_item_fragment(include_status: bool = False) -> str:
|
|
153
|
+
"""Build the ``WorkItemFields`` fragment, optionally selecting the status widget."""
|
|
154
|
+
return _WORK_ITEM_FRAGMENT_TEMPLATE.replace(
|
|
155
|
+
"__STATUS_WIDGET__", _STATUS_WIDGET_SELECTION if include_status else ""
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
139
159
|
# ---------------------------------------------------------------------------
|
|
140
160
|
# Helpers
|
|
141
161
|
# ---------------------------------------------------------------------------
|
|
@@ -213,6 +233,7 @@ async def get_work_item(
|
|
|
213
233
|
*,
|
|
214
234
|
project_path: Optional[ProjectId] = None,
|
|
215
235
|
group_path: Optional[GroupId] = None,
|
|
236
|
+
include_status: bool = False,
|
|
216
237
|
client: Optional[AsyncClient] = None,
|
|
217
238
|
) -> WorkItem:
|
|
218
239
|
"""Fetch a single workItem.
|
|
@@ -223,11 +244,17 @@ async def get_work_item(
|
|
|
223
244
|
- A project-scoped ``iid`` (``42`` or ``"42"``) along with
|
|
224
245
|
``project_path`` or ``group_path`` — looked up via the namespace's
|
|
225
246
|
``workItems`` connection.
|
|
247
|
+
|
|
248
|
+
Set ``include_status=True`` to populate :attr:`WorkItem.status` with the
|
|
249
|
+
custom work-item status. It is off by default because the underlying widget
|
|
250
|
+
is an experiment that only exists on GitLab 17.11+, and asking for it on an
|
|
251
|
+
older instance fails the whole query. When the status is all you need,
|
|
252
|
+
:func:`get_work_item_status` is a much smaller round-trip.
|
|
226
253
|
"""
|
|
227
254
|
if (project_path or group_path) and not is_global_id(str(work_item)):
|
|
228
255
|
path = project_path if project_path is not None else group_path
|
|
229
256
|
query = (
|
|
230
|
-
|
|
257
|
+
_work_item_fragment(include_status)
|
|
231
258
|
+ """
|
|
232
259
|
query GetWorkItemByIid($path: ID!, $iid: String!) {
|
|
233
260
|
namespace(fullPath: $path) {
|
|
@@ -250,7 +277,7 @@ async def get_work_item(
|
|
|
250
277
|
|
|
251
278
|
gid = make_work_item_gid(work_item)
|
|
252
279
|
query = (
|
|
253
|
-
|
|
280
|
+
_work_item_fragment(include_status)
|
|
254
281
|
+ """
|
|
255
282
|
query GetWorkItem($id: WorkItemID!) {
|
|
256
283
|
workItem(id: $id) { ...WorkItemFields }
|
|
@@ -264,6 +291,87 @@ async def get_work_item(
|
|
|
264
291
|
return WorkItem.from_node(node)
|
|
265
292
|
|
|
266
293
|
|
|
294
|
+
def _extract_status(node: Optional[Dict[str, Any]]) -> Optional[WorkItemStatus]:
|
|
295
|
+
"""Pull the status out of a work-item node's ``widgets`` list.
|
|
296
|
+
|
|
297
|
+
GitLab returns *every* widget on the item, but only the status widget matches
|
|
298
|
+
the inline fragment — the rest arrive as empty objects and are skipped here.
|
|
299
|
+
An item with no status assigned yields ``None``.
|
|
300
|
+
"""
|
|
301
|
+
for widget in (node or {}).get("widgets") or []:
|
|
302
|
+
status = widget.get("status")
|
|
303
|
+
if status:
|
|
304
|
+
return WorkItemStatus.model_validate(status)
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
|
|
309
|
+
async def get_work_item_status(
|
|
310
|
+
work_item: WorkItemRef,
|
|
311
|
+
*,
|
|
312
|
+
project_path: Optional[ProjectId] = None,
|
|
313
|
+
group_path: Optional[GroupId] = None,
|
|
314
|
+
client: Optional[AsyncClient] = None,
|
|
315
|
+
) -> Optional[WorkItemStatus]:
|
|
316
|
+
"""Fetch only the custom status of a workItem.
|
|
317
|
+
|
|
318
|
+
Same identifier rules as :func:`get_work_item`. Prefer this over
|
|
319
|
+
``get_work_item(..., include_status=True)`` when the status is all you need:
|
|
320
|
+
it skips the full field set, most notably the 50 discussions the shared
|
|
321
|
+
fragment pulls in, which matters on a per-event hot path.
|
|
322
|
+
|
|
323
|
+
Returns ``None`` when the item exists but has no status assigned. Raises when
|
|
324
|
+
the item cannot be found, or — because the widget is a 17.11+ experiment —
|
|
325
|
+
when the instance does not know the ``WorkItemWidgetStatus`` type at all.
|
|
326
|
+
"""
|
|
327
|
+
if (project_path or group_path) and not is_global_id(str(work_item)):
|
|
328
|
+
path = project_path if project_path is not None else group_path
|
|
329
|
+
query = """
|
|
330
|
+
query GetWorkItemStatusByIid($path: ID!, $iid: String!) {
|
|
331
|
+
namespace(fullPath: $path) {
|
|
332
|
+
workItems(iid: $iid, first: 1) {
|
|
333
|
+
nodes {
|
|
334
|
+
id
|
|
335
|
+
widgets {
|
|
336
|
+
... on WorkItemWidgetStatus {
|
|
337
|
+
status { id name iconName color position }
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
"""
|
|
345
|
+
data = await graphql(
|
|
346
|
+
query, {"path": str(path), "iid": str(work_item)}, client=client
|
|
347
|
+
)
|
|
348
|
+
nodes = (((data or {}).get("namespace") or {}).get("workItems") or {}).get(
|
|
349
|
+
"nodes"
|
|
350
|
+
) or []
|
|
351
|
+
if not nodes:
|
|
352
|
+
raise RuntimeError(f"workItem !{work_item} not found under {path}")
|
|
353
|
+
return _extract_status(nodes[0])
|
|
354
|
+
|
|
355
|
+
gid = make_work_item_gid(work_item)
|
|
356
|
+
query = """
|
|
357
|
+
query GetWorkItemStatus($id: WorkItemID!) {
|
|
358
|
+
workItem(id: $id) {
|
|
359
|
+
id
|
|
360
|
+
widgets {
|
|
361
|
+
... on WorkItemWidgetStatus {
|
|
362
|
+
status { id name iconName color position }
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
"""
|
|
368
|
+
data = await graphql(query, {"id": gid}, client=client)
|
|
369
|
+
node = (data or {}).get("workItem")
|
|
370
|
+
if not node:
|
|
371
|
+
raise RuntimeError(f"workItem {gid} not found")
|
|
372
|
+
return _extract_status(node)
|
|
373
|
+
|
|
374
|
+
|
|
267
375
|
@async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
|
|
268
376
|
async def search_work_items(
|
|
269
377
|
*,
|
|
@@ -273,6 +381,7 @@ async def search_work_items(
|
|
|
273
381
|
labels: Optional[List[str]] = None,
|
|
274
382
|
search: Optional[str] = None,
|
|
275
383
|
work_item_type_ids: Optional[List[str]] = None,
|
|
384
|
+
include_status: bool = False,
|
|
276
385
|
paginate_results: bool = True,
|
|
277
386
|
page_size: int = 100,
|
|
278
387
|
client: Optional[AsyncClient] = None,
|
|
@@ -288,6 +397,9 @@ async def search_work_items(
|
|
|
288
397
|
|
|
289
398
|
When ``paginate_results`` is ``True`` (the default) the helper follows
|
|
290
399
|
``pageInfo.endCursor`` until ``hasNextPage`` is false.
|
|
400
|
+
|
|
401
|
+
``include_status`` populates :attr:`WorkItem.status` on every result; see
|
|
402
|
+
:func:`get_work_item` for the version caveat.
|
|
291
403
|
"""
|
|
292
404
|
if (project_path is None) == (group_path is None):
|
|
293
405
|
raise ValueError("provide exactly one of project_path / group_path")
|
|
@@ -296,7 +408,7 @@ async def search_work_items(
|
|
|
296
408
|
path = project_path if project_path is not None else group_path
|
|
297
409
|
|
|
298
410
|
query = (
|
|
299
|
-
|
|
411
|
+
_work_item_fragment(include_status)
|
|
300
412
|
+ f"""
|
|
301
413
|
query SearchWorkItems(
|
|
302
414
|
$path: ID!,
|
|
@@ -418,7 +530,7 @@ async def set_work_item_state(
|
|
|
418
530
|
client=_client,
|
|
419
531
|
)
|
|
420
532
|
query = (
|
|
421
|
-
|
|
533
|
+
_work_item_fragment()
|
|
422
534
|
+ """
|
|
423
535
|
mutation SetWorkItemState($id: WorkItemID!, $stateEvent: WorkItemStateEvent!) {
|
|
424
536
|
workItemUpdate(input: { id: $id, stateEvent: $stateEvent }) {
|
|
@@ -471,7 +583,7 @@ async def set_work_item_labels(
|
|
|
471
583
|
client=_client,
|
|
472
584
|
)
|
|
473
585
|
query = (
|
|
474
|
-
|
|
586
|
+
_work_item_fragment()
|
|
475
587
|
+ """
|
|
476
588
|
mutation SetWorkItemLabels($input: WorkItemUpdateInput!) {
|
|
477
589
|
workItemUpdate(input: $input) {
|
|
@@ -3,9 +3,9 @@ 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,
|
|
6
|
+
plugins: asyncio-1.4.0, anyio-4.14.2, respx-0.23.1
|
|
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 96 items
|
|
9
9
|
|
|
10
10
|
tests/tests_for_api_utilities.py::test_build_auth_defaults_to_private_token PASSED [ 1%]
|
|
11
11
|
tests/tests_for_api_utilities.py::test_build_auth_selects_bearer_for_oauth PASSED [ 2%]
|
|
@@ -15,78 +15,85 @@ tests/tests_for_api_utilities.py::test_paginate_consumes_x_next_page PASSED [ 5
|
|
|
15
15
|
tests/tests_for_api_utilities.py::test_async_retry_http_aborts_on_ignored_status PASSED [ 6%]
|
|
16
16
|
tests/tests_for_api_utilities.py::test_async_retry_http_retries_on_5xx PASSED [ 7%]
|
|
17
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 [
|
|
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/tests_for_merge_requests_service.py::test_get_merge_request PASSED [
|
|
44
|
-
tests/tests_for_merge_requests_service.py::test_comment_on_merge_request PASSED [
|
|
45
|
-
tests/tests_for_merge_requests_service.py::test_inline_diff_comment_payload_shape PASSED [
|
|
46
|
-
tests/tests_for_merge_requests_service.py::test_approve_and_unapprove PASSED [
|
|
47
|
-
tests/tests_for_models.py::test_config_api_base_url_strips_trailing_slash PASSED [
|
|
48
|
-
tests/tests_for_models.py::test_issue_extra_fields_ignored PASSED [
|
|
49
|
-
tests/tests_for_models.py::test_merge_request_basic PASSED [
|
|
50
|
-
tests/tests_for_models.py::test_config_env_loading PASSED [
|
|
51
|
-
tests/tests_for_oauth_cli.py::test_missing_client_id_errors PASSED [
|
|
52
|
-
tests/tests_for_oauth_cli.py::test_success_prints_only_token PASSED [
|
|
53
|
-
tests/tests_for_oauth_cli.py::test_reads_config_from_env PASSED [
|
|
54
|
-
tests/tests_for_oauth_cli.py::test_bad_redirect_port_errors PASSED [
|
|
55
|
-
tests/tests_for_oauth_cli.py::test_oauth_error_returns_one PASSED [
|
|
56
|
-
tests/tests_for_oauth_service.py::test_make_pkce_challenge_is_s256_of_verifier PASSED [
|
|
57
|
-
tests/tests_for_oauth_service.py::test_cache_path_is_per_host_and_private PASSED [
|
|
58
|
-
tests/tests_for_oauth_service.py::test_token_if_valid_expiry PASSED [
|
|
59
|
-
tests/tests_for_oauth_service.py::test_store_token_response_roundtrip PASSED [
|
|
60
|
-
tests/tests_for_oauth_service.py::test_store_token_response_requires_access_token PASSED [
|
|
61
|
-
tests/tests_for_oauth_service.py::test_refresh_access_token_success PASSED [
|
|
62
|
-
tests/tests_for_oauth_service.py::test_refresh_access_token_returns_none_on_error PASSED [
|
|
63
|
-
tests/tests_for_oauth_service.py::test_get_access_token_reuses_valid_cache PASSED [
|
|
64
|
-
tests/tests_for_oauth_service.py::test_get_access_token_refreshes_expired_cache PASSED [
|
|
65
|
-
tests/tests_for_oauth_service.py::test_get_access_token_requires_client_id PASSED [
|
|
66
|
-
tests/tests_for_packages_service.py::test_upload_generic_package_url_shape PASSED [
|
|
67
|
-
tests/tests_for_packages_service.py::test_download_generic_package PASSED [
|
|
68
|
-
tests/tests_for_packages_service.py::test_list_packages PASSED [
|
|
69
|
-
tests/tests_for_repo_files_service.py::test_get_file_url_encoded PASSED [
|
|
70
|
-
tests/tests_for_repo_files_service.py::test_download_file_writes_to_dest PASSED [
|
|
71
|
-
tests/tests_for_repo_files_service.py::test_download_directory_walks_tree PASSED [
|
|
72
|
-
tests/tests_for_sync_cli.py::test_missing_repo_errors PASSED [
|
|
73
|
-
tests/tests_for_sync_cli.py::test_missing_client_id_errors PASSED [
|
|
74
|
-
tests/tests_for_sync_cli.py::test_success_prints_path_and_forwards_args PASSED [
|
|
75
|
-
tests/tests_for_sync_cli.py::test_reads_config_from_env PASSED [
|
|
76
|
-
tests/tests_for_sync_cli.py::test_oauth_error_returns_one PASSED [
|
|
77
|
-
tests/tests_for_sync_cli.py::test_sync_error_returns_one PASSED [
|
|
78
|
-
tests/tests_for_sync_cli.py::test_bad_redirect_port_errors PASSED [
|
|
79
|
-
tests/tests_for_work_items_service.py::test_make_work_item_gid_passthrough_and_coerce PASSED [
|
|
80
|
-
tests/tests_for_work_items_service.py::test_work_item_from_node_flattens_widgets PASSED [
|
|
81
|
-
tests/tests_for_work_items_service.py::test_graphql_raises_on_errors PASSED [
|
|
82
|
-
tests/tests_for_work_items_service.py::test_paginate_graphql_follows_end_cursor PASSED [
|
|
83
|
-
tests/tests_for_work_items_service.py::test_get_work_item_by_global_id PASSED [
|
|
84
|
-
tests/tests_for_work_items_service.py::test_get_work_item_by_project_path_and_iid_uses_namespace_lookup PASSED [
|
|
85
|
-
tests/tests_for_work_items_service.py::
|
|
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%]
|
|
86
92
|
tests/tests_for_work_items_service.py::test_search_work_items_paginates_and_translates_state PASSED [ 86%]
|
|
87
93
|
tests/tests_for_work_items_service.py::test_search_work_items_group_scope_uses_group_field PASSED [ 87%]
|
|
88
|
-
tests/tests_for_work_items_service.py::
|
|
89
|
-
tests/tests_for_work_items_service.py::
|
|
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%]
|
|
90
97
|
tests/tests_for_work_items_service.py::test_set_work_item_state_mutation_errors_raise PASSED [ 91%]
|
|
91
98
|
tests/tests_for_work_items_service.py::test_list_work_item_notes_filters_system_notes PASSED [ 92%]
|
|
92
99
|
tests/tests_for_work_items_service.py::test_comment_on_work_item_creates_note PASSED [ 93%]
|
|
@@ -97,4 +104,4 @@ tests/tests_for_work_items_service.py::test_list_labels_for_project_includes_anc
|
|
|
97
104
|
tests/tests_for_work_items_service.py::test_create_label_passes_project_path_color_and_description PASSED [ 98%]
|
|
98
105
|
tests/tests_for_work_items_service.py::test_create_label_group_scope_passes_group_path PASSED [100%]
|
|
99
106
|
|
|
100
|
-
==============================
|
|
107
|
+
============================== 96 passed in 1.91s ==============================
|
|
@@ -33,6 +33,7 @@ def _wi_node(
|
|
|
33
33
|
labels: Optional[List[Dict[str, Any]]] = None,
|
|
34
34
|
assignees: Optional[List[Dict[str, Any]]] = None,
|
|
35
35
|
discussions: Optional[List[Dict[str, Any]]] = None,
|
|
36
|
+
status: Optional[Dict[str, Any]] = None,
|
|
36
37
|
numeric_id: int = 12345,
|
|
37
38
|
) -> Dict[str, Any]:
|
|
38
39
|
"""Build a fake GraphQL workItem node — only the bits the service uses."""
|
|
@@ -41,6 +42,8 @@ def _wi_node(
|
|
|
41
42
|
{"type": "LABELS", "labels": {"nodes": labels or []}},
|
|
42
43
|
{"type": "ASSIGNEES", "assignees": {"nodes": assignees or []}},
|
|
43
44
|
]
|
|
45
|
+
if status is not None:
|
|
46
|
+
widgets.append({"type": "STATUS", "status": status})
|
|
44
47
|
if discussions is not None:
|
|
45
48
|
widgets.append(
|
|
46
49
|
{
|
|
@@ -70,6 +73,16 @@ def _wi_node(
|
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
|
|
76
|
+
def _status_node(*, name: str = "Ready", id_: int = 3) -> Dict[str, Any]:
|
|
77
|
+
return {
|
|
78
|
+
"id": f"gid://gitlab/WorkItems::Statuses::Custom::Status/{id_}",
|
|
79
|
+
"name": name,
|
|
80
|
+
"iconName": "status-running",
|
|
81
|
+
"color": "#1f75cb",
|
|
82
|
+
"position": 10,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
73
86
|
def _label_node(*, id_: int = 99, title: str = "bug", color: str = "#FF0000") -> Dict[str, Any]:
|
|
74
87
|
return {
|
|
75
88
|
"id": f"gid://gitlab/ProjectLabel/{id_}",
|
|
@@ -256,6 +269,45 @@ async def test_get_work_item_by_project_path_and_iid_uses_namespace_lookup():
|
|
|
256
269
|
assert "namespace(fullPath: $path)" in captured["query"]
|
|
257
270
|
|
|
258
271
|
|
|
272
|
+
@respx.mock
|
|
273
|
+
async def test_get_work_item_omits_the_status_widget_by_default():
|
|
274
|
+
"""The widget is a 17.11+ experiment, so an unasked-for query must not select it."""
|
|
275
|
+
captured: Dict[str, Any] = {}
|
|
276
|
+
|
|
277
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
278
|
+
captured.update(_decode_request(request))
|
|
279
|
+
return httpx.Response(200, json={"data": {"workItem": _wi_node()}})
|
|
280
|
+
|
|
281
|
+
respx.post(GRAPHQL_URL).mock(side_effect=handler)
|
|
282
|
+
|
|
283
|
+
item = await work_items_service.get_work_item("gid://gitlab/WorkItem/12345")
|
|
284
|
+
assert "WorkItemWidgetStatus" not in captured["query"]
|
|
285
|
+
assert "__STATUS_WIDGET__" not in captured["query"]
|
|
286
|
+
assert item.status is None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@respx.mock
|
|
290
|
+
async def test_get_work_item_include_status_selects_the_widget_and_lifts_it():
|
|
291
|
+
captured: Dict[str, Any] = {}
|
|
292
|
+
|
|
293
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
294
|
+
captured.update(_decode_request(request))
|
|
295
|
+
return httpx.Response(
|
|
296
|
+
200,
|
|
297
|
+
json={"data": {"workItem": _wi_node(status=_status_node(name="Ready"))}},
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
respx.post(GRAPHQL_URL).mock(side_effect=handler)
|
|
301
|
+
|
|
302
|
+
item = await work_items_service.get_work_item(
|
|
303
|
+
"gid://gitlab/WorkItem/12345", include_status=True
|
|
304
|
+
)
|
|
305
|
+
assert "... on WorkItemWidgetStatus" in captured["query"]
|
|
306
|
+
assert item.status is not None
|
|
307
|
+
assert item.status.name == "Ready"
|
|
308
|
+
assert item.status.icon_name == "status-running"
|
|
309
|
+
|
|
310
|
+
|
|
259
311
|
@respx.mock
|
|
260
312
|
async def test_get_work_item_missing_raises():
|
|
261
313
|
respx.post(GRAPHQL_URL).mock(
|
|
@@ -267,6 +319,113 @@ async def test_get_work_item_missing_raises():
|
|
|
267
319
|
await work_items_service.get_work_item(99, project_path="group/proj")
|
|
268
320
|
|
|
269
321
|
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
# get_work_item_status
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@respx.mock
|
|
328
|
+
async def test_get_work_item_status_by_project_path_and_iid():
|
|
329
|
+
captured: Dict[str, Any] = {}
|
|
330
|
+
|
|
331
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
332
|
+
captured.update(_decode_request(request))
|
|
333
|
+
return httpx.Response(
|
|
334
|
+
200,
|
|
335
|
+
json={
|
|
336
|
+
"data": {
|
|
337
|
+
"namespace": {
|
|
338
|
+
"workItems": {
|
|
339
|
+
"nodes": [
|
|
340
|
+
{
|
|
341
|
+
"id": "gid://gitlab/WorkItem/12345",
|
|
342
|
+
# Shaped like a real response: GitLab returns
|
|
343
|
+
# every widget, so the non-status ones come
|
|
344
|
+
# back as empty objects.
|
|
345
|
+
"widgets": [{}, {}, {"status": _status_node()}],
|
|
346
|
+
}
|
|
347
|
+
]
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
respx.post(GRAPHQL_URL).mock(side_effect=handler)
|
|
355
|
+
|
|
356
|
+
status = await work_items_service.get_work_item_status(425, project_path="group/proj")
|
|
357
|
+
assert status is not None
|
|
358
|
+
assert status.name == "Ready"
|
|
359
|
+
assert captured["variables"] == {"path": "group/proj", "iid": "425"}
|
|
360
|
+
# The point of this helper is that it does NOT drag the full fragment along.
|
|
361
|
+
assert "...WorkItemFields" not in captured["query"]
|
|
362
|
+
assert "discussions" not in captured["query"]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@respx.mock
|
|
366
|
+
async def test_get_work_item_status_by_global_id_skips_the_iid_lookup():
|
|
367
|
+
captured: Dict[str, Any] = {}
|
|
368
|
+
|
|
369
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
370
|
+
captured.update(_decode_request(request))
|
|
371
|
+
return httpx.Response(
|
|
372
|
+
200,
|
|
373
|
+
json={
|
|
374
|
+
"data": {
|
|
375
|
+
"workItem": {
|
|
376
|
+
"id": "gid://gitlab/WorkItem/12345",
|
|
377
|
+
"widgets": [{"status": _status_node(name="In progress")}],
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
respx.post(GRAPHQL_URL).mock(side_effect=handler)
|
|
384
|
+
|
|
385
|
+
status = await work_items_service.get_work_item_status("gid://gitlab/WorkItem/12345")
|
|
386
|
+
assert status is not None and status.name == "In progress"
|
|
387
|
+
assert captured["variables"] == {"id": "gid://gitlab/WorkItem/12345"}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@respx.mock
|
|
391
|
+
async def test_get_work_item_status_returns_none_when_no_status_is_assigned():
|
|
392
|
+
respx.post(GRAPHQL_URL).mock(
|
|
393
|
+
return_value=httpx.Response(
|
|
394
|
+
200,
|
|
395
|
+
json={
|
|
396
|
+
"data": {
|
|
397
|
+
"namespace": {
|
|
398
|
+
"workItems": {
|
|
399
|
+
"nodes": [
|
|
400
|
+
{
|
|
401
|
+
"id": "gid://gitlab/WorkItem/12345",
|
|
402
|
+
"widgets": [{}, {"status": None}],
|
|
403
|
+
}
|
|
404
|
+
]
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
assert (
|
|
413
|
+
await work_items_service.get_work_item_status(425, project_path="group/proj")
|
|
414
|
+
is None
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@respx.mock
|
|
419
|
+
async def test_get_work_item_status_missing_item_raises():
|
|
420
|
+
respx.post(GRAPHQL_URL).mock(
|
|
421
|
+
return_value=httpx.Response(
|
|
422
|
+
200, json={"data": {"namespace": {"workItems": {"nodes": []}}}}
|
|
423
|
+
)
|
|
424
|
+
)
|
|
425
|
+
with pytest.raises(ExceptionGroup):
|
|
426
|
+
await work_items_service.get_work_item_status(99, project_path="group/proj")
|
|
427
|
+
|
|
428
|
+
|
|
270
429
|
# ---------------------------------------------------------------------------
|
|
271
430
|
# search_work_items
|
|
272
431
|
# ---------------------------------------------------------------------------
|
|
@@ -358,6 +517,35 @@ async def test_search_work_items_group_scope_uses_group_field():
|
|
|
358
517
|
assert "group(fullPath: $path)" in captured["query"]
|
|
359
518
|
|
|
360
519
|
|
|
520
|
+
@respx.mock
|
|
521
|
+
async def test_search_work_items_include_status_selects_the_widget():
|
|
522
|
+
captured: Dict[str, Any] = {}
|
|
523
|
+
|
|
524
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
525
|
+
captured.update(_decode_request(request))
|
|
526
|
+
return httpx.Response(
|
|
527
|
+
200,
|
|
528
|
+
json={
|
|
529
|
+
"data": {
|
|
530
|
+
"project": {
|
|
531
|
+
"workItems": {
|
|
532
|
+
"nodes": [_wi_node(status=_status_node(name="PJ Test"))],
|
|
533
|
+
"pageInfo": {"endCursor": None, "hasNextPage": False},
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
respx.post(GRAPHQL_URL).mock(side_effect=handler)
|
|
541
|
+
|
|
542
|
+
items = await work_items_service.search_work_items(
|
|
543
|
+
project_path="group/proj", include_status=True
|
|
544
|
+
)
|
|
545
|
+
assert "... on WorkItemWidgetStatus" in captured["query"]
|
|
546
|
+
assert items[0].status is not None and items[0].status.name == "PJ Test"
|
|
547
|
+
|
|
548
|
+
|
|
361
549
|
async def test_search_work_items_requires_one_scope():
|
|
362
550
|
with pytest.raises(ExceptionGroup):
|
|
363
551
|
await work_items_service.search_work_items()
|
|
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
|
|
File without changes
|