runspec-gitlab-core 0.1.0__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.
- runspec_gitlab_core-0.1.0/.gitignore +61 -0
- runspec_gitlab_core-0.1.0/CHANGELOG.md +19 -0
- runspec_gitlab_core-0.1.0/PKG-INFO +10 -0
- runspec_gitlab_core-0.1.0/pyproject.toml +32 -0
- runspec_gitlab_core-0.1.0/runspec_gitlab_core/__init__.py +63 -0
- runspec_gitlab_core-0.1.0/runspec_gitlab_core/client.py +171 -0
- runspec_gitlab_core-0.1.0/runspec_gitlab_core/issues.py +45 -0
- runspec_gitlab_core-0.1.0/runspec_gitlab_core/merge_requests.py +45 -0
- runspec_gitlab_core-0.1.0/runspec_gitlab_core/pipelines.py +68 -0
- runspec_gitlab_core-0.1.0/runspec_gitlab_core/projects.py +51 -0
- runspec_gitlab_core-0.1.0/runspec_gitlab_core/repository.py +68 -0
- runspec_gitlab_core-0.1.0/tests/__init__.py +0 -0
- runspec_gitlab_core-0.1.0/tests/conftest.py +33 -0
- runspec_gitlab_core-0.1.0/tests/test_api.py +138 -0
- runspec_gitlab_core-0.1.0/tests/test_client.py +145 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.egg
|
|
8
|
+
*.egg-info/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
.eggs/
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
env/
|
|
15
|
+
.env
|
|
16
|
+
pip-wheel-metadata/
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
htmlcov/
|
|
21
|
+
.coverage
|
|
22
|
+
coverage.xml
|
|
23
|
+
*.cover
|
|
24
|
+
|
|
25
|
+
# Node
|
|
26
|
+
node_modules/
|
|
27
|
+
dist/
|
|
28
|
+
*.js.map
|
|
29
|
+
.npm
|
|
30
|
+
|
|
31
|
+
# Go
|
|
32
|
+
*.exe
|
|
33
|
+
*.test
|
|
34
|
+
*.out
|
|
35
|
+
vendor/
|
|
36
|
+
|
|
37
|
+
# IDE
|
|
38
|
+
.idea/
|
|
39
|
+
.vscode/
|
|
40
|
+
*.iml
|
|
41
|
+
*.iws
|
|
42
|
+
*.ipr
|
|
43
|
+
.DS_Store
|
|
44
|
+
Thumbs.db
|
|
45
|
+
|
|
46
|
+
# Docs
|
|
47
|
+
site/
|
|
48
|
+
|
|
49
|
+
# Misc
|
|
50
|
+
*.log
|
|
51
|
+
*.tmp
|
|
52
|
+
|
|
53
|
+
# External reference repos (cloned locally, not committed)
|
|
54
|
+
chainlit-docs/
|
|
55
|
+
.chainlit/
|
|
56
|
+
|
|
57
|
+
# Claude Code local config (machine-specific)
|
|
58
|
+
.claude/launch.json
|
|
59
|
+
|
|
60
|
+
# Stray committed test venv (removed from tracking)
|
|
61
|
+
.venv-test/
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `runspec-gitlab-core` are documented here.
|
|
4
|
+
|
|
5
|
+
## [0.1.0]
|
|
6
|
+
|
|
7
|
+
Initial release. Pure-Python GitLab REST (v4) read helpers — the importable core
|
|
8
|
+
behind `runspec-gitlab`, with **no runspec dependency, no runspec.toml, and no
|
|
9
|
+
entry points** (so it surfaces no runnables and stays importable on its own).
|
|
10
|
+
|
|
11
|
+
- `GitlabConfig` + `config_from_env` — env-var driven connection/identity
|
|
12
|
+
(`GITLAB_URL` defaulting to `https://gitlab.com`, `GITLAB_TOKEN`, optional
|
|
13
|
+
`GITLAB_PROJECT`), with `GITLAB_<ENV>_*` prefixes selected by `env`.
|
|
14
|
+
- `gitlab_request` / `encode_project` — authenticated `PRIVATE-TOKEN` request
|
|
15
|
+
helper against `{base_url}/api/v4`, raising `GitlabError` on non-2xx.
|
|
16
|
+
- Read helpers ported from the read tools of GitLab's hosted MCP server:
|
|
17
|
+
`whoami`, `get_project`, `search_projects`, `list_issues`, `get_issue`,
|
|
18
|
+
`list_merge_requests`, `get_merge_request`, `get_merge_request_changes`,
|
|
19
|
+
`list_pipelines`, `get_pipeline_jobs`, `get_job_log`, `get_file`, `list_tree`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runspec-gitlab-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pure-Python GitLab REST helpers — the importable core behind runspec-gitlab (no runspec dependency, no runnables)
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: httpx
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
9
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
10
|
+
Requires-Dist: ruff==0.15.20; extra == 'dev'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runspec-gitlab-core"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
description = "Pure-Python GitLab REST helpers — the importable core behind runspec-gitlab (no runspec dependency, no runnables)"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"httpx",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = [
|
|
16
|
+
"ruff==0.15.20",
|
|
17
|
+
"mypy",
|
|
18
|
+
"pytest>=8.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
testpaths = ["tests"]
|
|
23
|
+
|
|
24
|
+
[tool.mypy]
|
|
25
|
+
python_version = "3.10"
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
line-length = 200
|
|
29
|
+
target-version = "py310"
|
|
30
|
+
|
|
31
|
+
[tool.ruff.lint]
|
|
32
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""runspec-gitlab-core — pure-Python GitLab REST (v4) read helpers.
|
|
2
|
+
|
|
3
|
+
This package depends only on ``httpx`` — it has **no dependency on runspec** and
|
|
4
|
+
ships **no runspec.toml and no entry points**, so installing it exposes the
|
|
5
|
+
helper functions for import without surfacing any runnables (it is invisible to
|
|
6
|
+
``runspec local`` / ``runspec serve`` discovery). ``runspec-gitlab`` depends on it
|
|
7
|
+
and wraps each helper as a verb of the ``gitlab`` runnable.
|
|
8
|
+
|
|
9
|
+
The helpers port the read tools of GitLab's own (hosted) MCP server onto the
|
|
10
|
+
public REST API. Every operation takes an explicit :class:`GitlabConfig` first
|
|
11
|
+
argument, so a private (e.g. Nexus-hosted) wrapper package can inject credentials
|
|
12
|
+
in code, bake in a default ``project`` / ``env``, and build "one runnable per
|
|
13
|
+
project" on top without re-implementing the REST plumbing. Each function does the
|
|
14
|
+
work and returns plain data; failures raise :class:`GitlabError`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from runspec_gitlab_core.client import (
|
|
18
|
+
DEFAULT_URL,
|
|
19
|
+
VALID_ENVS,
|
|
20
|
+
GitlabConfig,
|
|
21
|
+
GitlabError,
|
|
22
|
+
config_from_env,
|
|
23
|
+
encode_project,
|
|
24
|
+
gitlab_request,
|
|
25
|
+
)
|
|
26
|
+
from runspec_gitlab_core.issues import get_issue, list_issues
|
|
27
|
+
from runspec_gitlab_core.merge_requests import (
|
|
28
|
+
get_merge_request,
|
|
29
|
+
get_merge_request_changes,
|
|
30
|
+
list_merge_requests,
|
|
31
|
+
)
|
|
32
|
+
from runspec_gitlab_core.pipelines import get_job_log, get_pipeline_jobs, list_pipelines
|
|
33
|
+
from runspec_gitlab_core.projects import get_project, search_projects, whoami
|
|
34
|
+
from runspec_gitlab_core.repository import get_file, list_tree
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
# client / config
|
|
38
|
+
"GitlabConfig",
|
|
39
|
+
"GitlabError",
|
|
40
|
+
"config_from_env",
|
|
41
|
+
"gitlab_request",
|
|
42
|
+
"encode_project",
|
|
43
|
+
"VALID_ENVS",
|
|
44
|
+
"DEFAULT_URL",
|
|
45
|
+
# identity / projects
|
|
46
|
+
"whoami",
|
|
47
|
+
"get_project",
|
|
48
|
+
"search_projects",
|
|
49
|
+
# issues
|
|
50
|
+
"list_issues",
|
|
51
|
+
"get_issue",
|
|
52
|
+
# merge requests
|
|
53
|
+
"list_merge_requests",
|
|
54
|
+
"get_merge_request",
|
|
55
|
+
"get_merge_request_changes",
|
|
56
|
+
# pipelines / jobs
|
|
57
|
+
"list_pipelines",
|
|
58
|
+
"get_pipeline_jobs",
|
|
59
|
+
"get_job_log",
|
|
60
|
+
# repository
|
|
61
|
+
"get_file",
|
|
62
|
+
"list_tree",
|
|
63
|
+
]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""client.py — authenticated GitLab REST (v4) helper.
|
|
2
|
+
|
|
3
|
+
This module is the reusable core of ``runspec-gitlab``. Every operation function
|
|
4
|
+
in the package takes an explicit :class:`GitlabConfig` as its first argument, so a
|
|
5
|
+
private (e.g. Nexus-hosted) wrapper package can construct a config from injected
|
|
6
|
+
credentials and call the same functions the CLI runnable's verbs call.
|
|
7
|
+
|
|
8
|
+
Auth is environment-variable driven (see :func:`config_from_env`) but a caller is
|
|
9
|
+
free to build a :class:`GitlabConfig` by hand. A single personal-access token
|
|
10
|
+
(``GITLAB_TOKEN``) is sent as the ``PRIVATE-TOKEN`` header; the instance URL
|
|
11
|
+
(``GITLAB_URL``) defaults to ``https://gitlab.com`` for the SaaS case.
|
|
12
|
+
|
|
13
|
+
This mirrors the read tools of GitLab's own (hosted) MCP server, but ports them
|
|
14
|
+
onto the public REST API rather than proxying that server — so the runnable runs
|
|
15
|
+
over the runspec-console SSH fleet with no extra process.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Any
|
|
23
|
+
from urllib.parse import quote
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
VALID_ENVS = ("dev", "uat", "prod")
|
|
28
|
+
DEFAULT_URL = "https://gitlab.com"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GitlabError(Exception):
|
|
32
|
+
"""Raised on configuration problems and non-2xx API responses."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class GitlabConfig:
|
|
37
|
+
"""Connection + identity for a GitLab instance.
|
|
38
|
+
|
|
39
|
+
``project`` is an optional default a wrapper package can bake in once so
|
|
40
|
+
every call inherits it (accepts a numeric id or a ``namespace/project``
|
|
41
|
+
path). ``env`` records which logical environment (dev/uat/prod) the config
|
|
42
|
+
targets, when one was selected.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
base_url: str
|
|
46
|
+
token: str
|
|
47
|
+
project: str | None = None
|
|
48
|
+
env: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _env_first(names: list[str]) -> str | None:
|
|
52
|
+
"""Return the first non-empty environment variable among ``names``."""
|
|
53
|
+
for name in names:
|
|
54
|
+
value = os.environ.get(name)
|
|
55
|
+
if value:
|
|
56
|
+
return value
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def config_from_env(env: str | None = None) -> GitlabConfig:
|
|
61
|
+
"""Build a :class:`GitlabConfig` from environment variables.
|
|
62
|
+
|
|
63
|
+
When ``env`` (or the ``GITLAB_ENV`` variable) selects dev/uat/prod, the
|
|
64
|
+
environment-prefixed variables are read first (e.g. ``GITLAB_UAT_TOKEN``),
|
|
65
|
+
falling back to the unprefixed ``GITLAB_*`` family for single-instance
|
|
66
|
+
setups. ``GITLAB_URL`` defaults to ``https://gitlab.com`` when unset. Raises
|
|
67
|
+
:class:`GitlabError` with an actionable message when the env label is invalid
|
|
68
|
+
or the token is missing.
|
|
69
|
+
"""
|
|
70
|
+
env = env or os.environ.get("GITLAB_ENV")
|
|
71
|
+
if env is not None:
|
|
72
|
+
env = env.lower()
|
|
73
|
+
if env not in VALID_ENVS:
|
|
74
|
+
raise GitlabError(f"Invalid env {env!r} — expected one of {', '.join(VALID_ENVS)}")
|
|
75
|
+
|
|
76
|
+
def lookup(suffix: str) -> str | None:
|
|
77
|
+
names = []
|
|
78
|
+
if env:
|
|
79
|
+
names.append(f"GITLAB_{env.upper()}_{suffix}")
|
|
80
|
+
names.append(f"GITLAB_{suffix}")
|
|
81
|
+
return _env_first(names)
|
|
82
|
+
|
|
83
|
+
token = lookup("TOKEN")
|
|
84
|
+
if not token:
|
|
85
|
+
hint = f"GITLAB_{env.upper()}_TOKEN" if env else "GITLAB_TOKEN"
|
|
86
|
+
raise GitlabError(f"No token configured — set {hint} (a GitLab personal access token)")
|
|
87
|
+
|
|
88
|
+
base_url = lookup("URL") or DEFAULT_URL
|
|
89
|
+
|
|
90
|
+
return GitlabConfig(
|
|
91
|
+
base_url=base_url.rstrip("/"),
|
|
92
|
+
token=token,
|
|
93
|
+
project=lookup("PROJECT"),
|
|
94
|
+
env=env,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def encode_project(project: str) -> str:
|
|
99
|
+
"""URL-encode a project identifier for the ``/projects/:id`` path.
|
|
100
|
+
|
|
101
|
+
A ``namespace/project`` path is percent-encoded (slashes included, as GitLab
|
|
102
|
+
requires); a numeric id passes through unchanged.
|
|
103
|
+
"""
|
|
104
|
+
return quote(str(project), safe="")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def gitlab_request(
|
|
108
|
+
method: str,
|
|
109
|
+
path: str,
|
|
110
|
+
*,
|
|
111
|
+
config: GitlabConfig,
|
|
112
|
+
params: dict[str, Any] | None = None,
|
|
113
|
+
raw: bool = False,
|
|
114
|
+
timeout: int = 30,
|
|
115
|
+
) -> Any:
|
|
116
|
+
"""Make an authenticated request against ``{base_url}/api/v4`` and return the
|
|
117
|
+
parsed JSON body (or the raw text when ``raw=True`` — used for job traces).
|
|
118
|
+
|
|
119
|
+
``path`` should start with ``/`` (e.g. ``/projects/1/issues``). ``None`` values
|
|
120
|
+
in ``params`` are dropped and booleans are rendered as ``"true"``/``"false"``.
|
|
121
|
+
Raises :class:`GitlabError` on any non-2xx response, surfacing the server's
|
|
122
|
+
error message.
|
|
123
|
+
"""
|
|
124
|
+
headers = {"PRIVATE-TOKEN": config.token}
|
|
125
|
+
if not raw:
|
|
126
|
+
headers["Accept"] = "application/json"
|
|
127
|
+
|
|
128
|
+
clean: dict[str, Any] | None = None
|
|
129
|
+
if params:
|
|
130
|
+
clean = {}
|
|
131
|
+
for key, value in params.items():
|
|
132
|
+
if value is None:
|
|
133
|
+
continue
|
|
134
|
+
clean[key] = "true" if value is True else "false" if value is False else value
|
|
135
|
+
|
|
136
|
+
resp = httpx.request(
|
|
137
|
+
method,
|
|
138
|
+
f"{config.base_url}/api/v4{path}",
|
|
139
|
+
headers=headers,
|
|
140
|
+
params=clean,
|
|
141
|
+
timeout=timeout,
|
|
142
|
+
)
|
|
143
|
+
if resp.status_code >= 400:
|
|
144
|
+
raise GitlabError(_error_message(resp))
|
|
145
|
+
if resp.status_code == 204 or not resp.content:
|
|
146
|
+
return "" if raw else None
|
|
147
|
+
if raw:
|
|
148
|
+
return resp.text
|
|
149
|
+
return resp.json()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _error_message(resp: httpx.Response) -> str:
|
|
153
|
+
"""Extract a human-readable message from a GitLab error response body."""
|
|
154
|
+
try:
|
|
155
|
+
body = resp.json()
|
|
156
|
+
except Exception:
|
|
157
|
+
return f"GitLab {resp.status_code}: {resp.text[:500]}"
|
|
158
|
+
|
|
159
|
+
if isinstance(body, dict):
|
|
160
|
+
# GitLab returns {"message": "..."} or {"message": {field: [errs]}},
|
|
161
|
+
# and OAuth-style {"error": "...", "error_description": "..."}.
|
|
162
|
+
message = body.get("message")
|
|
163
|
+
if isinstance(message, str):
|
|
164
|
+
return f"GitLab {resp.status_code}: {message}"
|
|
165
|
+
if isinstance(message, dict):
|
|
166
|
+
parts = [f"{k}: {', '.join(map(str, v)) if isinstance(v, list) else v}" for k, v in message.items()]
|
|
167
|
+
return f"GitLab {resp.status_code}: {'; '.join(parts)}"
|
|
168
|
+
error = body.get("error_description") or body.get("error")
|
|
169
|
+
if error:
|
|
170
|
+
return f"GitLab {resp.status_code}: {error}"
|
|
171
|
+
return f"GitLab {resp.status_code}: {str(body)[:500]}"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""issues.py — project issue read operations (GitLab REST v4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from runspec_gitlab_core.client import GitlabConfig, gitlab_request
|
|
8
|
+
from runspec_gitlab_core.projects import _project
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def list_issues(
|
|
12
|
+
config: GitlabConfig,
|
|
13
|
+
project: str | None = None,
|
|
14
|
+
state: str | None = None,
|
|
15
|
+
labels: str | None = None,
|
|
16
|
+
search: str | None = None,
|
|
17
|
+
per_page: int = 20,
|
|
18
|
+
page: int = 1,
|
|
19
|
+
) -> list[dict[str, Any]]:
|
|
20
|
+
"""List a project's issues.
|
|
21
|
+
|
|
22
|
+
``state`` is ``opened``/``closed``/``all``; ``labels`` is a comma-separated
|
|
23
|
+
label list; ``search`` matches title/description.
|
|
24
|
+
"""
|
|
25
|
+
return gitlab_request(
|
|
26
|
+
"GET",
|
|
27
|
+
f"/projects/{_project(config, project)}/issues",
|
|
28
|
+
config=config,
|
|
29
|
+
params={
|
|
30
|
+
"state": state,
|
|
31
|
+
"labels": labels,
|
|
32
|
+
"search": search,
|
|
33
|
+
"per_page": per_page,
|
|
34
|
+
"page": page,
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_issue(config: GitlabConfig, issue_iid: int, project: str | None = None) -> dict[str, Any]:
|
|
40
|
+
"""Fetch a single issue by its project-scoped internal id (``iid``)."""
|
|
41
|
+
return gitlab_request(
|
|
42
|
+
"GET",
|
|
43
|
+
f"/projects/{_project(config, project)}/issues/{issue_iid}",
|
|
44
|
+
config=config,
|
|
45
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""merge_requests.py — merge-request read operations (GitLab REST v4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from runspec_gitlab_core.client import GitlabConfig, gitlab_request
|
|
8
|
+
from runspec_gitlab_core.projects import _project
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def list_merge_requests(
|
|
12
|
+
config: GitlabConfig,
|
|
13
|
+
project: str | None = None,
|
|
14
|
+
state: str | None = None,
|
|
15
|
+
per_page: int = 20,
|
|
16
|
+
page: int = 1,
|
|
17
|
+
) -> list[dict[str, Any]]:
|
|
18
|
+
"""List a project's merge requests.
|
|
19
|
+
|
|
20
|
+
``state`` is ``opened``/``closed``/``merged``/``locked``/``all``.
|
|
21
|
+
"""
|
|
22
|
+
return gitlab_request(
|
|
23
|
+
"GET",
|
|
24
|
+
f"/projects/{_project(config, project)}/merge_requests",
|
|
25
|
+
config=config,
|
|
26
|
+
params={"state": state, "per_page": per_page, "page": page},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_merge_request(config: GitlabConfig, mr_iid: int, project: str | None = None) -> dict[str, Any]:
|
|
31
|
+
"""Fetch a single merge request by its project-scoped internal id (``iid``)."""
|
|
32
|
+
return gitlab_request(
|
|
33
|
+
"GET",
|
|
34
|
+
f"/projects/{_project(config, project)}/merge_requests/{mr_iid}",
|
|
35
|
+
config=config,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_merge_request_changes(config: GitlabConfig, mr_iid: int, project: str | None = None) -> dict[str, Any]:
|
|
40
|
+
"""Fetch a merge request together with its file changes / diffs."""
|
|
41
|
+
return gitlab_request(
|
|
42
|
+
"GET",
|
|
43
|
+
f"/projects/{_project(config, project)}/merge_requests/{mr_iid}/changes",
|
|
44
|
+
config=config,
|
|
45
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""pipelines.py — CI/CD pipeline + job read operations (GitLab REST v4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from runspec_gitlab_core.client import GitlabConfig, gitlab_request
|
|
8
|
+
from runspec_gitlab_core.projects import _project
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def list_pipelines(
|
|
12
|
+
config: GitlabConfig,
|
|
13
|
+
project: str | None = None,
|
|
14
|
+
ref: str | None = None,
|
|
15
|
+
status: str | None = None,
|
|
16
|
+
per_page: int = 20,
|
|
17
|
+
page: int = 1,
|
|
18
|
+
) -> list[dict[str, Any]]:
|
|
19
|
+
"""List a project's pipelines, optionally filtered by ``ref`` (branch/tag)
|
|
20
|
+
and ``status`` (e.g. ``success``/``failed``/``running``)."""
|
|
21
|
+
return gitlab_request(
|
|
22
|
+
"GET",
|
|
23
|
+
f"/projects/{_project(config, project)}/pipelines",
|
|
24
|
+
config=config,
|
|
25
|
+
params={"ref": ref, "status": status, "per_page": per_page, "page": page},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_pipeline_jobs(
|
|
30
|
+
config: GitlabConfig,
|
|
31
|
+
pipeline_id: int,
|
|
32
|
+
project: str | None = None,
|
|
33
|
+
per_page: int = 20,
|
|
34
|
+
page: int = 1,
|
|
35
|
+
) -> list[dict[str, Any]]:
|
|
36
|
+
"""List the jobs of a pipeline (by pipeline id)."""
|
|
37
|
+
return gitlab_request(
|
|
38
|
+
"GET",
|
|
39
|
+
f"/projects/{_project(config, project)}/pipelines/{pipeline_id}/jobs",
|
|
40
|
+
config=config,
|
|
41
|
+
params={"per_page": per_page, "page": page},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_job_log(
|
|
46
|
+
config: GitlabConfig,
|
|
47
|
+
job_id: int,
|
|
48
|
+
project: str | None = None,
|
|
49
|
+
tail: int | None = None,
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
"""Fetch a job's log (trace) as plain text.
|
|
52
|
+
|
|
53
|
+
``tail`` returns only the last N lines — logs are large, so an agent should
|
|
54
|
+
usually pass one. Returns ``{job_id, trace, truncated}``.
|
|
55
|
+
"""
|
|
56
|
+
text = gitlab_request(
|
|
57
|
+
"GET",
|
|
58
|
+
f"/projects/{_project(config, project)}/jobs/{job_id}/trace",
|
|
59
|
+
config=config,
|
|
60
|
+
raw=True,
|
|
61
|
+
)
|
|
62
|
+
truncated = False
|
|
63
|
+
if tail is not None and tail >= 0:
|
|
64
|
+
lines = text.splitlines()
|
|
65
|
+
if len(lines) > tail:
|
|
66
|
+
text = "\n".join(lines[-tail:])
|
|
67
|
+
truncated = True
|
|
68
|
+
return {"job_id": job_id, "trace": text, "truncated": truncated}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""projects.py — identity + project read operations (GitLab REST v4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from runspec_gitlab_core.client import GitlabConfig, GitlabError, encode_project, gitlab_request
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _project(config: GitlabConfig, project: str | None) -> str:
|
|
11
|
+
"""Resolve the target project, falling back to the config default, and
|
|
12
|
+
percent-encode it for the URL path."""
|
|
13
|
+
target = project or config.project
|
|
14
|
+
if not target:
|
|
15
|
+
raise GitlabError("No project — pass project or set it on the config (GITLAB_PROJECT)")
|
|
16
|
+
return encode_project(target)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def whoami(config: GitlabConfig) -> dict[str, Any]:
|
|
20
|
+
"""Return the authenticated user (``GET /user``) — a quick token/identity check."""
|
|
21
|
+
return gitlab_request("GET", "/user", config=config)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_project(config: GitlabConfig, project: str | None = None) -> dict[str, Any]:
|
|
25
|
+
"""Fetch a single project's metadata by numeric id or ``namespace/project`` path."""
|
|
26
|
+
return gitlab_request("GET", f"/projects/{_project(config, project)}", config=config)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def search_projects(
|
|
30
|
+
config: GitlabConfig,
|
|
31
|
+
search: str,
|
|
32
|
+
membership: bool = False,
|
|
33
|
+
per_page: int = 20,
|
|
34
|
+
page: int = 1,
|
|
35
|
+
) -> list[dict[str, Any]]:
|
|
36
|
+
"""Search visible projects by name/path.
|
|
37
|
+
|
|
38
|
+
``membership`` limits results to projects the token's user is a member of.
|
|
39
|
+
"""
|
|
40
|
+
return gitlab_request(
|
|
41
|
+
"GET",
|
|
42
|
+
"/projects",
|
|
43
|
+
config=config,
|
|
44
|
+
params={
|
|
45
|
+
"search": search,
|
|
46
|
+
"membership": membership,
|
|
47
|
+
"order_by": "last_activity_at",
|
|
48
|
+
"per_page": per_page,
|
|
49
|
+
"page": page,
|
|
50
|
+
},
|
|
51
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""repository.py — repository file + tree read operations (GitLab REST v4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
from runspec_gitlab_core.client import GitlabConfig, gitlab_request
|
|
11
|
+
from runspec_gitlab_core.projects import _project
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_file(
|
|
15
|
+
config: GitlabConfig,
|
|
16
|
+
file_path: str,
|
|
17
|
+
ref: str = "HEAD",
|
|
18
|
+
project: str | None = None,
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
"""Fetch a single repository file at ``ref`` (default ``HEAD`` = the default
|
|
21
|
+
branch's tip).
|
|
22
|
+
|
|
23
|
+
The base64 payload GitLab returns is decoded: UTF-8 text is returned under
|
|
24
|
+
``content``; a file that isn't valid UTF-8 is reported as
|
|
25
|
+
``{"binary": true, "content_base64": ...}`` instead.
|
|
26
|
+
"""
|
|
27
|
+
encoded_path = quote(file_path, safe="")
|
|
28
|
+
result = gitlab_request(
|
|
29
|
+
"GET",
|
|
30
|
+
f"/projects/{_project(config, project)}/repository/files/{encoded_path}",
|
|
31
|
+
config=config,
|
|
32
|
+
params={"ref": ref},
|
|
33
|
+
)
|
|
34
|
+
raw = result.get("content")
|
|
35
|
+
if isinstance(raw, str) and result.get("encoding") == "base64":
|
|
36
|
+
try:
|
|
37
|
+
data = base64.b64decode(raw)
|
|
38
|
+
result["content"] = data.decode("utf-8")
|
|
39
|
+
except (binascii.Error, UnicodeDecodeError):
|
|
40
|
+
result.pop("content", None)
|
|
41
|
+
result["content_base64"] = raw
|
|
42
|
+
result["binary"] = True
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def list_tree(
|
|
47
|
+
config: GitlabConfig,
|
|
48
|
+
path: str | None = None,
|
|
49
|
+
ref: str | None = None,
|
|
50
|
+
recursive: bool = False,
|
|
51
|
+
project: str | None = None,
|
|
52
|
+
per_page: int = 20,
|
|
53
|
+
page: int = 1,
|
|
54
|
+
) -> list[dict[str, Any]]:
|
|
55
|
+
"""List a repository tree at ``path`` (default root) and ``ref`` (default
|
|
56
|
+
branch). ``recursive`` walks subdirectories."""
|
|
57
|
+
return gitlab_request(
|
|
58
|
+
"GET",
|
|
59
|
+
f"/projects/{_project(config, project)}/repository/tree",
|
|
60
|
+
config=config,
|
|
61
|
+
params={
|
|
62
|
+
"path": path,
|
|
63
|
+
"ref": ref,
|
|
64
|
+
"recursive": recursive,
|
|
65
|
+
"per_page": per_page,
|
|
66
|
+
"page": page,
|
|
67
|
+
},
|
|
68
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Shared test helpers: a clean GITLAB_* env and an httpx.MockTransport router."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from runspec_gitlab_core import client as client_mod
|
|
11
|
+
|
|
12
|
+
_SUFFIXES = ("URL", "TOKEN", "PROJECT")
|
|
13
|
+
_ALL_VARS = ["GITLAB_ENV"] + [f"GITLAB_{s}" for s in _SUFFIXES]
|
|
14
|
+
for _env in ("DEV", "UAT", "PROD"):
|
|
15
|
+
_ALL_VARS += [f"GITLAB_{_env}_{s}" for s in _SUFFIXES]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(autouse=True)
|
|
19
|
+
def _clean_env(monkeypatch):
|
|
20
|
+
for var in _ALL_VARS:
|
|
21
|
+
monkeypatch.delenv(var, raising=False)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def mock_request(monkeypatch, handler: Callable[[httpx.Request], httpx.Response]) -> None:
|
|
25
|
+
"""Route runspec_gitlab_core.client's module-level httpx.request through a
|
|
26
|
+
MockTransport so tests exercise real URL/param/header building."""
|
|
27
|
+
transport = httpx.MockTransport(handler)
|
|
28
|
+
|
|
29
|
+
def fake_request(method, url, **kwargs):
|
|
30
|
+
with httpx.Client(transport=transport) as c:
|
|
31
|
+
return c.request(method, url, **kwargs)
|
|
32
|
+
|
|
33
|
+
monkeypatch.setattr(client_mod.httpx, "request", fake_request)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Read-helper tests — assert each helper hits the right URL/params and shapes
|
|
2
|
+
its result, routed through an httpx.MockTransport (no real network)."""
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from runspec_gitlab_core import (
|
|
10
|
+
GitlabConfig,
|
|
11
|
+
GitlabError,
|
|
12
|
+
get_file,
|
|
13
|
+
get_issue,
|
|
14
|
+
get_job_log,
|
|
15
|
+
get_merge_request_changes,
|
|
16
|
+
list_issues,
|
|
17
|
+
list_pipelines,
|
|
18
|
+
list_tree,
|
|
19
|
+
search_projects,
|
|
20
|
+
whoami,
|
|
21
|
+
)
|
|
22
|
+
from tests.conftest import mock_request
|
|
23
|
+
|
|
24
|
+
CFG = GitlabConfig(base_url="https://gl", token="tok", project="grp/app")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _capture(monkeypatch, response: httpx.Response) -> dict:
|
|
28
|
+
seen: dict = {}
|
|
29
|
+
|
|
30
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
31
|
+
# raw_path keeps %2F encoded (the real wire path); .path would decode it.
|
|
32
|
+
seen["path"] = request.url.raw_path.decode().split("?")[0]
|
|
33
|
+
seen["query"] = dict(request.url.params)
|
|
34
|
+
return response
|
|
35
|
+
|
|
36
|
+
mock_request(monkeypatch, handler)
|
|
37
|
+
return seen
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_whoami(monkeypatch):
|
|
41
|
+
seen = _capture(monkeypatch, httpx.Response(200, json={"username": "alice"}))
|
|
42
|
+
assert whoami(CFG) == {"username": "alice"}
|
|
43
|
+
assert seen["path"] == "/api/v4/user"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_search_projects_encodes_membership(monkeypatch):
|
|
47
|
+
seen = _capture(monkeypatch, httpx.Response(200, json=[]))
|
|
48
|
+
search_projects(CFG, "runspec", membership=True)
|
|
49
|
+
assert seen["path"] == "/api/v4/projects"
|
|
50
|
+
assert seen["query"]["search"] == "runspec"
|
|
51
|
+
assert seen["query"]["membership"] == "true"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_list_issues_uses_default_project_and_filters(monkeypatch):
|
|
55
|
+
seen = _capture(monkeypatch, httpx.Response(200, json=[{"iid": 1}]))
|
|
56
|
+
list_issues(CFG, state="opened", labels="bug,urgent")
|
|
57
|
+
assert seen["path"] == "/api/v4/projects/grp%2Fapp/issues"
|
|
58
|
+
assert seen["query"]["state"] == "opened"
|
|
59
|
+
assert seen["query"]["labels"] == "bug,urgent"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_get_issue_by_iid(monkeypatch):
|
|
63
|
+
seen = _capture(monkeypatch, httpx.Response(200, json={"iid": 5}))
|
|
64
|
+
assert get_issue(CFG, 5) == {"iid": 5}
|
|
65
|
+
assert seen["path"] == "/api/v4/projects/grp%2Fapp/issues/5"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_explicit_project_overrides_default(monkeypatch):
|
|
69
|
+
seen = _capture(monkeypatch, httpx.Response(200, json=[]))
|
|
70
|
+
list_issues(CFG, project="99")
|
|
71
|
+
assert seen["path"] == "/api/v4/projects/99/issues"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_missing_project_raises(monkeypatch):
|
|
75
|
+
_capture(monkeypatch, httpx.Response(200, json=[]))
|
|
76
|
+
cfg = GitlabConfig(base_url="https://gl", token="tok") # no default project
|
|
77
|
+
with pytest.raises(GitlabError, match="No project"):
|
|
78
|
+
list_issues(cfg)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_mr_changes_path(monkeypatch):
|
|
82
|
+
seen = _capture(monkeypatch, httpx.Response(200, json={"changes": []}))
|
|
83
|
+
get_merge_request_changes(CFG, 12)
|
|
84
|
+
assert seen["path"] == "/api/v4/projects/grp%2Fapp/merge_requests/12/changes"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_list_pipelines_filters(monkeypatch):
|
|
88
|
+
seen = _capture(monkeypatch, httpx.Response(200, json=[]))
|
|
89
|
+
list_pipelines(CFG, ref="main", status="failed")
|
|
90
|
+
assert seen["path"] == "/api/v4/projects/grp%2Fapp/pipelines"
|
|
91
|
+
assert seen["query"]["ref"] == "main"
|
|
92
|
+
assert seen["query"]["status"] == "failed"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_get_job_log_tails(monkeypatch):
|
|
96
|
+
body = "\n".join(f"line{i}" for i in range(10))
|
|
97
|
+
seen = _capture(monkeypatch, httpx.Response(200, text=body))
|
|
98
|
+
result = get_job_log(CFG, 77, tail=3)
|
|
99
|
+
assert seen["path"] == "/api/v4/projects/grp%2Fapp/jobs/77/trace"
|
|
100
|
+
assert result["job_id"] == 77
|
|
101
|
+
assert result["truncated"] is True
|
|
102
|
+
assert result["trace"] == "line7\nline8\nline9"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_get_job_log_no_tail_not_truncated(monkeypatch):
|
|
106
|
+
_capture(monkeypatch, httpx.Response(200, text="a\nb"))
|
|
107
|
+
result = get_job_log(CFG, 1)
|
|
108
|
+
assert result["truncated"] is False
|
|
109
|
+
assert result["trace"] == "a\nb"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_get_file_decodes_base64_text(monkeypatch):
|
|
113
|
+
encoded = base64.b64encode(b"hello world").decode()
|
|
114
|
+
seen = _capture(
|
|
115
|
+
monkeypatch,
|
|
116
|
+
httpx.Response(200, json={"file_path": "README.md", "encoding": "base64", "content": encoded}),
|
|
117
|
+
)
|
|
118
|
+
result = get_file(CFG, "docs/README.md", ref="main")
|
|
119
|
+
assert seen["path"] == "/api/v4/projects/grp%2Fapp/repository/files/docs%2FREADME.md"
|
|
120
|
+
assert seen["query"]["ref"] == "main"
|
|
121
|
+
assert result["content"] == "hello world"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_get_file_flags_binary(monkeypatch):
|
|
125
|
+
encoded = base64.b64encode(b"\xff\xfe\x00\x01").decode()
|
|
126
|
+
_capture(monkeypatch, httpx.Response(200, json={"encoding": "base64", "content": encoded}))
|
|
127
|
+
result = get_file(CFG, "logo.png")
|
|
128
|
+
assert result.get("binary") is True
|
|
129
|
+
assert result["content_base64"] == encoded
|
|
130
|
+
assert "content" not in result
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_list_tree_recursive(monkeypatch):
|
|
134
|
+
seen = _capture(monkeypatch, httpx.Response(200, json=[{"name": "src"}]))
|
|
135
|
+
list_tree(CFG, path="src", ref="main", recursive=True)
|
|
136
|
+
assert seen["path"] == "/api/v4/projects/grp%2Fapp/repository/tree"
|
|
137
|
+
assert seen["query"]["recursive"] == "true"
|
|
138
|
+
assert seen["query"]["path"] == "src"
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from runspec_gitlab_core.client import (
|
|
5
|
+
DEFAULT_URL,
|
|
6
|
+
GitlabConfig,
|
|
7
|
+
GitlabError,
|
|
8
|
+
config_from_env,
|
|
9
|
+
encode_project,
|
|
10
|
+
gitlab_request,
|
|
11
|
+
)
|
|
12
|
+
from tests.conftest import mock_request
|
|
13
|
+
|
|
14
|
+
# ── config_from_env ─────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_config_from_env_defaults_url(monkeypatch):
|
|
18
|
+
monkeypatch.setenv("GITLAB_TOKEN", "tok123")
|
|
19
|
+
cfg = config_from_env()
|
|
20
|
+
assert cfg.base_url == DEFAULT_URL
|
|
21
|
+
assert cfg.token == "tok123"
|
|
22
|
+
assert cfg.env is None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_config_from_env_strips_trailing_slash(monkeypatch):
|
|
26
|
+
monkeypatch.setenv("GITLAB_TOKEN", "tok")
|
|
27
|
+
monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com/")
|
|
28
|
+
assert config_from_env().base_url == "https://gitlab.example.com"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_env_prefixed_resolution(monkeypatch):
|
|
32
|
+
monkeypatch.setenv("GITLAB_UAT_URL", "https://uat.example.com")
|
|
33
|
+
monkeypatch.setenv("GITLAB_UAT_TOKEN", "uat-tok")
|
|
34
|
+
monkeypatch.setenv("GITLAB_UAT_PROJECT", "grp/app")
|
|
35
|
+
cfg = config_from_env(env="uat")
|
|
36
|
+
assert cfg.base_url == "https://uat.example.com"
|
|
37
|
+
assert cfg.token == "uat-tok"
|
|
38
|
+
assert cfg.project == "grp/app"
|
|
39
|
+
assert cfg.env == "uat"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_env_prefixed_falls_back_to_unprefixed(monkeypatch):
|
|
43
|
+
monkeypatch.setenv("GITLAB_PROD_URL", "https://prod.example.com")
|
|
44
|
+
monkeypatch.setenv("GITLAB_TOKEN", "shared-tok")
|
|
45
|
+
cfg = config_from_env(env="prod")
|
|
46
|
+
assert cfg.base_url == "https://prod.example.com"
|
|
47
|
+
assert cfg.token == "shared-tok"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_env_from_gitlab_env_var(monkeypatch):
|
|
51
|
+
monkeypatch.setenv("GITLAB_ENV", "dev")
|
|
52
|
+
monkeypatch.setenv("GITLAB_DEV_TOKEN", "dev-tok")
|
|
53
|
+
cfg = config_from_env()
|
|
54
|
+
assert cfg.env == "dev"
|
|
55
|
+
assert cfg.token == "dev-tok"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_invalid_env_raises(monkeypatch):
|
|
59
|
+
with pytest.raises(GitlabError, match="Invalid env"):
|
|
60
|
+
config_from_env(env="staging")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_missing_token_raises(monkeypatch):
|
|
64
|
+
with pytest.raises(GitlabError, match="No token"):
|
|
65
|
+
config_from_env()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ── encode_project ──────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_encode_project_path():
|
|
72
|
+
assert encode_project("group/sub/app") == "group%2Fsub%2Fapp"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_encode_project_numeric():
|
|
76
|
+
assert encode_project("42") == "42"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── gitlab_request ──────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_gitlab_request_builds_url_auth_and_params(monkeypatch):
|
|
83
|
+
seen = {}
|
|
84
|
+
|
|
85
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
86
|
+
seen["url"] = str(request.url)
|
|
87
|
+
seen["method"] = request.method
|
|
88
|
+
seen["token"] = request.headers.get("PRIVATE-TOKEN")
|
|
89
|
+
return httpx.Response(200, json=[{"id": 1}])
|
|
90
|
+
|
|
91
|
+
mock_request(monkeypatch, handler)
|
|
92
|
+
cfg = GitlabConfig(base_url="https://gitlab.example.com", token="tok")
|
|
93
|
+
result = gitlab_request("GET", "/projects/7/issues", config=cfg, params={"state": "opened"})
|
|
94
|
+
|
|
95
|
+
assert result == [{"id": 1}]
|
|
96
|
+
assert seen["url"] == "https://gitlab.example.com/api/v4/projects/7/issues?state=opened"
|
|
97
|
+
assert seen["method"] == "GET"
|
|
98
|
+
assert seen["token"] == "tok"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_gitlab_request_drops_none_and_renders_bools(monkeypatch):
|
|
102
|
+
seen = {}
|
|
103
|
+
|
|
104
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
105
|
+
seen["query"] = dict(request.url.params)
|
|
106
|
+
return httpx.Response(200, json=[])
|
|
107
|
+
|
|
108
|
+
mock_request(monkeypatch, handler)
|
|
109
|
+
cfg = GitlabConfig(base_url="https://gl", token="tok")
|
|
110
|
+
gitlab_request("GET", "/x", config=cfg, params={"recursive": True, "membership": False, "ref": None})
|
|
111
|
+
|
|
112
|
+
assert seen["query"] == {"recursive": "true", "membership": "false"}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_gitlab_request_raw_returns_text(monkeypatch):
|
|
116
|
+
mock_request(monkeypatch, lambda req: httpx.Response(200, text="line1\nline2\n"))
|
|
117
|
+
cfg = GitlabConfig(base_url="https://gl", token="tok")
|
|
118
|
+
assert gitlab_request("GET", "/jobs/1/trace", config=cfg, raw=True) == "line1\nline2\n"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_gitlab_request_none_on_204(monkeypatch):
|
|
122
|
+
mock_request(monkeypatch, lambda req: httpx.Response(204))
|
|
123
|
+
cfg = GitlabConfig(base_url="https://gl", token="tok")
|
|
124
|
+
assert gitlab_request("GET", "/x", config=cfg) is None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_gitlab_request_raises_string_message(monkeypatch):
|
|
128
|
+
mock_request(monkeypatch, lambda req: httpx.Response(404, json={"message": "404 Project Not Found"}))
|
|
129
|
+
cfg = GitlabConfig(base_url="https://gl", token="tok")
|
|
130
|
+
with pytest.raises(GitlabError, match="Project Not Found"):
|
|
131
|
+
gitlab_request("GET", "/projects/nope", config=cfg)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_gitlab_request_raises_dict_message(monkeypatch):
|
|
135
|
+
mock_request(monkeypatch, lambda req: httpx.Response(400, json={"message": {"labels": ["is invalid"]}}))
|
|
136
|
+
cfg = GitlabConfig(base_url="https://gl", token="tok")
|
|
137
|
+
with pytest.raises(GitlabError, match="labels: is invalid"):
|
|
138
|
+
gitlab_request("GET", "/x", config=cfg)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_gitlab_request_raises_oauth_error(monkeypatch):
|
|
142
|
+
mock_request(monkeypatch, lambda req: httpx.Response(401, json={"error": "invalid_token", "error_description": "token expired"}))
|
|
143
|
+
cfg = GitlabConfig(base_url="https://gl", token="tok")
|
|
144
|
+
with pytest.raises(GitlabError, match="token expired"):
|
|
145
|
+
gitlab_request("GET", "/user", config=cfg)
|