pjdev-gitlab 5.1.7__tar.gz → 5.1.9__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 (36) hide show
  1. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/PKG-INFO +73 -4
  2. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/README.md +72 -3
  3. pjdev_gitlab-5.1.9/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_work_items/SKILL.md +173 -0
  4. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/__about__.py +1 -1
  5. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/__init__.py +2 -0
  6. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/api_utilities.py +153 -1
  7. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/config_service.py +3 -0
  8. pjdev_gitlab-5.1.9/src/pjdev_gitlab/models.py +369 -0
  9. pjdev_gitlab-5.1.9/src/pjdev_gitlab/oauth_service.py +354 -0
  10. pjdev_gitlab-5.1.9/src/pjdev_gitlab/work_items_service.py +720 -0
  11. pjdev_gitlab-5.1.9/test_report_pjdev-gitlab.txt +69 -0
  12. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/tests/tests_for_api_utilities.py +33 -0
  13. pjdev_gitlab-5.1.9/tests/tests_for_oauth_service.py +142 -0
  14. pjdev_gitlab-5.1.9/tests/tests_for_work_items_service.py +681 -0
  15. pjdev_gitlab-5.1.7/src/pjdev_gitlab/models.py +0 -173
  16. pjdev_gitlab-5.1.7/test_report_pjdev-gitlab.txt +0 -36
  17. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/.gitignore +0 -0
  18. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/LICENSE.txt +0 -0
  19. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/pyproject.toml +0 -0
  20. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_issues/SKILL.md +0 -0
  21. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +0 -0
  22. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +0 -0
  23. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +0 -0
  24. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/issues_service.py +0 -0
  25. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/merge_requests_service.py +0 -0
  26. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/packages_service.py +0 -0
  27. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/py.typed +0 -0
  28. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/repo_files_service.py +0 -0
  29. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/test.sh +0 -0
  30. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/tests/__init__.py +0 -0
  31. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/tests/conftest.py +0 -0
  32. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/tests/tests_for_issues_service.py +0 -0
  33. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/tests/tests_for_merge_requests_service.py +0 -0
  34. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/tests/tests_for_models.py +0 -0
  35. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/tests/tests_for_packages_service.py +0 -0
  36. {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/tests/tests_for_repo_files_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pjdev-gitlab
3
- Version: 5.1.7
3
+ Version: 5.1.9
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 the GitLab REST API v4 directly with `httpx.AsyncClient` and Pydantic models. Covers issues, 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), merge requests, repository files, and the generic package registry.
35
35
 
36
36
  -----
37
37
 
@@ -48,11 +48,38 @@ via `init()`):
48
48
 
49
49
  | variable | required | purpose |
50
50
  | --- | --- | --- |
51
- | `GL_TOKEN` | yes | personal/group/project access token (`api` scope) |
51
+ | `GL_TOKEN` | yes | access token (`api` scope) — a PAT, or an OAuth token with `GL_AUTH_SCHEME=bearer` |
52
52
  | `GL_GITLAB_URL` | yes | base URL, e.g. `https://gitlab.com` |
53
+ | `GL_AUTH_SCHEME` | no | `private-token` (default, PAT header) or `bearer` (OAuth access token) |
53
54
  | `GL_DEFAULT_PROJECT_ID` | no | default project for service helpers |
54
55
  | `GL_OUTPUT_PATH` | no | directory for downloaded files |
55
56
 
57
+ ### OAuth 2.0 (per-user attribution)
58
+
59
+ To attribute API writes to a real person instead of a shared bot token, mint a
60
+ short-lived OAuth access token with the interactive Authorization Code + PKCE
61
+ flow and initialize with `auth_scheme="bearer"`:
62
+
63
+ ```python
64
+ from pjdev_gitlab import config_service, oauth_service
65
+
66
+ # Opens a browser for sign-in/consent on first run; caches + refreshes the token
67
+ # per host under ~/.config/pjdev-gitlab/tokens/ (0600) on subsequent runs.
68
+ token = oauth_service.get_access_token(
69
+ gitlab_url="https://gitlab.example.com",
70
+ client_id="<registered PKCE app client id>",
71
+ )
72
+ config_service.init(
73
+ token=token, gitlab_url="https://gitlab.example.com", auth_scheme="bearer"
74
+ )
75
+ ```
76
+
77
+ The OAuth application must be registered as a non-confidential (PKCE) app with
78
+ **scope** `api` and **Redirect URI** exactly `http://localhost:7331/callback`
79
+ (override the port via `get_access_token(redirect_port=...)`). A `bearer` token
80
+ is sent as `Authorization: Bearer <token>`; the default `private-token` scheme
81
+ sends `PRIVATE-TOKEN` for personal/project/group access tokens.
82
+
56
83
  ### Recommended: 1Password + `op run`
57
84
 
58
85
  On a developer laptop, keep the token in 1Password and inject it into the host
@@ -120,9 +147,51 @@ asyncio.run(main())
120
147
 
121
148
  Run it: `op run --env-file=.env.op -- python my_script.py`.
122
149
 
150
+ ### WorkItems (GraphQL)
151
+
152
+ `workItem` is GitLab's unified replacement for the legacy `Issue` type — use
153
+ `work_items_service` for status/comments/labels on modern GitLab instances:
154
+
155
+ ```python
156
+ import asyncio
157
+ from pjdev_gitlab import config_service, work_items_service
158
+ from pjdev_gitlab.models import WorkItemState, WorkItemStateEvent
159
+
160
+ async def main() -> None:
161
+ config_service.init()
162
+
163
+ open_bugs = await work_items_service.search_work_items(
164
+ project_path="my-group/my-project",
165
+ state=WorkItemState.OPEN,
166
+ labels=["bug"],
167
+ search="timeout",
168
+ )
169
+
170
+ label = await work_items_service.create_label(
171
+ "needs-review", project_path="my-group/my-project", color="#FFAA00"
172
+ )
173
+ await work_items_service.set_work_item_labels(
174
+ open_bugs[0].iid,
175
+ [label.id],
176
+ mode="add",
177
+ project_path="my-group/my-project",
178
+ )
179
+ await work_items_service.comment_on_work_item(
180
+ open_bugs[0].iid,
181
+ "Triaged — assigning a reviewer.",
182
+ project_path="my-group/my-project",
183
+ )
184
+ await work_items_service.set_work_item_state(
185
+ open_bugs[0].iid, WorkItemStateEvent.CLOSE,
186
+ project_path="my-group/my-project",
187
+ )
188
+
189
+ asyncio.run(main())
190
+ ```
191
+
123
192
  ## Bundled agent skills
124
193
 
125
- 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, merge requests, repository files, generic packages.
194
+ 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.
126
195
 
127
196
  ## License
128
197
 
@@ -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 the GitLab REST API v4 directly with `httpx.AsyncClient` and Pydantic models. Covers issues, 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), merge requests, repository files, and the generic package registry.
7
7
 
8
8
  -----
9
9
 
@@ -20,11 +20,38 @@ via `init()`):
20
20
 
21
21
  | variable | required | purpose |
22
22
  | --- | --- | --- |
23
- | `GL_TOKEN` | yes | personal/group/project access token (`api` scope) |
23
+ | `GL_TOKEN` | yes | access token (`api` scope) — a PAT, or an OAuth token with `GL_AUTH_SCHEME=bearer` |
24
24
  | `GL_GITLAB_URL` | yes | base URL, e.g. `https://gitlab.com` |
25
+ | `GL_AUTH_SCHEME` | no | `private-token` (default, PAT header) or `bearer` (OAuth access token) |
25
26
  | `GL_DEFAULT_PROJECT_ID` | no | default project for service helpers |
26
27
  | `GL_OUTPUT_PATH` | no | directory for downloaded files |
27
28
 
29
+ ### OAuth 2.0 (per-user attribution)
30
+
31
+ To attribute API writes to a real person instead of a shared bot token, mint a
32
+ short-lived OAuth access token with the interactive Authorization Code + PKCE
33
+ flow and initialize with `auth_scheme="bearer"`:
34
+
35
+ ```python
36
+ from pjdev_gitlab import config_service, oauth_service
37
+
38
+ # Opens a browser for sign-in/consent on first run; caches + refreshes the token
39
+ # per host under ~/.config/pjdev-gitlab/tokens/ (0600) on subsequent runs.
40
+ token = oauth_service.get_access_token(
41
+ gitlab_url="https://gitlab.example.com",
42
+ client_id="<registered PKCE app client id>",
43
+ )
44
+ config_service.init(
45
+ token=token, gitlab_url="https://gitlab.example.com", auth_scheme="bearer"
46
+ )
47
+ ```
48
+
49
+ The OAuth application must be registered as a non-confidential (PKCE) app with
50
+ **scope** `api` and **Redirect URI** exactly `http://localhost:7331/callback`
51
+ (override the port via `get_access_token(redirect_port=...)`). A `bearer` token
52
+ is sent as `Authorization: Bearer <token>`; the default `private-token` scheme
53
+ sends `PRIVATE-TOKEN` for personal/project/group access tokens.
54
+
28
55
  ### Recommended: 1Password + `op run`
29
56
 
30
57
  On a developer laptop, keep the token in 1Password and inject it into the host
@@ -92,9 +119,51 @@ asyncio.run(main())
92
119
 
93
120
  Run it: `op run --env-file=.env.op -- python my_script.py`.
94
121
 
122
+ ### WorkItems (GraphQL)
123
+
124
+ `workItem` is GitLab's unified replacement for the legacy `Issue` type — use
125
+ `work_items_service` for status/comments/labels on modern GitLab instances:
126
+
127
+ ```python
128
+ import asyncio
129
+ from pjdev_gitlab import config_service, work_items_service
130
+ from pjdev_gitlab.models import WorkItemState, WorkItemStateEvent
131
+
132
+ async def main() -> None:
133
+ config_service.init()
134
+
135
+ open_bugs = await work_items_service.search_work_items(
136
+ project_path="my-group/my-project",
137
+ state=WorkItemState.OPEN,
138
+ labels=["bug"],
139
+ search="timeout",
140
+ )
141
+
142
+ label = await work_items_service.create_label(
143
+ "needs-review", project_path="my-group/my-project", color="#FFAA00"
144
+ )
145
+ await work_items_service.set_work_item_labels(
146
+ open_bugs[0].iid,
147
+ [label.id],
148
+ mode="add",
149
+ project_path="my-group/my-project",
150
+ )
151
+ await work_items_service.comment_on_work_item(
152
+ open_bugs[0].iid,
153
+ "Triaged — assigning a reviewer.",
154
+ project_path="my-group/my-project",
155
+ )
156
+ await work_items_service.set_work_item_state(
157
+ open_bugs[0].iid, WorkItemStateEvent.CLOSE,
158
+ project_path="my-group/my-project",
159
+ )
160
+
161
+ asyncio.run(main())
162
+ ```
163
+
95
164
  ## Bundled agent skills
96
165
 
97
- 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, merge requests, repository files, generic packages.
166
+ 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.
98
167
 
99
168
  ## License
100
169
 
@@ -0,0 +1,173 @@
1
+ ---
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.
4
+ ---
5
+
6
+ # pjdev_gitlab_work_items
7
+
8
+ Async helpers for GitLab workItems, built on `httpx.AsyncClient` against the
9
+ [GitLab GraphQL API](https://docs.gitlab.com/api/graphql/).
10
+
11
+ `workItem` is GitLab's unified replacement for the legacy `Issue` type. New
12
+ behavior (custom statuses, hierarchy, the typed-issuable system) lives on
13
+ workItems and is only reachable via GraphQL. Use this module instead of
14
+ `issues_service` when working on a modern GitLab instance.
15
+
16
+ Reference docs (source of truth for behavior):
17
+ - [GraphQL API](https://docs.gitlab.com/api/graphql/)
18
+ - [GraphQL reference](https://docs.gitlab.com/api/graphql/reference/)
19
+ - [WorkItems](https://docs.gitlab.com/user/work_items/)
20
+ - [Labels](https://docs.gitlab.com/user/project/labels/)
21
+ - [Comments and threads](https://docs.gitlab.com/user/discussions/)
22
+
23
+ ## Setup
24
+
25
+ `pjdev_gitlab` reads `GL_TOKEN` and `GL_GITLAB_URL` from the environment.
26
+ Recommended pattern: keep the token in 1Password and inject it with
27
+ [`op run`](https://developer.1password.com/docs/cli/secrets-environment-variables/).
28
+
29
+ ```bash
30
+ # .env.op
31
+ GL_TOKEN="op://Private/GitLab — <host>/credential"
32
+ GL_GITLAB_URL="https://gitlab.example.com"
33
+ ```
34
+
35
+ ```bash
36
+ op run --env-file=.env.op -- python my_script.py
37
+ ```
38
+
39
+ ```python
40
+ from pjdev_gitlab import config_service, work_items_service
41
+
42
+ config_service.init() # token/url from env
43
+ ```
44
+
45
+ The token needs `api` scope (mutations) or at least `read_api` (queries only).
46
+
47
+ ## Identifiers
48
+
49
+ Every workItem-facing helper accepts either:
50
+
51
+ - A **Global ID** like `gid://gitlab/WorkItem/12345`.
52
+ - A **numeric DB id** (`12345`), which is auto-coerced to a Global ID.
53
+ - A project/group-scoped **iid** (the number users see in the URL) plus
54
+ `project_path=...` or `group_path=...` — the helper resolves it to a
55
+ Global ID with one extra query.
56
+
57
+ Pick whichever you have on hand:
58
+
59
+ ```python
60
+ # By iid (common when scripting against URLs the user shared)
61
+ item = await work_items_service.get_work_item(42, project_path="group/proj")
62
+
63
+ # By Global ID (zero-lookup path)
64
+ item = await work_items_service.get_work_item("gid://gitlab/WorkItem/12345")
65
+ ```
66
+
67
+ ## Search & filter
68
+
69
+ `search_work_items` walks the namespace's `workItems` connection and follows
70
+ `pageInfo.endCursor` until exhausted (set `paginate_results=False` for a
71
+ single page). Either `project_path` or `group_path` is required.
72
+
73
+ ```python
74
+ from pjdev_gitlab.models import WorkItemState
75
+
76
+ open_bugs = await work_items_service.search_work_items(
77
+ project_path="group/proj",
78
+ state=WorkItemState.OPEN,
79
+ labels=["bug"],
80
+ search="timeout", # matches title/description
81
+ )
82
+
83
+ across_group = await work_items_service.search_work_items(
84
+ group_path="group",
85
+ state=WorkItemState.OPEN,
86
+ page_size=50,
87
+ )
88
+ ```
89
+
90
+ ## View / update status
91
+
92
+ ```python
93
+ from pjdev_gitlab.models import WorkItemStateEvent
94
+
95
+ item = await work_items_service.get_work_item(42, project_path="group/proj")
96
+ print(item.state) # WorkItemState.OPEN | CLOSED
97
+
98
+ await work_items_service.set_work_item_state(
99
+ 42, WorkItemStateEvent.CLOSE, project_path="group/proj"
100
+ )
101
+ ```
102
+
103
+ ## Comments and threaded replies
104
+
105
+ ```python
106
+ # Read all human comments (system notes filtered out by default)
107
+ notes = await work_items_service.list_work_item_notes(
108
+ 42, project_path="group/proj"
109
+ )
110
+ for n in notes:
111
+ print(n.author.username if n.author else "?", "->", n.body)
112
+
113
+ # Post a new top-level comment (starts a new discussion thread)
114
+ await work_items_service.comment_on_work_item(
115
+ 42, "Looking into this now.", project_path="group/proj"
116
+ )
117
+
118
+ # Reply to an existing thread
119
+ discussions = await work_items_service.list_work_item_discussions(
120
+ 42, project_path="group/proj"
121
+ )
122
+ await work_items_service.reply_to_work_item_discussion(
123
+ 42,
124
+ discussion_id=discussions[0].id,
125
+ body="Confirmed — pushing a fix shortly.",
126
+ project_path="group/proj",
127
+ )
128
+ ```
129
+
130
+ ## Manage labels
131
+
132
+ Label management uses a **delta API** — add or remove specific labels. There
133
+ is no "replace" mode; compute the diff yourself if you need one.
134
+
135
+ ```python
136
+ # Look up labels (includes parent group labels for projects)
137
+ labels = await work_items_service.list_labels(
138
+ project_path="group/proj", search="prio"
139
+ )
140
+ # [ProjectLabel(id='gid://gitlab/ProjectLabel/7', title='priority::high', ...), ...]
141
+
142
+ # Apply / remove on a workItem
143
+ await work_items_service.set_work_item_labels(
144
+ 42, [labels[0].id], mode="add", project_path="group/proj"
145
+ )
146
+ await work_items_service.set_work_item_labels(
147
+ 42, [labels[0].id], mode="remove", project_path="group/proj"
148
+ )
149
+
150
+ # Create a brand-new project label
151
+ new_label = await work_items_service.create_label(
152
+ "needs-review",
153
+ project_path="group/proj",
154
+ color="#FFAA00",
155
+ description="Awaiting reviewer triage",
156
+ )
157
+ ```
158
+
159
+ Group labels (visible to all child projects) are created the same way with
160
+ `group_path=` instead of `project_path=`.
161
+
162
+ ## Tips for AI agents
163
+
164
+ - Prefer batching label changes into one `set_work_item_labels(mode="add", labels=[a, b, c])`
165
+ call — that's one mutation instead of three.
166
+ - When you only have an iid, pass `project_path=` once and reuse the
167
+ `WorkItem.id` (Global ID) on follow-up mutations to skip the lookup round-trip.
168
+ - `WorkItem.state` is `OPEN`/`CLOSED` (uppercase, matches the GraphQL enum
169
+ directly), but the **filter** uses lowercase `opened`/`closed` — the helper
170
+ translates `WorkItemState.OPEN` automatically when you pass it to
171
+ `search_work_items(state=...)`.
172
+ - A workItem returned by a mutation always reflects the post-mutation state,
173
+ so you can read `result.state` / `result.labels` instead of re-fetching.
@@ -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.7"
4
+ __version__ = "5.1.9"
@@ -12,6 +12,8 @@ __all__ = [
12
12
  "issues_service",
13
13
  "merge_requests_service",
14
14
  "models",
15
+ "oauth_service",
15
16
  "packages_service",
16
17
  "repo_files_service",
18
+ "work_items_service",
17
19
  ]
@@ -12,12 +12,32 @@ from loguru import logger
12
12
  from pjdev_gitlab.config_service import get_config
13
13
 
14
14
 
15
+ class GraphQLError(RuntimeError):
16
+ """Raised when a GraphQL response contains top-level ``errors``.
17
+
18
+ The original error list is exposed on ``self.errors`` so callers can
19
+ inspect ``extensions`` (e.g. ``needsCaptchaResponse``) or match on
20
+ individual messages.
21
+ """
22
+
23
+ def __init__(self, errors: List[Dict[str, Any]]) -> None:
24
+ self.errors = errors
25
+ messages = "; ".join(str(e.get("message", e)) for e in errors)
26
+ super().__init__(messages)
27
+
28
+
15
29
  def encode_path_segment(value: Any) -> str:
16
30
  """URL-encode a path segment (e.g. project id 'group/sub/project' -> 'group%2Fsub%2Fproject')."""
17
31
  return quote(str(value), safe="")
18
32
 
19
33
 
20
34
  class GitlabTokenAuth(httpx.Auth):
35
+ """Send the credential in the ``PRIVATE-TOKEN`` header.
36
+
37
+ GitLab accepts this header for personal/project/group access tokens. This is
38
+ the default scheme (``config.auth_scheme == "private-token"``).
39
+ """
40
+
21
41
  def __init__(self, token: str) -> None:
22
42
  self.token = token
23
43
 
@@ -26,6 +46,30 @@ class GitlabTokenAuth(httpx.Auth):
26
46
  yield request
27
47
 
28
48
 
49
+ class GitlabBearerAuth(httpx.Auth):
50
+ """Send the credential as an OAuth 2.0 ``Authorization: Bearer`` header.
51
+
52
+ GitLab requires OAuth access tokens (e.g. those minted by
53
+ :mod:`pjdev_gitlab.oauth_service`) to be sent as Bearer credentials; the
54
+ ``PRIVATE-TOKEN`` header is rejected for them. Selected when
55
+ ``config.auth_scheme == "bearer"``.
56
+ """
57
+
58
+ def __init__(self, token: str) -> None:
59
+ self.token = token
60
+
61
+ def auth_flow(self, request):
62
+ request.headers["Authorization"] = f"Bearer {self.token}"
63
+ yield request
64
+
65
+
66
+ def build_auth(config) -> httpx.Auth:
67
+ """Return the httpx auth for the configured scheme (bearer for OAuth tokens)."""
68
+ if str(config.auth_scheme).lower() == "bearer":
69
+ return GitlabBearerAuth(config.token)
70
+ return GitlabTokenAuth(config.token)
71
+
72
+
29
73
  async def log_request_headers(request: httpx.Request) -> None:
30
74
  logger.debug(f"Request: {request.method} {request.url}")
31
75
 
@@ -43,7 +87,7 @@ def get_http_client() -> httpx.AsyncClient:
43
87
 
44
88
  return httpx.AsyncClient(
45
89
  base_url=config.api_base_url,
46
- auth=GitlabTokenAuth(config.token),
90
+ auth=build_auth(config),
47
91
  verify=ssl.create_default_context() if config.verify_ssl else False,
48
92
  timeout=config.request_timeout_seconds,
49
93
  event_hooks={
@@ -59,6 +103,114 @@ async def http_client() -> AsyncIterator[httpx.AsyncClient]:
59
103
  yield _client
60
104
 
61
105
 
106
+ def get_graphql_client() -> httpx.AsyncClient:
107
+ """Build an httpx client pointed at the instance root.
108
+
109
+ The GraphQL endpoint lives at ``{gitlab_url}/api/graphql`` (NOT under
110
+ ``/api/v4``), so it can't share a ``base_url`` with the REST helpers.
111
+ """
112
+ config = get_config()
113
+ if not config.token:
114
+ raise ValueError("GitLab token is not configured -- set GL_TOKEN or pass token=...")
115
+
116
+ return httpx.AsyncClient(
117
+ base_url=config.gitlab_url.rstrip("/"),
118
+ auth=build_auth(config),
119
+ verify=ssl.create_default_context() if config.verify_ssl else False,
120
+ timeout=config.request_timeout_seconds,
121
+ event_hooks={
122
+ "request": [log_request_headers],
123
+ "response": [log_response_headers],
124
+ },
125
+ )
126
+
127
+
128
+ @asynccontextmanager
129
+ async def graphql_client() -> AsyncIterator[httpx.AsyncClient]:
130
+ async with get_graphql_client() as _client:
131
+ yield _client
132
+
133
+
134
+ async def graphql(
135
+ query: str,
136
+ variables: Optional[Dict[str, Any]] = None,
137
+ *,
138
+ client: Optional[httpx.AsyncClient] = None,
139
+ ) -> Dict[str, Any]:
140
+ """Execute a GraphQL query or mutation and return the ``data`` payload.
141
+
142
+ Raises :class:`GraphQLError` when the response contains top-level
143
+ ``errors``. Resource-level errors that GitLab surfaces as part of a
144
+ mutation's payload (e.g. ``mutationName.errors``) are returned as-is
145
+ in the data and are the caller's responsibility to check.
146
+ """
147
+ payload: Dict[str, Any] = {"query": query, "variables": variables or {}}
148
+
149
+ async def _exec(_client: httpx.AsyncClient) -> Dict[str, Any]:
150
+ r = await _client.post("/api/graphql", json=payload)
151
+ r.raise_for_status()
152
+ body = r.json()
153
+ if body.get("errors"):
154
+ raise GraphQLError(body["errors"])
155
+ return body.get("data") or {}
156
+
157
+ if client is None:
158
+ async with graphql_client() as _client:
159
+ return await _exec(_client)
160
+ return await _exec(client)
161
+
162
+
163
+ async def paginate_graphql(
164
+ query: str,
165
+ variables: Dict[str, Any],
166
+ *,
167
+ connection_path: List[str],
168
+ page_size: int = 100,
169
+ client: Optional[httpx.AsyncClient] = None,
170
+ ) -> List[Dict[str, Any]]:
171
+ """Follow ``pageInfo.endCursor`` through a Relay-style GraphQL connection.
172
+
173
+ The supplied ``query`` MUST declare an ``$after: String`` variable, pass it
174
+ as ``after: $after`` to the connection, and select both ``pageInfo {
175
+ endCursor hasNextPage }`` and ``nodes { ... }``. ``connection_path`` is the
176
+ list of keys to walk from the top of ``data`` down to the connection
177
+ object (e.g. ``["group", "workItems"]``).
178
+
179
+ The variable ``first`` is set to ``page_size`` on every page.
180
+ """
181
+
182
+ def _walk(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
183
+ current: Any = data
184
+ for key in connection_path:
185
+ if current is None:
186
+ return None
187
+ current = current.get(key)
188
+ return current
189
+
190
+ nodes: List[Dict[str, Any]] = []
191
+ page_vars: Dict[str, Any] = {**variables, "first": page_size, "after": None}
192
+
193
+ async def _run(_client: Optional[httpx.AsyncClient]) -> None:
194
+ while True:
195
+ data = await graphql(query, page_vars, client=_client)
196
+ connection = _walk(data)
197
+ if connection is None:
198
+ break
199
+ nodes.extend(connection.get("nodes") or [])
200
+ page_info = connection.get("pageInfo") or {}
201
+ if not page_info.get("hasNextPage"):
202
+ break
203
+ page_vars["after"] = page_info.get("endCursor")
204
+
205
+ if client is None:
206
+ async with graphql_client() as _client:
207
+ await _run(_client)
208
+ else:
209
+ await _run(client)
210
+
211
+ return nodes
212
+
213
+
62
214
  def async_retry_http(
63
215
  default_value: Optional[Any] = None,
64
216
  status_codes_to_ignore: Optional[List[int]] = None,
@@ -16,6 +16,7 @@ def init(
16
16
  env_path: Optional[Path] = None,
17
17
  token: Optional[str] = None,
18
18
  gitlab_url: Optional[str] = None,
19
+ auth_scheme: Optional[str] = None,
19
20
  default_project_id: Optional[ProjectId] = None,
20
21
  output_path: Optional[Path] = None,
21
22
  http_retry_max_count: Optional[int] = None,
@@ -31,6 +32,8 @@ def init(
31
32
  kwargs["token"] = token
32
33
  if gitlab_url is not None:
33
34
  kwargs["gitlab_url"] = gitlab_url
35
+ if auth_scheme is not None:
36
+ kwargs["auth_scheme"] = auth_scheme
34
37
  if default_project_id is not None:
35
38
  kwargs["default_project_id"] = default_project_id
36
39
  if output_path is not None: