pjdev-gitlab 5.1.12__tar.gz → 5.1.14__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/PKG-INFO +89 -2
  2. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/README.md +87 -0
  3. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_work_items/SKILL.md +34 -1
  4. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/__about__.py +1 -1
  5. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/__init__.py +1 -0
  6. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/issues_service.py +26 -0
  7. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/merge_requests_service.py +16 -10
  8. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/models.py +34 -0
  9. pjdev_gitlab-5.1.14/src/pjdev_gitlab/notes_service.py +294 -0
  10. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/work_items_service.py +118 -6
  11. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/test_report_pjdev-gitlab.txt +108 -85
  12. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/tests/tests_for_issues_service.py +21 -0
  13. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/tests/tests_for_merge_requests_service.py +23 -0
  14. pjdev_gitlab-5.1.14/tests/tests_for_notes_service.py +250 -0
  15. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/tests/tests_for_work_items_service.py +188 -0
  16. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/.gitignore +0 -0
  17. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/LICENSE.txt +0 -0
  18. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/pyproject.toml +0 -0
  19. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_issues/SKILL.md +0 -0
  20. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +0 -0
  21. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +0 -0
  22. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +0 -0
  23. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/api_utilities.py +0 -0
  24. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/config_service.py +0 -0
  25. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/git_sync_service.py +0 -0
  26. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/oauth_cli.py +0 -0
  27. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/oauth_service.py +0 -0
  28. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/packages_service.py +0 -0
  29. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/py.typed +0 -0
  30. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/repo_files_service.py +0 -0
  31. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/src/pjdev_gitlab/sync_cli.py +0 -0
  32. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/test.sh +0 -0
  33. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/tests/__init__.py +0 -0
  34. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/tests/conftest.py +0 -0
  35. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/tests/tests_for_api_utilities.py +0 -0
  36. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/tests/tests_for_git_sync_service.py +0 -0
  37. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/tests/tests_for_models.py +0 -0
  38. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/tests/tests_for_oauth_cli.py +0 -0
  39. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/tests/tests_for_oauth_service.py +0 -0
  40. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/tests/tests_for_packages_service.py +0 -0
  41. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/tests/tests_for_repo_files_service.py +0 -0
  42. {pjdev_gitlab-5.1.12 → pjdev_gitlab-5.1.14}/tests/tests_for_sync_cli.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: pjdev-gitlab
3
- Version: 5.1.12
3
+ Version: 5.1.14
4
4
  Project-URL: Documentation, https://gitlab.purplejay.io/keystone/python/-/tree/main/pjdev-gitlab/README.md
5
5
  Project-URL: Issues, https://gitlab.purplejay.io/keystone/python/-/issues
6
6
  Project-URL: Source, https://gitlab.purplejay.io/keystone/python
@@ -192,6 +192,68 @@ asyncio.run(main())
192
192
 
193
193
  Run it: `op run --env-file=.env.op -- python my_script.py`.
194
194
 
195
+ ### Notes (comments) and replies
196
+
197
+ `notes_service` reads and replies to comments on **both** issues and merge
198
+ requests — they share the same endpoint shape, so one `Noteable` parameter
199
+ selects which.
200
+
201
+ GitLab only lets you reply to a *discussion* (a thread), never to a note id, so
202
+ `reply_to_note` resolves the note to its containing thread for you:
203
+
204
+ ```python
205
+ import asyncio
206
+ from pjdev_gitlab import config_service, notes_service
207
+ from pjdev_gitlab.notes_service import Noteable, QuickActionOnlyError
208
+
209
+ async def main() -> None:
210
+ config_service.init()
211
+
212
+ # Fetch. Notes come back newest-first; pass sort="asc" to read in order.
213
+ notes = await notes_service.list_notes(
214
+ "my-group/my-project", Noteable.merge_request, 166,
215
+ include_system=False, # drop "assigned to @x", "changed milestone", ...
216
+ sort="asc",
217
+ )
218
+
219
+ # Threads, when you need a discussion id to answer into.
220
+ threads = await notes_service.list_discussions(
221
+ "my-group/my-project", Noteable.merge_request, 166
222
+ )
223
+
224
+ # Reply, addressing the note you're answering.
225
+ await notes_service.reply_to_note(
226
+ "my-group/my-project", Noteable.merge_request, 166,
227
+ note_id=notes[0].id,
228
+ body="Good catch — fixed in the next push.",
229
+ )
230
+
231
+ # Or straight into a known thread.
232
+ await notes_service.reply_to_discussion(
233
+ "my-group/my-project", Noteable.issue, 730,
234
+ discussion_id=threads[0].id,
235
+ body="Verified on TEST1.",
236
+ )
237
+
238
+ asyncio.run(main())
239
+ ```
240
+
241
+ `list_merge_request_notes` / `list_issue_notes` remain as thin aliases for
242
+ `list_notes`.
243
+
244
+ A body made up of **only** quick actions (e.g. `/milestone %"v2.0.6"`) creates no
245
+ note — GitLab runs the command and returns the commands it executed instead. The
246
+ reply helpers raise `QuickActionOnlyError` for that case, carrying the outcome so
247
+ you can confirm the side effect landed:
248
+
249
+ ```python
250
+ try:
251
+ await notes_service.reply_to_discussion(..., body='/milestone %"v2.0.6"')
252
+ except QuickActionOnlyError as exc:
253
+ print(exc.summary) # ['Set milestone to %"v2.0.6".']
254
+ print(exc.commands_changes) # {'milestone': {...}}
255
+ ```
256
+
195
257
  ### WorkItems (GraphQL)
196
258
 
197
259
  `workItem` is GitLab's unified replacement for the legacy `Issue` type — use
@@ -234,6 +296,31 @@ async def main() -> None:
234
296
  asyncio.run(main())
235
297
  ```
236
298
 
299
+ #### Custom statuses
300
+
301
+ The workflow status an instance defines for itself (`Ready`, `In progress`, ...)
302
+ is neither a label nor the open/closed `state`, and REST does not expose it.
303
+ Reading it is opt-in, because GitLab ships the status widget as an experiment
304
+ (**17.11+**) and selecting it against an older instance fails the whole query:
305
+
306
+ ```python
307
+ # Just the status — one small query. Preferred on a hot path.
308
+ status = await work_items_service.get_work_item_status(
309
+ 42, project_path="my-group/my-project"
310
+ )
311
+ print(status.name if status else "no status set")
312
+
313
+ # Or folded into a read you were making anyway.
314
+ item = await work_items_service.get_work_item(
315
+ 42, project_path="my-group/my-project", include_status=True
316
+ )
317
+ items = await work_items_service.search_work_items(
318
+ project_path="my-group/my-project", include_status=True
319
+ )
320
+ ```
321
+
322
+ Status names are configured per namespace — match them case-insensitively.
323
+
237
324
  ## Bundled agent skills
238
325
 
239
326
  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.
@@ -164,6 +164,68 @@ asyncio.run(main())
164
164
 
165
165
  Run it: `op run --env-file=.env.op -- python my_script.py`.
166
166
 
167
+ ### Notes (comments) and replies
168
+
169
+ `notes_service` reads and replies to comments on **both** issues and merge
170
+ requests — they share the same endpoint shape, so one `Noteable` parameter
171
+ selects which.
172
+
173
+ GitLab only lets you reply to a *discussion* (a thread), never to a note id, so
174
+ `reply_to_note` resolves the note to its containing thread for you:
175
+
176
+ ```python
177
+ import asyncio
178
+ from pjdev_gitlab import config_service, notes_service
179
+ from pjdev_gitlab.notes_service import Noteable, QuickActionOnlyError
180
+
181
+ async def main() -> None:
182
+ config_service.init()
183
+
184
+ # Fetch. Notes come back newest-first; pass sort="asc" to read in order.
185
+ notes = await notes_service.list_notes(
186
+ "my-group/my-project", Noteable.merge_request, 166,
187
+ include_system=False, # drop "assigned to @x", "changed milestone", ...
188
+ sort="asc",
189
+ )
190
+
191
+ # Threads, when you need a discussion id to answer into.
192
+ threads = await notes_service.list_discussions(
193
+ "my-group/my-project", Noteable.merge_request, 166
194
+ )
195
+
196
+ # Reply, addressing the note you're answering.
197
+ await notes_service.reply_to_note(
198
+ "my-group/my-project", Noteable.merge_request, 166,
199
+ note_id=notes[0].id,
200
+ body="Good catch — fixed in the next push.",
201
+ )
202
+
203
+ # Or straight into a known thread.
204
+ await notes_service.reply_to_discussion(
205
+ "my-group/my-project", Noteable.issue, 730,
206
+ discussion_id=threads[0].id,
207
+ body="Verified on TEST1.",
208
+ )
209
+
210
+ asyncio.run(main())
211
+ ```
212
+
213
+ `list_merge_request_notes` / `list_issue_notes` remain as thin aliases for
214
+ `list_notes`.
215
+
216
+ A body made up of **only** quick actions (e.g. `/milestone %"v2.0.6"`) creates no
217
+ note — GitLab runs the command and returns the commands it executed instead. The
218
+ reply helpers raise `QuickActionOnlyError` for that case, carrying the outcome so
219
+ you can confirm the side effect landed:
220
+
221
+ ```python
222
+ try:
223
+ await notes_service.reply_to_discussion(..., body='/milestone %"v2.0.6"')
224
+ except QuickActionOnlyError as exc:
225
+ print(exc.summary) # ['Set milestone to %"v2.0.6".']
226
+ print(exc.commands_changes) # {'milestone': {...}}
227
+ ```
228
+
167
229
  ### WorkItems (GraphQL)
168
230
 
169
231
  `workItem` is GitLab's unified replacement for the legacy `Issue` type — use
@@ -206,6 +268,31 @@ async def main() -> None:
206
268
  asyncio.run(main())
207
269
  ```
208
270
 
271
+ #### Custom statuses
272
+
273
+ The workflow status an instance defines for itself (`Ready`, `In progress`, ...)
274
+ is neither a label nor the open/closed `state`, and REST does not expose it.
275
+ Reading it is opt-in, because GitLab ships the status widget as an experiment
276
+ (**17.11+**) and selecting it against an older instance fails the whole query:
277
+
278
+ ```python
279
+ # Just the status — one small query. Preferred on a hot path.
280
+ status = await work_items_service.get_work_item_status(
281
+ 42, project_path="my-group/my-project"
282
+ )
283
+ print(status.name if status else "no status set")
284
+
285
+ # Or folded into a read you were making anyway.
286
+ item = await work_items_service.get_work_item(
287
+ 42, project_path="my-group/my-project", include_status=True
288
+ )
289
+ items = await work_items_service.search_work_items(
290
+ project_path="my-group/my-project", include_status=True
291
+ )
292
+ ```
293
+
294
+ Status names are configured per namespace — match them case-insensitively.
295
+
209
296
  ## Bundled agent skills
210
297
 
211
298
  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.14"
@@ -12,6 +12,7 @@ __all__ = [
12
12
  "issues_service",
13
13
  "merge_requests_service",
14
14
  "models",
15
+ "notes_service",
15
16
  "oauth_cli",
16
17
  "oauth_service",
17
18
  "packages_service",
@@ -6,6 +6,7 @@ from typing import Any, Dict, List, Literal, Optional
6
6
  from httpx import AsyncClient
7
7
  from loguru import logger
8
8
 
9
+ from pjdev_gitlab import notes_service
9
10
  from pjdev_gitlab.api_utilities import (
10
11
  async_retry_http,
11
12
  encode_path_segment,
@@ -227,6 +228,31 @@ async def comment_on_issue(
227
228
  return await _exec(client)
228
229
 
229
230
 
231
+ async def list_issue_notes(
232
+ project_id: ProjectId,
233
+ issue_iid: int,
234
+ *,
235
+ include_system: bool = True,
236
+ page_size: int = 100,
237
+ client: Optional[AsyncClient] = None,
238
+ ) -> List[Note]:
239
+ """Fetch every note on an issue.
240
+
241
+ Thin alias for :func:`pjdev_gitlab.notes_service.list_notes`; see that module
242
+ for threads, single-note reads, and replying. Deliberately undecorated -- the
243
+ delegate already retries, and stacking the decorator would square the attempt
244
+ count.
245
+ """
246
+ return await notes_service.list_notes(
247
+ project_id,
248
+ notes_service.Noteable.issue,
249
+ issue_iid,
250
+ include_system=include_system,
251
+ page_size=page_size,
252
+ client=client,
253
+ )
254
+
255
+
230
256
  @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
231
257
  async def start_issue_discussion(
232
258
  project_id: ProjectId,
@@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional
2
2
 
3
3
  from httpx import AsyncClient
4
4
 
5
+ from pjdev_gitlab import notes_service
5
6
  from pjdev_gitlab.api_utilities import (
6
7
  async_retry_http,
7
8
  encode_path_segment,
@@ -181,24 +182,29 @@ async def comment_on_merge_request_diff(
181
182
  return await _exec(client)
182
183
 
183
184
 
184
- @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
185
185
  async def list_merge_request_notes(
186
186
  project_id: ProjectId,
187
187
  mr_iid: int,
188
188
  *,
189
+ include_system: bool = True,
189
190
  page_size: int = 100,
190
191
  client: Optional[AsyncClient] = None,
191
192
  ) -> List[Note]:
192
- url = f"{_mr_base(project_id, mr_iid)}/notes"
193
+ """Fetch every note on a merge request.
193
194
 
194
- async def _exec(_client: AsyncClient) -> List[Note]:
195
- rows = await paginate(_client, url, params={}, page_size=page_size)
196
- return [Note.model_validate(row) for row in rows]
197
-
198
- if client is None:
199
- async with http_client() as _client:
200
- return await _exec(_client)
201
- return await _exec(client)
195
+ Thin alias for :func:`pjdev_gitlab.notes_service.list_notes`; see that module
196
+ for threads, single-note reads, and replying. Deliberately undecorated -- the
197
+ delegate already retries, and stacking the decorator would square the attempt
198
+ count.
199
+ """
200
+ return await notes_service.list_notes(
201
+ project_id,
202
+ notes_service.Noteable.merge_request,
203
+ mr_iid,
204
+ include_system=include_system,
205
+ page_size=page_size,
206
+ client=client,
207
+ )
202
208
 
203
209
 
204
210
  @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
@@ -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"] = [