pjdev-gitlab 5.3.0__tar.gz → 2026.1007.82101__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 (46) hide show
  1. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/.gitignore +3 -1
  2. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/PKG-INFO +46 -3
  3. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/README.md +45 -2
  4. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_work_items/SKILL.md +74 -1
  5. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/__about__.py +1 -1
  6. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/__init__.py +1 -0
  7. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/api_utilities.py +11 -0
  8. pjdev_gitlab-2026.1007.82101/src/pjdev_gitlab/custom_fields_service.py +723 -0
  9. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/models.py +101 -0
  10. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/users_service.py +80 -0
  11. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/test_report_pjdev-gitlab.txt +154 -117
  12. pjdev_gitlab-2026.1007.82101/tests/tests_for_custom_fields_service.py +588 -0
  13. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/tests/tests_for_users_service.py +89 -0
  14. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/LICENSE.txt +0 -0
  15. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/pyproject.toml +0 -0
  16. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_issues/SKILL.md +0 -0
  17. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +0 -0
  18. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +0 -0
  19. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +0 -0
  20. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/config_service.py +0 -0
  21. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/git_sync_service.py +0 -0
  22. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/issues_service.py +0 -0
  23. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/merge_requests_service.py +0 -0
  24. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/notes_service.py +0 -0
  25. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/oauth_cli.py +0 -0
  26. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/oauth_service.py +0 -0
  27. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/packages_service.py +0 -0
  28. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/py.typed +0 -0
  29. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/repo_files_service.py +0 -0
  30. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/sync_cli.py +0 -0
  31. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/src/pjdev_gitlab/work_items_service.py +0 -0
  32. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/test.sh +0 -0
  33. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/tests/__init__.py +0 -0
  34. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/tests/conftest.py +0 -0
  35. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/tests/tests_for_api_utilities.py +0 -0
  36. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/tests/tests_for_git_sync_service.py +0 -0
  37. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/tests/tests_for_issues_service.py +0 -0
  38. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/tests/tests_for_merge_requests_service.py +0 -0
  39. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/tests/tests_for_models.py +0 -0
  40. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/tests/tests_for_notes_service.py +0 -0
  41. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/tests/tests_for_oauth_cli.py +0 -0
  42. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/tests/tests_for_oauth_service.py +0 -0
  43. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/tests/tests_for_packages_service.py +0 -0
  44. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/tests/tests_for_repo_files_service.py +0 -0
  45. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/tests/tests_for_sync_cli.py +0 -0
  46. {pjdev_gitlab-5.3.0 → pjdev_gitlab-2026.1007.82101}/tests/tests_for_work_items_service.py +0 -0
@@ -165,4 +165,6 @@ cython_debug/
165
165
  #.idea/
166
166
 
167
167
  **/uv.lock
168
- !./uv.lock
168
+ !./uv.lock
169
+ # Local scratch CLI for exercising libraries
170
+ cli_testing/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pjdev-gitlab
3
- Version: 5.3.0
3
+ Version: 2026.1007.82101
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
@@ -31,7 +31,7 @@ Description-Content-Type: text/markdown
31
31
  [![PyPI - Version](https://img.shields.io/pypi/v/pjdev-gitlab.svg)](https://pypi.org/project/pjdev-gitlab)
32
32
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pjdev-gitlab.svg)](https://pypi.org/project/pjdev-gitlab)
33
33
 
34
- Async GitLab automation SDK. Wraps both the GitLab REST API v4 and GraphQL API directly with `httpx.AsyncClient` and Pydantic models. Covers issues, workItems (the GraphQL replacement for issues — status, comments, labels), merge requests, repository files, and the generic package registry.
34
+ Async GitLab automation SDK. Wraps both the GitLab REST API v4 and GraphQL API directly with `httpx.AsyncClient` and Pydantic models. Covers issues, workItems (the GraphQL replacement for issues — status, comments, labels, custom fields), merge requests, repository files, and the generic package registry.
35
35
 
36
36
  -----
37
37
 
@@ -383,9 +383,52 @@ items = await work_items_service.search_work_items(
383
383
 
384
384
  Status names are configured per namespace — match them case-insensitively.
385
385
 
386
+ #### Custom fields
387
+
388
+ Custom fields are typed properties (single/multi select, number, text, date)
389
+ defined on a **top-level group** and inherited by every subgroup and project
390
+ under it (Premium/Ultimate, GitLab 18.0+). `custom_fields_service` accepts
391
+ either `group_path` or `project_path` everywhere and resolves it to that group:
392
+
393
+ ```python
394
+ from pjdev_gitlab import custom_fields_service
395
+ from pjdev_gitlab.models import CustomFieldType
396
+
397
+ # Define once — lands on "my-group", the project's top-level group.
398
+ team = await custom_fields_service.create_custom_field(
399
+ "Team",
400
+ CustomFieldType.SINGLE_SELECT,
401
+ project_path="my-group/my-project",
402
+ select_options=["Platform", "Payments"],
403
+ work_item_types=["Issue", "Task"], # names, ids or Global IDs
404
+ )
405
+ await custom_fields_service.create_custom_field(
406
+ "Story points", CustomFieldType.NUMBER,
407
+ group_path="my-group", work_item_types=["Issue"],
408
+ )
409
+
410
+ # Set values on a work item: keyed by field name, plain Python values.
411
+ await custom_fields_service.set_work_item_custom_fields(
412
+ 42,
413
+ {"Team": "Platform", "Story points": 5},
414
+ project_path="my-group/my-project",
415
+ )
416
+ values = await custom_fields_service.get_work_item_custom_field_values(
417
+ 42, project_path="my-group/my-project"
418
+ )
419
+ ```
420
+
421
+ Values are validated against the field definition before anything is sent;
422
+ `None` clears a field. Updating many items? Fetch the definitions once with
423
+ `list_custom_fields` and pass them as `fields=` to skip the per-call lookup.
424
+ `update_custom_field(select_options=...)` **replaces** the option list (dropped
425
+ options lose their selections) — use `add_custom_field_select_options` to
426
+ append. There is no delete: `archive_custom_field` hides a field and keeps its
427
+ values for `unarchive_custom_field`.
428
+
386
429
  ## Bundled agent skills
387
430
 
388
- 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.
431
+ 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 (including custom fields), merge requests, repository files, generic packages.
389
432
 
390
433
  ## License
391
434
 
@@ -3,7 +3,7 @@
3
3
  [![PyPI - Version](https://img.shields.io/pypi/v/pjdev-gitlab.svg)](https://pypi.org/project/pjdev-gitlab)
4
4
  [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pjdev-gitlab.svg)](https://pypi.org/project/pjdev-gitlab)
5
5
 
6
- Async GitLab automation SDK. Wraps both the GitLab REST API v4 and GraphQL API directly with `httpx.AsyncClient` and Pydantic models. Covers issues, workItems (the GraphQL replacement for issues — status, comments, labels), merge requests, repository files, and the generic package registry.
6
+ Async GitLab automation SDK. Wraps both the GitLab REST API v4 and GraphQL API directly with `httpx.AsyncClient` and Pydantic models. Covers issues, workItems (the GraphQL replacement for issues — status, comments, labels, custom fields), merge requests, repository files, and the generic package registry.
7
7
 
8
8
  -----
9
9
 
@@ -355,9 +355,52 @@ items = await work_items_service.search_work_items(
355
355
 
356
356
  Status names are configured per namespace — match them case-insensitively.
357
357
 
358
+ #### Custom fields
359
+
360
+ Custom fields are typed properties (single/multi select, number, text, date)
361
+ defined on a **top-level group** and inherited by every subgroup and project
362
+ under it (Premium/Ultimate, GitLab 18.0+). `custom_fields_service` accepts
363
+ either `group_path` or `project_path` everywhere and resolves it to that group:
364
+
365
+ ```python
366
+ from pjdev_gitlab import custom_fields_service
367
+ from pjdev_gitlab.models import CustomFieldType
368
+
369
+ # Define once — lands on "my-group", the project's top-level group.
370
+ team = await custom_fields_service.create_custom_field(
371
+ "Team",
372
+ CustomFieldType.SINGLE_SELECT,
373
+ project_path="my-group/my-project",
374
+ select_options=["Platform", "Payments"],
375
+ work_item_types=["Issue", "Task"], # names, ids or Global IDs
376
+ )
377
+ await custom_fields_service.create_custom_field(
378
+ "Story points", CustomFieldType.NUMBER,
379
+ group_path="my-group", work_item_types=["Issue"],
380
+ )
381
+
382
+ # Set values on a work item: keyed by field name, plain Python values.
383
+ await custom_fields_service.set_work_item_custom_fields(
384
+ 42,
385
+ {"Team": "Platform", "Story points": 5},
386
+ project_path="my-group/my-project",
387
+ )
388
+ values = await custom_fields_service.get_work_item_custom_field_values(
389
+ 42, project_path="my-group/my-project"
390
+ )
391
+ ```
392
+
393
+ Values are validated against the field definition before anything is sent;
394
+ `None` clears a field. Updating many items? Fetch the definitions once with
395
+ `list_custom_fields` and pass them as `fields=` to skip the per-call lookup.
396
+ `update_custom_field(select_options=...)` **replaces** the option list (dropped
397
+ options lose their selections) — use `add_custom_field_select_options` to
398
+ append. There is no delete: `archive_custom_field` hides a field and keeps its
399
+ values for `unarchive_custom_field`.
400
+
358
401
  ## Bundled agent skills
359
402
 
360
- 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.
403
+ 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 (including custom fields), merge requests, repository files, generic packages.
361
404
 
362
405
  ## License
363
406
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pjdev_gitlab_work_items
3
- description: Use this skill to automate GitLab workItems via pjdev-gitlab — the GraphQL-backed successor to issues. Covers searching/filtering by project or group with pagination, viewing/updating status, reading/posting/replying to comments, and managing labels (including creating new project or group labels). Trigger when the user asks to query, create, or modify GitLab workItems (or "issues" on a recent GitLab instance) programmatically.
3
+ description: Use this skill to automate GitLab workItems via pjdev-gitlab — the GraphQL-backed successor to issues. Covers searching/filtering by project or group with pagination, viewing/updating status, reading/posting/replying to comments, managing labels (including creating new project or group labels), and defining/setting custom fields (typed work item properties). Trigger when the user asks to query, create, or modify GitLab workItems (or "issues" on a recent GitLab instance) programmatically.
4
4
  ---
5
5
 
6
6
  # pjdev_gitlab_work_items
@@ -18,6 +18,7 @@ Reference docs (source of truth for behavior):
18
18
  - [GraphQL reference](https://docs.gitlab.com/api/graphql/reference/)
19
19
  - [WorkItems](https://docs.gitlab.com/user/work_items/)
20
20
  - [Labels](https://docs.gitlab.com/user/project/labels/)
21
+ - [Custom fields](https://docs.gitlab.com/user/work_items/custom_fields/)
21
22
  - [Comments and threads](https://docs.gitlab.com/user/discussions/)
22
23
 
23
24
  ## Setup
@@ -133,6 +134,78 @@ items = await work_items_service.search_work_items(
133
134
  namespace, so **match them case-insensitively** rather than hard-coding an enum —
134
135
  `"Code Complete"` on one instance may be `"code complete"` on another.
135
136
 
137
+ ## Custom fields
138
+
139
+ Typed properties on work items — `SINGLE_SELECT`, `MULTI_SELECT`, `NUMBER`,
140
+ `TEXT`, `DATE` — via `custom_fields_service`. Premium/Ultimate, GitLab 18.0+.
141
+
142
+ Definitions live **only on the top-level group** and are inherited by every
143
+ subgroup and project below it. Every helper takes `group_path=` or
144
+ `project_path=` and resolves to that group, so you never need to work out the
145
+ root yourself. Each field is shown only on the work item types it is
146
+ associated with — create it without `work_item_types` and it appears nowhere.
147
+
148
+ ```python
149
+ import datetime
150
+
151
+ from pjdev_gitlab import custom_fields_service
152
+ from pjdev_gitlab.models import CustomFieldType
153
+
154
+ # What exists already (active only by default; active=None for archived too)
155
+ fields = await custom_fields_service.list_custom_fields(project_path="group/proj")
156
+ types = await custom_fields_service.list_work_item_types(project_path="group/proj")
157
+
158
+ # Define (Maintainer+ on the top-level group)
159
+ team = await custom_fields_service.create_custom_field(
160
+ "Team",
161
+ CustomFieldType.SINGLE_SELECT,
162
+ project_path="group/proj", # -> created on "group"
163
+ select_options=["Platform", "Payments"],
164
+ work_item_types=["Issue", "Task"], # names, numeric ids or Global IDs
165
+ )
166
+
167
+ # Grow a select field without disturbing existing selections
168
+ team = await custom_fields_service.add_custom_field_select_options(team, ["Mobile"])
169
+
170
+ # Set values: keys are field names (case-insensitive) or Global IDs
171
+ await custom_fields_service.set_work_item_custom_fields(
172
+ 42,
173
+ {
174
+ "Team": "platform", # option value or option Global ID
175
+ "Components": ["API", "UI"], # multi-select replaces the selection
176
+ "Story points": 5,
177
+ "Customer": "Acme",
178
+ "Target date": datetime.date(2026, 11, 1),
179
+ "Old field": None, # None clears
180
+ },
181
+ project_path="group/proj",
182
+ )
183
+
184
+ # Read
185
+ for v in await custom_fields_service.get_work_item_custom_field_values(
186
+ 42, project_path="group/proj"
187
+ ):
188
+ print(v.custom_field.name, v.value, [o.value for o in v.selected_options])
189
+ ```
190
+
191
+ Gotchas:
192
+
193
+ - `set_work_item_custom_fields` validates every value against the field type
194
+ and its options **before** sending; an unknown field or option raises
195
+ `ValueError` (wrapped in an `ExceptionGroup` by the retry decorator) listing
196
+ what is valid. Fields you do not name are left untouched.
197
+ - GitLab answers "Invalid custom field ID" when the field is archived or not
198
+ associated with the item's work item type.
199
+ - Bulk updates: call `list_custom_fields` once and pass `fields=` — otherwise
200
+ every call re-lists the definitions.
201
+ - `update_custom_field(select_options=...)` **replaces** the whole option list:
202
+ pass existing `CustomFieldSelectOption` objects to keep them, strings to add.
203
+ Omitted options are deleted along with their selections on work items.
204
+ - No hard delete: `archive_custom_field` hides a field (values kept),
205
+ `unarchive_custom_field` restores it.
206
+ - Limits: 50 active fields per group, 10 per work item type, 50 options per
207
+ select field, 1024 characters per text value.
208
+
136
209
  ## Comments and threaded replies
137
210
 
138
211
  ```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.3.0"
4
+ __version__ = "2026.1007.82101"
@@ -9,6 +9,7 @@ logger.disable("pjdev_gitlab")
9
9
  __all__ = [
10
10
  "api_utilities",
11
11
  "config_service",
12
+ "custom_fields_service",
12
13
  "issues_service",
13
14
  "merge_requests_service",
14
15
  "models",
@@ -166,6 +166,7 @@ async def paginate_graphql(
166
166
  *,
167
167
  connection_path: List[str],
168
168
  page_size: int = 100,
169
+ require_connection: bool = False,
169
170
  client: Optional[httpx.AsyncClient] = None,
170
171
  ) -> List[Dict[str, Any]]:
171
172
  """Follow ``pageInfo.endCursor`` through a Relay-style GraphQL connection.
@@ -177,6 +178,10 @@ async def paginate_graphql(
177
178
  object (e.g. ``["group", "workItems"]``).
178
179
 
179
180
  The variable ``first`` is set to ``page_size`` on every page.
181
+
182
+ GraphQL answers an unknown or inaccessible ``fullPath`` with ``null`` rather
183
+ than an error, which reads as "no results". ``require_connection=True``
184
+ raises :class:`LookupError` instead when the connection is missing.
180
185
  """
181
186
 
182
187
  def _walk(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
@@ -195,6 +200,12 @@ async def paginate_graphql(
195
200
  data = await graphql(query, page_vars, client=_client)
196
201
  connection = _walk(data)
197
202
  if connection is None:
203
+ if require_connection:
204
+ raise LookupError(
205
+ f"{'.'.join(connection_path)} not found: "
206
+ f"{variables.get('path', '<path>')!r} does not exist "
207
+ "or is not visible to this token"
208
+ )
198
209
  break
199
210
  nodes.extend(connection.get("nodes") or [])
200
211
  page_info = connection.get("pageInfo") or {}