runspec-gitlab-core 0.1.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,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
+ )
@@ -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,10 @@
1
+ runspec_gitlab_core/__init__.py,sha256=mFZzpF5lu0NXMeL6dHq3TEnu9g0NKzZMCxPC3mYBv8Q,2073
2
+ runspec_gitlab_core/client.py,sha256=7OXdxkFXwsMV2bv5_vNygS_StjjbCyDebW4ntHuMdiM,6004
3
+ runspec_gitlab_core/issues.py,sha256=U3uEmiAV59Be4gLo47Vn_ZC7NgehKEiOMdWSNmUGsbo,1282
4
+ runspec_gitlab_core/merge_requests.py,sha256=JdcW_yqkR5U1qoP-xNJUTN1pxE5qaDObcT4TMhuYbFU,1438
5
+ runspec_gitlab_core/pipelines.py,sha256=fvqdv7cUKJbKNDKk8eTMB98GAPK37olFhWrLB4jk2Ts,2029
6
+ runspec_gitlab_core/projects.py,sha256=Npx4Cv1c9dL9sNd2ugd27Xb98wv_wi4wiSnTuxTyz6w,1665
7
+ runspec_gitlab_core/repository.py,sha256=tbo8OmryMuzGYux8fExiHUPkyQLWVNIGOavDn4E8j8Q,2064
8
+ runspec_gitlab_core-0.1.0.dist-info/METADATA,sha256=UzoOYNvw95wm1QnDN9WHE_YCJ0I_OxZl4YMIKlrbEAE,377
9
+ runspec_gitlab_core-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
10
+ runspec_gitlab_core-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any