pjdev-gitlab 5.0.0__py3-none-any.whl
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/.agents/skills/pjdev_gitlab_issues/SKILL.md +150 -0
- pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +101 -0
- pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +83 -0
- pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +96 -0
- pjdev_gitlab/__about__.py +4 -0
- pjdev_gitlab/__init__.py +17 -0
- pjdev_gitlab/api_utilities.py +201 -0
- pjdev_gitlab/config_service.py +49 -0
- pjdev_gitlab/issues_service.py +430 -0
- pjdev_gitlab/merge_requests_service.py +244 -0
- pjdev_gitlab/models.py +172 -0
- pjdev_gitlab/packages_service.py +134 -0
- pjdev_gitlab/py.typed +0 -0
- pjdev_gitlab/repo_files_service.py +145 -0
- pjdev_gitlab-5.0.0.dist-info/METADATA +129 -0
- pjdev_gitlab-5.0.0.dist-info/RECORD +18 -0
- pjdev_gitlab-5.0.0.dist-info/WHEEL +4 -0
- pjdev_gitlab-5.0.0.dist-info/licenses/LICENSE.txt +9 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pjdev_gitlab_issues
|
|
3
|
+
description: Use this skill to automate GitLab issues via pjdev-gitlab — search/analytics, create issues with markdown and inline images, post comments and threaded discussions, and change state, labels, iteration, or milestone. Trigger when the user asks to query, create, or modify GitLab issues programmatically.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# pjdev_gitlab_issues
|
|
7
|
+
|
|
8
|
+
Async helpers for GitLab issue automation, built on `httpx.AsyncClient` against the [GitLab REST API v4](https://docs.gitlab.com/api/issues/).
|
|
9
|
+
|
|
10
|
+
Reference docs (source of truth for behavior):
|
|
11
|
+
- [Issues](https://docs.gitlab.com/user/project/issues/)
|
|
12
|
+
- [Comments and threads](https://docs.gitlab.com/user/discussions/)
|
|
13
|
+
- [Labels](https://docs.gitlab.com/user/project/labels/)
|
|
14
|
+
- [Iterations](https://docs.gitlab.com/user/group/iterations/)
|
|
15
|
+
- [Milestones](https://docs.gitlab.com/user/project/milestones/)
|
|
16
|
+
- [Quick actions](https://docs.gitlab.com/user/project/quick_actions/)
|
|
17
|
+
- [GitLab Flavored Markdown](https://docs.gitlab.com/user/markdown/)
|
|
18
|
+
|
|
19
|
+
## Setup
|
|
20
|
+
|
|
21
|
+
`pjdev_gitlab` reads `GL_TOKEN` and `GL_GITLAB_URL` from the environment. The
|
|
22
|
+
recommended pattern is to keep the token in 1Password and inject it into the
|
|
23
|
+
host process with [`op run`](https://developer.1password.com/docs/cli/secrets-environment-variables/),
|
|
24
|
+
so the secret never sits in your shell environment or on disk in plaintext.
|
|
25
|
+
|
|
26
|
+
1. Store the token in 1Password (e.g. an API Credential titled `GitLab — <host>`
|
|
27
|
+
with a `credential` field).
|
|
28
|
+
2. In the project that uses this skill, create a committable `.env.op` with
|
|
29
|
+
1Password references — no real secrets:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# .env.op
|
|
33
|
+
GL_TOKEN="op://Private/GitLab — <host>/credential"
|
|
34
|
+
GL_GITLAB_URL="https://gitlab.example.com"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
3. Launch the host process (the script, or the entire Claude Code session)
|
|
38
|
+
under `op run`:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
op run --env-file=.env.op -- python my_script.py
|
|
42
|
+
op run --env-file=.env.op -- claude
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Then call `config_service.init()` with no arguments — it picks up `GL_*` from env:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from pjdev_gitlab import config_service, issues_service
|
|
49
|
+
|
|
50
|
+
config_service.init(default_project_id="my-group/my-project") # token/url from env
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For tests or one-off scripts you can still pass values explicitly:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
config_service.init(token="glpat-xxx", gitlab_url="https://gitlab.com")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The token needs `api` scope. Project-scoped tokens work for project-only
|
|
60
|
+
operations; iterations live at group level, so use a group/personal token for
|
|
61
|
+
`list_iterations`.
|
|
62
|
+
|
|
63
|
+
## Search & analytics
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from pjdev_gitlab import issues_service
|
|
67
|
+
from pjdev_gitlab.models import IssueState
|
|
68
|
+
|
|
69
|
+
open_bugs = await issues_service.search_issues(
|
|
70
|
+
"my-group/my-project",
|
|
71
|
+
state=IssueState.opened,
|
|
72
|
+
labels=["bug"],
|
|
73
|
+
created_after="2026-01-01T00:00:00Z",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
by_label = await issues_service.aggregate_issues(
|
|
77
|
+
"my-group/my-project", group_by="label", state=IssueState.opened
|
|
78
|
+
)
|
|
79
|
+
# {"bug": 12, "frontend": 7, ...}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`search_issues` paginates by default. Set `paginate_results=False` for a single page.
|
|
83
|
+
|
|
84
|
+
## Create with markdown and inline images
|
|
85
|
+
|
|
86
|
+
`<img src="...">` tags in the description are replaced positionally with the markdown
|
|
87
|
+
returned by GitLab's upload endpoint. Quick actions in the description (e.g. `/label`,
|
|
88
|
+
`/assign`) are processed by GitLab server-side.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from pathlib import Path
|
|
92
|
+
|
|
93
|
+
issue = await issues_service.create_issue(
|
|
94
|
+
"my-group/my-project",
|
|
95
|
+
title="Bug: timeout on /widgets",
|
|
96
|
+
description=(
|
|
97
|
+
"Repro:\n"
|
|
98
|
+
"1. Open page\n"
|
|
99
|
+
'2. <img src="placeholder1">\n\n'
|
|
100
|
+
"/label ~bug ~priority::high\n"
|
|
101
|
+
"/assign @me"
|
|
102
|
+
),
|
|
103
|
+
image_paths=[Path("./screenshot.png")],
|
|
104
|
+
labels=["bug"],
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Comment vs. threaded discussion
|
|
109
|
+
|
|
110
|
+
Single comment:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
await issues_service.comment_on_issue(
|
|
114
|
+
"my-group/my-project", issue_iid=42, body="Investigating now."
|
|
115
|
+
)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Threaded discussion (use this when subsequent replies should belong to the same thread):
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
discussion = await issues_service.start_issue_discussion(
|
|
122
|
+
"my-group/my-project", issue_iid=42, body="Root-cause analysis"
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Change state, labels, iteration, milestone
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from pjdev_gitlab.models import StateEvent
|
|
130
|
+
|
|
131
|
+
await issues_service.set_issue_state("my-group/my-project", 42, StateEvent.close)
|
|
132
|
+
await issues_service.set_issue_labels(
|
|
133
|
+
"my-group/my-project", 42, ["regression"], mode="add"
|
|
134
|
+
)
|
|
135
|
+
await issues_service.set_issue_iteration("my-group/my-project", 42, iteration_id=17)
|
|
136
|
+
await issues_service.set_issue_milestone("my-group/my-project", 42, milestone_id=5)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Iterations and milestones can be discovered with `list_iterations(group_id)` and
|
|
140
|
+
`list_milestones(project_id)`.
|
|
141
|
+
|
|
142
|
+
## Tips for AI agents
|
|
143
|
+
|
|
144
|
+
- Prefer quick actions (`/label`, `/assign`, `/iteration *iteration:42`, `/close`)
|
|
145
|
+
inside the description or comment body instead of multiple API calls — this is
|
|
146
|
+
one round-trip and matches how humans use GitLab.
|
|
147
|
+
- IIDs (`issue.iid`) are project-scoped and what users see; IDs (`issue.id`) are
|
|
148
|
+
GitLab-global. Always use IIDs for issue endpoints.
|
|
149
|
+
- Project IDs may be numeric (`12345`) or namespaced paths (`group/sub/project`).
|
|
150
|
+
Both work; the helpers URL-encode them automatically.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pjdev_gitlab_merge_requests
|
|
3
|
+
description: Use this skill to automate GitLab merge request review with pjdev-gitlab — list/get MRs, fetch diffs/changes, post flat or threaded inline-diff comments, and approve/unapprove. Trigger when the user wants to review, comment on, or approve GitLab merge requests programmatically.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# pjdev_gitlab_merge_requests
|
|
7
|
+
|
|
8
|
+
Async helpers for GitLab merge request automation against the [Merge Requests API](https://docs.gitlab.com/api/merge_requests/) and [Discussions API](https://docs.gitlab.com/api/discussions/).
|
|
9
|
+
|
|
10
|
+
Reference docs:
|
|
11
|
+
- [Merge requests](https://docs.gitlab.com/user/project/merge_requests/)
|
|
12
|
+
- [Comments and threads](https://docs.gitlab.com/user/discussions/)
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
|
|
16
|
+
`pjdev_gitlab` reads `GL_TOKEN` and `GL_GITLAB_URL` from the environment.
|
|
17
|
+
The recommended pattern is 1Password + [`op run`](https://developer.1password.com/docs/cli/secrets-environment-variables/) — the token stays in your vault and is injected only into the host process.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# .env.op (committable — references only)
|
|
21
|
+
GL_TOKEN="op://Private/GitLab — <host>/credential"
|
|
22
|
+
GL_GITLAB_URL="https://gitlab.example.com"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Launch the script or Claude Code session under `op run`:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
op run --env-file=.env.op -- python my_script.py
|
|
29
|
+
op run --env-file=.env.op -- claude
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then in code:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from pjdev_gitlab import config_service
|
|
36
|
+
config_service.init() # GL_TOKEN / GL_GITLAB_URL come from env
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
For tests or quick scripts, pass values explicitly: `config_service.init(token="glpat-xxx", gitlab_url="https://gitlab.com")`.
|
|
40
|
+
|
|
41
|
+
## List and inspect
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from pjdev_gitlab import merge_requests_service
|
|
45
|
+
from pjdev_gitlab.models import MergeRequestState
|
|
46
|
+
|
|
47
|
+
open_mrs = await merge_requests_service.list_merge_requests(
|
|
48
|
+
"my-group/my-project", state=MergeRequestState.opened
|
|
49
|
+
)
|
|
50
|
+
mr = await merge_requests_service.get_merge_request("my-group/my-project", 88)
|
|
51
|
+
changes = await merge_requests_service.get_merge_request_changes(
|
|
52
|
+
"my-group/my-project", 88
|
|
53
|
+
)
|
|
54
|
+
# changes["changes"] is a list of {old_path, new_path, diff, ...}
|
|
55
|
+
# changes["diff_refs"] has base_sha / start_sha / head_sha needed for inline comments
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Flat comment (un-threaded)
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
await merge_requests_service.comment_on_merge_request(
|
|
62
|
+
"my-group/my-project", 88, "Looks good — one nit on naming."
|
|
63
|
+
)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Threaded inline-diff comment
|
|
67
|
+
|
|
68
|
+
Use the `diff_refs` from `get_merge_request` (or `get_merge_request_changes`) to position the comment on a specific line of a specific file.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
mr = await merge_requests_service.get_merge_request("my-group/my-project", 88)
|
|
72
|
+
refs = mr.diff_refs # {"base_sha": ..., "start_sha": ..., "head_sha": ...}
|
|
73
|
+
|
|
74
|
+
await merge_requests_service.comment_on_merge_request_diff(
|
|
75
|
+
"my-group/my-project",
|
|
76
|
+
mr_iid=88,
|
|
77
|
+
body="Consider extracting this into a helper.",
|
|
78
|
+
base_sha=refs["base_sha"],
|
|
79
|
+
start_sha=refs["start_sha"],
|
|
80
|
+
head_sha=refs["head_sha"],
|
|
81
|
+
new_path="src/pjdev_gitlab/issues_service.py",
|
|
82
|
+
new_line=42,
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Approve / unapprove
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
await merge_requests_service.approve_merge_request("my-group/my-project", 88)
|
|
90
|
+
await merge_requests_service.unapprove_merge_request("my-group/my-project", 88)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Tips for AI agents
|
|
94
|
+
|
|
95
|
+
- A typical review loop is: `get_merge_request_changes` → analyze diff → for each
|
|
96
|
+
finding, `comment_on_merge_request_diff` (threaded inline) → optional summary
|
|
97
|
+
via `comment_on_merge_request` (flat).
|
|
98
|
+
- Inline diff comments on **deleted** lines need `old_path` + `old_line`; on
|
|
99
|
+
**added** lines need `new_path` + `new_line`. Pass both when commenting on a
|
|
100
|
+
context line.
|
|
101
|
+
- `mr.iid` (project-scoped) is what URLs and humans use — always pass `iid`, not `id`.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pjdev_gitlab_packages
|
|
3
|
+
description: Use this skill to publish, download, or list artifacts in the GitLab generic package registry via pjdev-gitlab. Trigger when the user wants to upload build artifacts, retrieve a previously-published file, or enumerate packages in a project.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# pjdev_gitlab_packages
|
|
7
|
+
|
|
8
|
+
Async helpers for the [GitLab generic package registry](https://docs.gitlab.com/user/packages/generic_packages/).
|
|
9
|
+
|
|
10
|
+
## Setup
|
|
11
|
+
|
|
12
|
+
`pjdev_gitlab` reads `GL_TOKEN` and `GL_GITLAB_URL` from the environment.
|
|
13
|
+
The recommended pattern on a developer laptop is 1Password + [`op run`](https://developer.1password.com/docs/cli/secrets-environment-variables/) — the token stays in your vault and is injected only into the host process.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# .env.op (committable — references only)
|
|
17
|
+
GL_TOKEN="op://Private/GitLab — <host>/credential"
|
|
18
|
+
GL_GITLAB_URL="https://gitlab.example.com"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Launch the script or Claude Code session under `op run`:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
op run --env-file=.env.op -- python publish.py
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then in code:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from pjdev_gitlab import config_service
|
|
31
|
+
config_service.init() # GL_TOKEN / GL_GITLAB_URL come from env
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The token must have `api` scope. In GitLab CI, skip 1Password and rely on
|
|
35
|
+
`CI_JOB_TOKEN` (set `GL_TOKEN=$CI_JOB_TOKEN` in the job) — the project's
|
|
36
|
+
package-registry permissions of the running pipeline apply automatically.
|
|
37
|
+
|
|
38
|
+
## Upload
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from pjdev_gitlab import packages_service
|
|
43
|
+
|
|
44
|
+
result = await packages_service.upload_generic_package(
|
|
45
|
+
project_id="my-group/my-project",
|
|
46
|
+
package_name="my-tool",
|
|
47
|
+
package_version="1.4.0",
|
|
48
|
+
file_name="my-tool-1.4.0.tar.gz",
|
|
49
|
+
file_path=Path("./dist/my-tool-1.4.0.tar.gz"),
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`status="hidden"` keeps the package out of the UI listing (useful for staging
|
|
54
|
+
a release before flipping it to default).
|
|
55
|
+
|
|
56
|
+
## Download
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
path = await packages_service.download_generic_package(
|
|
60
|
+
project_id="my-group/my-project",
|
|
61
|
+
package_name="my-tool",
|
|
62
|
+
package_version="1.4.0",
|
|
63
|
+
file_name="my-tool-1.4.0.tar.gz",
|
|
64
|
+
dest=Path("./out/my-tool-1.4.0.tar.gz"),
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## List
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
packages = await packages_service.list_packages(
|
|
72
|
+
project_id="my-group/my-project", package_type="generic"
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Tips for AI agents
|
|
77
|
+
|
|
78
|
+
- File names are URL-encoded for you. Use the literal upload filename.
|
|
79
|
+
- The same `(name, version, file_name)` triple is upsert: re-uploading
|
|
80
|
+
overwrites unless the project's "Reject duplicate uploads" setting blocks it.
|
|
81
|
+
- For very large files, prefer streaming uploads at the HTTP layer; this helper
|
|
82
|
+
reads the whole file into memory because the generic-package endpoint expects
|
|
83
|
+
the body to be the raw file content.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pjdev_gitlab_repo_files
|
|
3
|
+
description: Use this skill to fetch a single file or recursively download a directory from a GitLab repository at a specific ref via pjdev-gitlab. Trigger when the user wants to read repo content, download files, or mirror a subtree without cloning the repo.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# pjdev_gitlab_repo_files
|
|
7
|
+
|
|
8
|
+
Async helpers for reading repository content via the [GitLab Repositories API](https://docs.gitlab.com/api/repositories/) and [Repository Files API](https://docs.gitlab.com/api/repository_files/) — without cloning the repo.
|
|
9
|
+
|
|
10
|
+
Reference docs:
|
|
11
|
+
- [Repositories](https://docs.gitlab.com/user/project/repository/)
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
`pjdev_gitlab` reads `GL_TOKEN` and `GL_GITLAB_URL` from the environment.
|
|
16
|
+
The recommended pattern is 1Password + [`op run`](https://developer.1password.com/docs/cli/secrets-environment-variables/) — the token stays in your vault and is injected only into the host process.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# .env.op (committable — references only)
|
|
20
|
+
GL_TOKEN="op://Private/GitLab — <host>/credential"
|
|
21
|
+
GL_GITLAB_URL="https://gitlab.example.com"
|
|
22
|
+
GL_OUTPUT_PATH="./downloads" # optional default for downloads
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Launch the script or Claude Code session under `op run`:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
op run --env-file=.env.op -- python my_script.py
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then in code:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from pjdev_gitlab import config_service
|
|
35
|
+
config_service.init() # GL_TOKEN / GL_GITLAB_URL / GL_OUTPUT_PATH come from env
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
For tests or quick scripts, pass values explicitly: `config_service.init(token="glpat-xxx", gitlab_url="https://gitlab.com", output_path=Path("./downloads"))`.
|
|
39
|
+
|
|
40
|
+
## Get a single file
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from pjdev_gitlab import repo_files_service
|
|
44
|
+
|
|
45
|
+
# Decoded text
|
|
46
|
+
readme = await repo_files_service.get_file(
|
|
47
|
+
"my-group/my-project", "README.md", ref="main"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Raw bytes (e.g. for binaries)
|
|
51
|
+
raw = await repo_files_service.get_file(
|
|
52
|
+
"my-group/my-project", "assets/logo.png", ref="main", decode=False
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Download a single file to disk
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
path = await repo_files_service.download_file(
|
|
60
|
+
"my-group/my-project",
|
|
61
|
+
file_path="docs/spec.pdf",
|
|
62
|
+
ref="v1.4.0",
|
|
63
|
+
dest=Path("./out/spec.pdf"), # falls back to config.output_path if omitted
|
|
64
|
+
)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## List a tree (paginated, optionally recursive)
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
entries = await repo_files_service.list_tree(
|
|
71
|
+
"my-group/my-project", path="docs", ref="main", recursive=True
|
|
72
|
+
)
|
|
73
|
+
# Each entry: id, name, type ("blob" | "tree"), path, mode
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Download an entire directory
|
|
77
|
+
|
|
78
|
+
Walks the tree recursively and downloads every blob, preserving relative paths.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
await repo_files_service.download_directory(
|
|
82
|
+
"my-group/my-project",
|
|
83
|
+
path="docs",
|
|
84
|
+
ref="main",
|
|
85
|
+
dest=Path("./out/docs"),
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Tips for AI agents
|
|
90
|
+
|
|
91
|
+
- File paths are URL-encoded automatically (`docs/spec.pdf` -> `docs%2Fspec.pdf`),
|
|
92
|
+
so just pass the literal repo path.
|
|
93
|
+
- `ref` accepts branches, tags, and full commit SHAs. Pin to a SHA if you need
|
|
94
|
+
reproducibility.
|
|
95
|
+
- For very large repos, `download_directory` issues one HTTP request per blob.
|
|
96
|
+
Use `recursive=True` on `list_tree` first to size the work.
|
pjdev_gitlab/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present Chris O'Neill <chris@purplejay.io>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
logger.disable("pjdev_gitlab")
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"api_utilities",
|
|
11
|
+
"config_service",
|
|
12
|
+
"issues_service",
|
|
13
|
+
"merge_requests_service",
|
|
14
|
+
"models",
|
|
15
|
+
"packages_service",
|
|
16
|
+
"repo_files_service",
|
|
17
|
+
]
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import ssl
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from typing import Any, AsyncIterator, Callable, Dict, List, Optional
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from httpx import ConnectError, HTTPStatusError
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
from pjdev_gitlab.config_service import get_config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def encode_path_segment(value: Any) -> str:
|
|
16
|
+
"""URL-encode a path segment (e.g. project id 'group/sub/project' -> 'group%2Fsub%2Fproject')."""
|
|
17
|
+
return quote(str(value), safe="")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GitlabTokenAuth(httpx.Auth):
|
|
21
|
+
def __init__(self, token: str) -> None:
|
|
22
|
+
self.token = token
|
|
23
|
+
|
|
24
|
+
def auth_flow(self, request):
|
|
25
|
+
request.headers["PRIVATE-TOKEN"] = self.token
|
|
26
|
+
yield request
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def log_request_headers(request: httpx.Request) -> None:
|
|
30
|
+
logger.debug(f"Request: {request.method} {request.url}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def log_response_headers(response: httpx.Response) -> None:
|
|
34
|
+
logger.debug(
|
|
35
|
+
f"Response: {response.request.method} {response.request.url} -> {response.status_code}"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_http_client() -> httpx.AsyncClient:
|
|
40
|
+
config = get_config()
|
|
41
|
+
if not config.token:
|
|
42
|
+
raise ValueError("GitLab token is not configured -- set GL_TOKEN or pass token=...")
|
|
43
|
+
|
|
44
|
+
return httpx.AsyncClient(
|
|
45
|
+
base_url=config.api_base_url,
|
|
46
|
+
auth=GitlabTokenAuth(config.token),
|
|
47
|
+
verify=ssl.create_default_context(),
|
|
48
|
+
timeout=config.request_timeout_seconds,
|
|
49
|
+
event_hooks={
|
|
50
|
+
"request": [log_request_headers],
|
|
51
|
+
"response": [log_response_headers],
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@asynccontextmanager
|
|
57
|
+
async def http_client() -> AsyncIterator[httpx.AsyncClient]:
|
|
58
|
+
async with get_http_client() as _client:
|
|
59
|
+
yield _client
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def async_retry_http(
|
|
63
|
+
default_value: Optional[Any] = None,
|
|
64
|
+
status_codes_to_ignore: Optional[List[int]] = None,
|
|
65
|
+
):
|
|
66
|
+
"""Async retry decorator with exponential backoff.
|
|
67
|
+
|
|
68
|
+
Mirrors `pjdev_sn_sdk.api_utilities.async_retry_http`. Aborts immediately on listed
|
|
69
|
+
status codes (typically 4xx) so we don't hammer the API on permanent failures.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def decorator(func):
|
|
73
|
+
@wraps(func)
|
|
74
|
+
async def wrapper(*args, **kwargs):
|
|
75
|
+
config = get_config()
|
|
76
|
+
max_attempts = config.http_retry_max_count
|
|
77
|
+
delay_seconds = config.http_retry_delay_seconds
|
|
78
|
+
attempts = 0
|
|
79
|
+
exceptions: List[BaseException] = []
|
|
80
|
+
|
|
81
|
+
while attempts < max_attempts:
|
|
82
|
+
try:
|
|
83
|
+
return await func(*args, **kwargs)
|
|
84
|
+
except HTTPStatusError as e:
|
|
85
|
+
logger.warning(f"{e.response.status_code}: {e.response.reason_phrase}")
|
|
86
|
+
logger.warning(e.response.text)
|
|
87
|
+
exceptions.append(e)
|
|
88
|
+
if (
|
|
89
|
+
status_codes_to_ignore
|
|
90
|
+
and e.response.status_code in status_codes_to_ignore
|
|
91
|
+
):
|
|
92
|
+
break
|
|
93
|
+
except ConnectError as e:
|
|
94
|
+
logger.warning(f"{e.request.url} not reachable")
|
|
95
|
+
exceptions.append(e)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.warning("unexpected exception")
|
|
98
|
+
logger.warning(e)
|
|
99
|
+
exceptions.append(e)
|
|
100
|
+
break
|
|
101
|
+
|
|
102
|
+
attempts += 1
|
|
103
|
+
if attempts == max_attempts:
|
|
104
|
+
break
|
|
105
|
+
total_delay = delay_seconds**attempts
|
|
106
|
+
logger.warning(
|
|
107
|
+
f"Attempt {attempts}/{max_attempts} failed. Retrying in {total_delay}s..."
|
|
108
|
+
)
|
|
109
|
+
await asyncio.sleep(total_delay)
|
|
110
|
+
|
|
111
|
+
if default_value is None:
|
|
112
|
+
raise ExceptionGroup(f"Failed after {max_attempts} attempts", exceptions)
|
|
113
|
+
logger.error(f"Failed after {max_attempts} attempts")
|
|
114
|
+
return default_value
|
|
115
|
+
|
|
116
|
+
return wrapper
|
|
117
|
+
|
|
118
|
+
return decorator
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def paginate(
|
|
122
|
+
client: httpx.AsyncClient,
|
|
123
|
+
url: str,
|
|
124
|
+
params: Optional[Dict[str, Any]] = None,
|
|
125
|
+
page_size: int = 100,
|
|
126
|
+
) -> List[Dict[str, Any]]:
|
|
127
|
+
"""Paginate a GitLab list endpoint following the X-Next-Page header.
|
|
128
|
+
|
|
129
|
+
GitLab returns ``X-Next-Page`` (empty when no more pages) and ``X-Total`` /
|
|
130
|
+
``X-Total-Pages`` for offset-paginated endpoints. Some endpoints only support
|
|
131
|
+
keyset pagination, but the offset/page params are still accepted as a fallback.
|
|
132
|
+
"""
|
|
133
|
+
request_params: Dict[str, Any] = {**(params or {}), "per_page": page_size, "page": 1}
|
|
134
|
+
results: List[Dict[str, Any]] = []
|
|
135
|
+
|
|
136
|
+
while True:
|
|
137
|
+
response = await client.get(url, params=request_params)
|
|
138
|
+
response.raise_for_status()
|
|
139
|
+
page = response.json()
|
|
140
|
+
if not isinstance(page, list):
|
|
141
|
+
raise ValueError(f"expected list response from {url}, got {type(page).__name__}")
|
|
142
|
+
results.extend(page)
|
|
143
|
+
next_page = response.headers.get("X-Next-Page", "").strip()
|
|
144
|
+
if not next_page:
|
|
145
|
+
break
|
|
146
|
+
try:
|
|
147
|
+
request_params["page"] = int(next_page)
|
|
148
|
+
except ValueError:
|
|
149
|
+
logger.warning(f"could not parse X-Next-Page={next_page!r}")
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
return results
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_error_message(eg: ExceptionGroup) -> str:
|
|
156
|
+
parts: List[str] = []
|
|
157
|
+
for e in eg.exceptions:
|
|
158
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
159
|
+
parts.append(f"{e.response.status_code} -> {e.response.text}")
|
|
160
|
+
else:
|
|
161
|
+
parts.append(str(e))
|
|
162
|
+
message = " | ".join(parts)
|
|
163
|
+
logger.error(message)
|
|
164
|
+
return message
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
__upload_sem = asyncio.Semaphore(10)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def upload_to_project(
|
|
171
|
+
project_id: Any,
|
|
172
|
+
filepath: Any,
|
|
173
|
+
*,
|
|
174
|
+
filename: Optional[str] = None,
|
|
175
|
+
client: Optional[httpx.AsyncClient] = None,
|
|
176
|
+
) -> Dict[str, Any]:
|
|
177
|
+
"""Upload a file to a project's uploads endpoint and return the GitLab response.
|
|
178
|
+
|
|
179
|
+
The response includes a ``markdown`` field (e.g. ````)
|
|
180
|
+
that callers can splice into issue/MR/comment bodies.
|
|
181
|
+
"""
|
|
182
|
+
from pathlib import Path
|
|
183
|
+
|
|
184
|
+
path = Path(filepath)
|
|
185
|
+
final_name = filename or path.name
|
|
186
|
+
|
|
187
|
+
async def _exec(_client: httpx.AsyncClient) -> Dict[str, Any]:
|
|
188
|
+
async with __upload_sem:
|
|
189
|
+
with open(path, "rb") as fh:
|
|
190
|
+
files = {"file": (final_name, fh.read())}
|
|
191
|
+
r = await _client.post(
|
|
192
|
+
f"/projects/{encode_path_segment(project_id)}/uploads",
|
|
193
|
+
files=files,
|
|
194
|
+
)
|
|
195
|
+
r.raise_for_status()
|
|
196
|
+
return r.json()
|
|
197
|
+
|
|
198
|
+
if client is None:
|
|
199
|
+
async with http_client() as _client:
|
|
200
|
+
return await _exec(_client)
|
|
201
|
+
return await _exec(client)
|