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.
@@ -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.
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2026-present Chris O'Neill <chris@purplejay.io>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ __version__ = "5.0.0"
@@ -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. ``![alt](/uploads/.../foo.png)``)
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)