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.
- runspec_gitlab_core/__init__.py +63 -0
- runspec_gitlab_core/client.py +171 -0
- runspec_gitlab_core/issues.py +45 -0
- runspec_gitlab_core/merge_requests.py +45 -0
- runspec_gitlab_core/pipelines.py +68 -0
- runspec_gitlab_core/projects.py +51 -0
- runspec_gitlab_core/repository.py +68 -0
- runspec_gitlab_core-0.1.0.dist-info/METADATA +10 -0
- runspec_gitlab_core-0.1.0.dist-info/RECORD +10 -0
- runspec_gitlab_core-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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,,
|