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.
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/PKG-INFO +73 -4
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/README.md +72 -3
- pjdev_gitlab-5.1.9/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_work_items/SKILL.md +173 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/__about__.py +1 -1
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/__init__.py +2 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/api_utilities.py +153 -1
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/config_service.py +3 -0
- pjdev_gitlab-5.1.9/src/pjdev_gitlab/models.py +369 -0
- pjdev_gitlab-5.1.9/src/pjdev_gitlab/oauth_service.py +354 -0
- pjdev_gitlab-5.1.9/src/pjdev_gitlab/work_items_service.py +720 -0
- pjdev_gitlab-5.1.9/test_report_pjdev-gitlab.txt +69 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/tests/tests_for_api_utilities.py +33 -0
- pjdev_gitlab-5.1.9/tests/tests_for_oauth_service.py +142 -0
- pjdev_gitlab-5.1.9/tests/tests_for_work_items_service.py +681 -0
- pjdev_gitlab-5.1.7/src/pjdev_gitlab/models.py +0 -173
- pjdev_gitlab-5.1.7/test_report_pjdev-gitlab.txt +0 -36
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/.gitignore +0 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/LICENSE.txt +0 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/pyproject.toml +0 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_issues/SKILL.md +0 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +0 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +0 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +0 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/issues_service.py +0 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/merge_requests_service.py +0 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/packages_service.py +0 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/py.typed +0 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/src/pjdev_gitlab/repo_files_service.py +0 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/test.sh +0 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/tests/__init__.py +0 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/tests/conftest.py +0 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/tests/tests_for_issues_service.py +0 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/tests/tests_for_merge_requests_service.py +0 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/tests/tests_for_models.py +0 -0
- {pjdev_gitlab-5.1.7 → pjdev_gitlab-5.1.9}/tests/tests_for_packages_service.py +0 -0
- {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.
|
|
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
|
[](https://pypi.org/project/pjdev-gitlab)
|
|
32
32
|
[](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 |
|
|
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
|
[](https://pypi.org/project/pjdev-gitlab)
|
|
4
4
|
[](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 |
|
|
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.
|
|
@@ -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=
|
|
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:
|