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.
Files changed (40) hide show
  1. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/PKG-INFO +26 -1
  2. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/README.md +25 -0
  3. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_work_items/SKILL.md +34 -1
  4. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/__about__.py +1 -1
  5. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/models.py +34 -0
  6. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/work_items_service.py +118 -6
  7. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/test_report_pjdev-gitlab.txt +80 -73
  8. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_work_items_service.py +188 -0
  9. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/.gitignore +0 -0
  10. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/LICENSE.txt +0 -0
  11. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/pyproject.toml +0 -0
  12. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_issues/SKILL.md +0 -0
  13. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +0 -0
  14. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +0 -0
  15. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +0 -0
  16. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/__init__.py +0 -0
  17. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/api_utilities.py +0 -0
  18. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/config_service.py +0 -0
  19. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/git_sync_service.py +0 -0
  20. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/issues_service.py +0 -0
  21. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/merge_requests_service.py +0 -0
  22. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/oauth_cli.py +0 -0
  23. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/oauth_service.py +0 -0
  24. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/packages_service.py +0 -0
  25. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/py.typed +0 -0
  26. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/repo_files_service.py +0 -0
  27. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/sync_cli.py +0 -0
  28. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/test.sh +0 -0
  29. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/__init__.py +0 -0
  30. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/conftest.py +0 -0
  31. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_api_utilities.py +0 -0
  32. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_git_sync_service.py +0 -0
  33. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_issues_service.py +0 -0
  34. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_merge_requests_service.py +0 -0
  35. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_models.py +0 -0
  36. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_oauth_cli.py +0 -0
  37. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_oauth_service.py +0 -0
  38. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_packages_service.py +0 -0
  39. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.13}/tests/tests_for_repo_files_service.py +0 -0
  40. {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.12
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
- ## View / update status
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
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2026-present Chris O'Neill <chris@purplejay.io>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
- __version__ = "5.1.12"
4
+ __version__ = "5.1.13"
@@ -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
- _WORK_ITEM_FRAGMENT = (
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
- _WORK_ITEM_FRAGMENT
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
- _WORK_ITEM_FRAGMENT
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
- _WORK_ITEM_FRAGMENT
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
- _WORK_ITEM_FRAGMENT
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
- _WORK_ITEM_FRAGMENT
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, respx-0.23.1, anyio-4.14.2
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 89 items
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 [ 10%]
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 [ 11%]
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 [ 12%]
21
- tests/tests_for_git_sync_service.py::test_build_remote_url_empty_repo_errors PASSED [ 13%]
22
- tests/tests_for_git_sync_service.py::test_default_target_dir[developers/claude-skills-claude-skills] PASSED [ 14%]
23
- tests/tests_for_git_sync_service.py::test_default_target_dir[g/s/name.git-name] PASSED [ 15%]
24
- tests/tests_for_git_sync_service.py::test_default_target_dir[solo-solo] PASSED [ 16%]
25
- tests/tests_for_git_sync_service.py::test_clone_when_target_missing PASSED [ 17%]
26
- tests/tests_for_git_sync_service.py::test_clone_passes_token_via_askpass_env_only PASSED [ 19%]
27
- tests/tests_for_git_sync_service.py::test_clone_with_branch PASSED [ 20%]
28
- tests/tests_for_git_sync_service.py::test_update_when_target_is_repo PASSED [ 21%]
29
- tests/tests_for_git_sync_service.py::test_update_with_existing_branch_checks_out_and_ff PASSED [ 22%]
30
- tests/tests_for_git_sync_service.py::test_update_with_new_branch_creates_from_fetch_head PASSED [ 23%]
31
- tests/tests_for_git_sync_service.py::test_update_adds_origin_when_absent PASSED [ 24%]
32
- tests/tests_for_git_sync_service.py::test_update_detached_head_errors PASSED [ 25%]
33
- tests/tests_for_git_sync_service.py::test_clone_failure_removes_partial_target PASSED [ 26%]
34
- tests/tests_for_git_sync_service.py::test_target_exists_but_not_a_repo_errors PASSED [ 28%]
35
- tests/tests_for_git_sync_service.py::test_missing_token_errors PASSED [ 29%]
36
- tests/tests_for_git_sync_service.py::test_ff_only_failure_propagates PASSED [ 30%]
37
- tests/tests_for_issues_service.py::test_search_issues_paginates PASSED [ 31%]
38
- tests/tests_for_issues_service.py::test_create_issue_with_image_uploads_first_then_creates PASSED [ 32%]
39
- tests/tests_for_issues_service.py::test_set_issue_state PASSED [ 33%]
40
- tests/tests_for_issues_service.py::test_set_issue_labels_modes PASSED [ 34%]
41
- tests/tests_for_issues_service.py::test_aggregate_issues_by_label PASSED [ 35%]
42
- tests/tests_for_issues_service.py::test_comment_on_issue PASSED [ 37%]
43
- tests/tests_for_merge_requests_service.py::test_get_merge_request PASSED [ 38%]
44
- tests/tests_for_merge_requests_service.py::test_comment_on_merge_request PASSED [ 39%]
45
- tests/tests_for_merge_requests_service.py::test_inline_diff_comment_payload_shape PASSED [ 40%]
46
- tests/tests_for_merge_requests_service.py::test_approve_and_unapprove PASSED [ 41%]
47
- tests/tests_for_models.py::test_config_api_base_url_strips_trailing_slash PASSED [ 42%]
48
- tests/tests_for_models.py::test_issue_extra_fields_ignored PASSED [ 43%]
49
- tests/tests_for_models.py::test_merge_request_basic PASSED [ 44%]
50
- tests/tests_for_models.py::test_config_env_loading PASSED [ 46%]
51
- tests/tests_for_oauth_cli.py::test_missing_client_id_errors PASSED [ 47%]
52
- tests/tests_for_oauth_cli.py::test_success_prints_only_token PASSED [ 48%]
53
- tests/tests_for_oauth_cli.py::test_reads_config_from_env PASSED [ 49%]
54
- tests/tests_for_oauth_cli.py::test_bad_redirect_port_errors PASSED [ 50%]
55
- tests/tests_for_oauth_cli.py::test_oauth_error_returns_one PASSED [ 51%]
56
- tests/tests_for_oauth_service.py::test_make_pkce_challenge_is_s256_of_verifier PASSED [ 52%]
57
- tests/tests_for_oauth_service.py::test_cache_path_is_per_host_and_private PASSED [ 53%]
58
- tests/tests_for_oauth_service.py::test_token_if_valid_expiry PASSED [ 55%]
59
- tests/tests_for_oauth_service.py::test_store_token_response_roundtrip PASSED [ 56%]
60
- tests/tests_for_oauth_service.py::test_store_token_response_requires_access_token PASSED [ 57%]
61
- tests/tests_for_oauth_service.py::test_refresh_access_token_success PASSED [ 58%]
62
- tests/tests_for_oauth_service.py::test_refresh_access_token_returns_none_on_error PASSED [ 59%]
63
- tests/tests_for_oauth_service.py::test_get_access_token_reuses_valid_cache PASSED [ 60%]
64
- tests/tests_for_oauth_service.py::test_get_access_token_refreshes_expired_cache PASSED [ 61%]
65
- tests/tests_for_oauth_service.py::test_get_access_token_requires_client_id PASSED [ 62%]
66
- tests/tests_for_packages_service.py::test_upload_generic_package_url_shape PASSED [ 64%]
67
- tests/tests_for_packages_service.py::test_download_generic_package PASSED [ 65%]
68
- tests/tests_for_packages_service.py::test_list_packages PASSED [ 66%]
69
- tests/tests_for_repo_files_service.py::test_get_file_url_encoded PASSED [ 67%]
70
- tests/tests_for_repo_files_service.py::test_download_file_writes_to_dest PASSED [ 68%]
71
- tests/tests_for_repo_files_service.py::test_download_directory_walks_tree PASSED [ 69%]
72
- tests/tests_for_sync_cli.py::test_missing_repo_errors PASSED [ 70%]
73
- tests/tests_for_sync_cli.py::test_missing_client_id_errors PASSED [ 71%]
74
- tests/tests_for_sync_cli.py::test_success_prints_path_and_forwards_args PASSED [ 73%]
75
- tests/tests_for_sync_cli.py::test_reads_config_from_env PASSED [ 74%]
76
- tests/tests_for_sync_cli.py::test_oauth_error_returns_one PASSED [ 75%]
77
- tests/tests_for_sync_cli.py::test_sync_error_returns_one PASSED [ 76%]
78
- tests/tests_for_sync_cli.py::test_bad_redirect_port_errors PASSED [ 77%]
79
- tests/tests_for_work_items_service.py::test_make_work_item_gid_passthrough_and_coerce PASSED [ 78%]
80
- tests/tests_for_work_items_service.py::test_work_item_from_node_flattens_widgets PASSED [ 79%]
81
- tests/tests_for_work_items_service.py::test_graphql_raises_on_errors PASSED [ 80%]
82
- tests/tests_for_work_items_service.py::test_paginate_graphql_follows_end_cursor PASSED [ 82%]
83
- tests/tests_for_work_items_service.py::test_get_work_item_by_global_id PASSED [ 83%]
84
- tests/tests_for_work_items_service.py::test_get_work_item_by_project_path_and_iid_uses_namespace_lookup PASSED [ 84%]
85
- tests/tests_for_work_items_service.py::test_get_work_item_missing_raises PASSED [ 85%]
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::test_search_work_items_requires_one_scope PASSED [ 88%]
89
- tests/tests_for_work_items_service.py::test_set_work_item_state_closes_via_workItemUpdate PASSED [ 89%]
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
- ============================== 89 passed in 1.80s ==============================
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