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.
@@ -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)