runspec-gitlab-core 0.1.0__tar.gz → 0.2.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 → runspec_gitlab_core-0.2.0}/CHANGELOG.md +17 -0
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.2.0}/PKG-INFO +1 -1
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.2.0}/pyproject.toml +1 -1
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.2.0}/runspec_gitlab_core/__init__.py +12 -3
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.2.0}/runspec_gitlab_core/client.py +10 -3
- runspec_gitlab_core-0.2.0/runspec_gitlab_core/merge_requests.py +127 -0
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.2.0}/runspec_gitlab_core/projects.py +19 -0
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.2.0}/tests/test_api.py +55 -5
- runspec_gitlab_core-0.1.0/runspec_gitlab_core/merge_requests.py +0 -45
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.2.0}/.gitignore +0 -0
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.2.0}/runspec_gitlab_core/issues.py +0 -0
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.2.0}/runspec_gitlab_core/pipelines.py +0 -0
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.2.0}/runspec_gitlab_core/repository.py +0 -0
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.2.0}/tests/__init__.py +0 -0
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.2.0}/tests/conftest.py +0 -0
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.2.0}/tests/test_client.py +0 -0
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `runspec-gitlab-core` are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.2.0]
|
|
6
|
+
|
|
7
|
+
Reconciled against GitLab's canonical MCP tool set.
|
|
8
|
+
|
|
9
|
+
- **Renamed** `get_merge_request_changes` → `get_merge_request_diffs` — the
|
|
10
|
+
canonical name; targets the paginated `/diffs` endpoint instead of the
|
|
11
|
+
deprecated `/changes`. **Breaking** API change vs 0.1.0.
|
|
12
|
+
- **Added** read helpers: `get_merge_request_commits`, `get_merge_request_notes`,
|
|
13
|
+
`get_merge_request_pipelines`, `get_merge_request_conflicts`, and
|
|
14
|
+
`resolve_project_path`.
|
|
15
|
+
- `gitlab_request` gains an `api=False` mode to target `{base_url}{path}` (no
|
|
16
|
+
`/api/v4` prefix) — used only for the non-public MR conflicts web endpoint.
|
|
17
|
+
- `get_merge_request_conflicts` is **best-effort**: it relays GitLab's
|
|
18
|
+
non-public web-UI conflicts endpoint (there is no public `/api/v4` one) and
|
|
19
|
+
returns its JSON verbatim, resolving a numeric project id to its
|
|
20
|
+
`namespace/path` via `resolve_project_path`.
|
|
21
|
+
|
|
5
22
|
## [0.1.0]
|
|
6
23
|
|
|
7
24
|
Initial release. Pure-Python GitLab REST (v4) read helpers — the importable core
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "runspec-gitlab-core"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
requires-python = ">=3.10"
|
|
9
9
|
description = "Pure-Python GitLab REST helpers — the importable core behind runspec-gitlab (no runspec dependency, no runnables)"
|
|
10
10
|
dependencies = [
|
|
@@ -26,11 +26,15 @@ from runspec_gitlab_core.client import (
|
|
|
26
26
|
from runspec_gitlab_core.issues import get_issue, list_issues
|
|
27
27
|
from runspec_gitlab_core.merge_requests import (
|
|
28
28
|
get_merge_request,
|
|
29
|
-
|
|
29
|
+
get_merge_request_commits,
|
|
30
|
+
get_merge_request_conflicts,
|
|
31
|
+
get_merge_request_diffs,
|
|
32
|
+
get_merge_request_notes,
|
|
33
|
+
get_merge_request_pipelines,
|
|
30
34
|
list_merge_requests,
|
|
31
35
|
)
|
|
32
36
|
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
|
|
37
|
+
from runspec_gitlab_core.projects import get_project, resolve_project_path, search_projects, whoami
|
|
34
38
|
from runspec_gitlab_core.repository import get_file, list_tree
|
|
35
39
|
|
|
36
40
|
__all__ = [
|
|
@@ -40,6 +44,7 @@ __all__ = [
|
|
|
40
44
|
"config_from_env",
|
|
41
45
|
"gitlab_request",
|
|
42
46
|
"encode_project",
|
|
47
|
+
"resolve_project_path",
|
|
43
48
|
"VALID_ENVS",
|
|
44
49
|
"DEFAULT_URL",
|
|
45
50
|
# identity / projects
|
|
@@ -52,7 +57,11 @@ __all__ = [
|
|
|
52
57
|
# merge requests
|
|
53
58
|
"list_merge_requests",
|
|
54
59
|
"get_merge_request",
|
|
55
|
-
"
|
|
60
|
+
"get_merge_request_diffs",
|
|
61
|
+
"get_merge_request_commits",
|
|
62
|
+
"get_merge_request_notes",
|
|
63
|
+
"get_merge_request_pipelines",
|
|
64
|
+
"get_merge_request_conflicts",
|
|
56
65
|
# pipelines / jobs
|
|
57
66
|
"list_pipelines",
|
|
58
67
|
"get_pipeline_jobs",
|
|
@@ -111,10 +111,16 @@ def gitlab_request(
|
|
|
111
111
|
config: GitlabConfig,
|
|
112
112
|
params: dict[str, Any] | None = None,
|
|
113
113
|
raw: bool = False,
|
|
114
|
+
api: bool = True,
|
|
114
115
|
timeout: int = 30,
|
|
115
116
|
) -> Any:
|
|
116
|
-
"""Make an authenticated request
|
|
117
|
-
|
|
117
|
+
"""Make an authenticated request and return the parsed JSON body (or the raw
|
|
118
|
+
text when ``raw=True`` — used for job traces).
|
|
119
|
+
|
|
120
|
+
By default the request targets the public REST API at ``{base_url}/api/v4``.
|
|
121
|
+
Pass ``api=False`` to hit ``{base_url}{path}`` directly — used only for the
|
|
122
|
+
non-public web endpoints GitLab does not expose under ``/api/v4`` (e.g. the
|
|
123
|
+
merge-request conflicts endpoint).
|
|
118
124
|
|
|
119
125
|
``path`` should start with ``/`` (e.g. ``/projects/1/issues``). ``None`` values
|
|
120
126
|
in ``params`` are dropped and booleans are rendered as ``"true"``/``"false"``.
|
|
@@ -133,9 +139,10 @@ def gitlab_request(
|
|
|
133
139
|
continue
|
|
134
140
|
clean[key] = "true" if value is True else "false" if value is False else value
|
|
135
141
|
|
|
142
|
+
prefix = "/api/v4" if api else ""
|
|
136
143
|
resp = httpx.request(
|
|
137
144
|
method,
|
|
138
|
-
f"{config.base_url}
|
|
145
|
+
f"{config.base_url}{prefix}{path}",
|
|
139
146
|
headers=headers,
|
|
140
147
|
params=clean,
|
|
141
148
|
timeout=timeout,
|
|
@@ -0,0 +1,127 @@
|
|
|
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, resolve_project_path
|
|
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_diffs(
|
|
40
|
+
config: GitlabConfig,
|
|
41
|
+
mr_iid: int,
|
|
42
|
+
project: str | None = None,
|
|
43
|
+
per_page: int = 20,
|
|
44
|
+
page: int = 1,
|
|
45
|
+
) -> list[dict[str, Any]]:
|
|
46
|
+
"""List a merge request's file diffs.
|
|
47
|
+
|
|
48
|
+
Mirrors the canonical ``get_merge_request_diffs`` MCP tool — the paginated
|
|
49
|
+
``/merge_requests/:iid/diffs`` endpoint, not the deprecated ``/changes``.
|
|
50
|
+
"""
|
|
51
|
+
return gitlab_request(
|
|
52
|
+
"GET",
|
|
53
|
+
f"/projects/{_project(config, project)}/merge_requests/{mr_iid}/diffs",
|
|
54
|
+
config=config,
|
|
55
|
+
params={"per_page": per_page, "page": page},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_merge_request_commits(
|
|
60
|
+
config: GitlabConfig,
|
|
61
|
+
mr_iid: int,
|
|
62
|
+
project: str | None = None,
|
|
63
|
+
per_page: int = 20,
|
|
64
|
+
page: int = 1,
|
|
65
|
+
) -> list[dict[str, Any]]:
|
|
66
|
+
"""List the commits on a merge request (canonical ``get_merge_request_commits``)."""
|
|
67
|
+
return gitlab_request(
|
|
68
|
+
"GET",
|
|
69
|
+
f"/projects/{_project(config, project)}/merge_requests/{mr_iid}/commits",
|
|
70
|
+
config=config,
|
|
71
|
+
params={"per_page": per_page, "page": page},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_merge_request_notes(
|
|
76
|
+
config: GitlabConfig,
|
|
77
|
+
mr_iid: int,
|
|
78
|
+
project: str | None = None,
|
|
79
|
+
sort: str | None = None,
|
|
80
|
+
per_page: int = 20,
|
|
81
|
+
page: int = 1,
|
|
82
|
+
) -> list[dict[str, Any]]:
|
|
83
|
+
"""List a merge request's notes (comments) — the REST equivalent of the
|
|
84
|
+
canonical ``get_merge_request_notes`` tool. ``sort`` is ``asc``/``desc``."""
|
|
85
|
+
return gitlab_request(
|
|
86
|
+
"GET",
|
|
87
|
+
f"/projects/{_project(config, project)}/merge_requests/{mr_iid}/notes",
|
|
88
|
+
config=config,
|
|
89
|
+
params={"sort": sort, "per_page": per_page, "page": page},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_merge_request_pipelines(
|
|
94
|
+
config: GitlabConfig,
|
|
95
|
+
mr_iid: int,
|
|
96
|
+
project: str | None = None,
|
|
97
|
+
per_page: int = 20,
|
|
98
|
+
page: int = 1,
|
|
99
|
+
) -> list[dict[str, Any]]:
|
|
100
|
+
"""List the pipelines run for a merge request (canonical ``get_merge_request_pipelines``)."""
|
|
101
|
+
return gitlab_request(
|
|
102
|
+
"GET",
|
|
103
|
+
f"/projects/{_project(config, project)}/merge_requests/{mr_iid}/pipelines",
|
|
104
|
+
config=config,
|
|
105
|
+
params={"per_page": per_page, "page": page},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_merge_request_conflicts(config: GitlabConfig, mr_iid: int, project: str | None = None) -> Any:
|
|
110
|
+
"""Fetch a merge request's conflict files (the canonical ``get_merge_request_conflicts``).
|
|
111
|
+
|
|
112
|
+
**Best-effort — not a public REST API.** GitLab exposes MR conflict *contents*
|
|
113
|
+
only through its web-UI endpoint (``{base}/{namespace}/{project}/-/merge_requests/
|
|
114
|
+
:iid/conflicts``), not under ``/api/v4``; the MCP server's own tool calls an
|
|
115
|
+
internal service in-process. This relays that endpoint's JSON verbatim (files
|
|
116
|
+
with conflict markers/sections), so its shape is GitLab's, not ours, and may
|
|
117
|
+
change between versions. Requires a token with read access to the MR (and,
|
|
118
|
+
per GitLab, push access to the source branch). A non-conflicting or
|
|
119
|
+
unmergeable MR typically yields a non-2xx surfaced as :class:`GitlabError`.
|
|
120
|
+
"""
|
|
121
|
+
path = resolve_project_path(config, project)
|
|
122
|
+
return gitlab_request(
|
|
123
|
+
"GET",
|
|
124
|
+
f"/{path}/-/merge_requests/{mr_iid}/conflicts",
|
|
125
|
+
config=config,
|
|
126
|
+
api=False,
|
|
127
|
+
)
|
|
@@ -16,6 +16,25 @@ def _project(config: GitlabConfig, project: str | None) -> str:
|
|
|
16
16
|
return encode_project(target)
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
def resolve_project_path(config: GitlabConfig, project: str | None = None) -> str:
|
|
20
|
+
"""Resolve the target project to its ``namespace/path`` form for non-API web
|
|
21
|
+
endpoints (which need real slashes, not a numeric id or a %2F-encoded path).
|
|
22
|
+
|
|
23
|
+
A numeric id is resolved to ``path_with_namespace`` via one API call; a path
|
|
24
|
+
is returned as-is.
|
|
25
|
+
"""
|
|
26
|
+
target = project or config.project
|
|
27
|
+
if not target:
|
|
28
|
+
raise GitlabError("No project — pass project or set it on the config (GITLAB_PROJECT)")
|
|
29
|
+
target = str(target)
|
|
30
|
+
if target.isdigit():
|
|
31
|
+
path = get_project(config, target).get("path_with_namespace")
|
|
32
|
+
if not path:
|
|
33
|
+
raise GitlabError(f"Could not resolve a path for project id {target}")
|
|
34
|
+
return str(path)
|
|
35
|
+
return target
|
|
36
|
+
|
|
37
|
+
|
|
19
38
|
def whoami(config: GitlabConfig) -> dict[str, Any]:
|
|
20
39
|
"""Return the authenticated user (``GET /user``) — a quick token/identity check."""
|
|
21
40
|
return gitlab_request("GET", "/user", config=config)
|
|
@@ -12,7 +12,11 @@ from runspec_gitlab_core import (
|
|
|
12
12
|
get_file,
|
|
13
13
|
get_issue,
|
|
14
14
|
get_job_log,
|
|
15
|
-
|
|
15
|
+
get_merge_request_commits,
|
|
16
|
+
get_merge_request_conflicts,
|
|
17
|
+
get_merge_request_diffs,
|
|
18
|
+
get_merge_request_notes,
|
|
19
|
+
get_merge_request_pipelines,
|
|
16
20
|
list_issues,
|
|
17
21
|
list_pipelines,
|
|
18
22
|
list_tree,
|
|
@@ -78,10 +82,56 @@ def test_missing_project_raises(monkeypatch):
|
|
|
78
82
|
list_issues(cfg)
|
|
79
83
|
|
|
80
84
|
|
|
81
|
-
def
|
|
82
|
-
seen = _capture(monkeypatch, httpx.Response(200, json=
|
|
83
|
-
|
|
84
|
-
assert seen["path"] == "/api/v4/projects/grp%2Fapp/merge_requests/12/
|
|
85
|
+
def test_mr_diffs_path(monkeypatch):
|
|
86
|
+
seen = _capture(monkeypatch, httpx.Response(200, json=[]))
|
|
87
|
+
get_merge_request_diffs(CFG, 12)
|
|
88
|
+
assert seen["path"] == "/api/v4/projects/grp%2Fapp/merge_requests/12/diffs"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_mr_commits_path(monkeypatch):
|
|
92
|
+
seen = _capture(monkeypatch, httpx.Response(200, json=[]))
|
|
93
|
+
get_merge_request_commits(CFG, 12)
|
|
94
|
+
assert seen["path"] == "/api/v4/projects/grp%2Fapp/merge_requests/12/commits"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_mr_notes_path_and_sort(monkeypatch):
|
|
98
|
+
seen = _capture(monkeypatch, httpx.Response(200, json=[]))
|
|
99
|
+
get_merge_request_notes(CFG, 12, sort="desc")
|
|
100
|
+
assert seen["path"] == "/api/v4/projects/grp%2Fapp/merge_requests/12/notes"
|
|
101
|
+
assert seen["query"]["sort"] == "desc"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_mr_pipelines_path(monkeypatch):
|
|
105
|
+
seen = _capture(monkeypatch, httpx.Response(200, json=[]))
|
|
106
|
+
get_merge_request_pipelines(CFG, 12)
|
|
107
|
+
assert seen["path"] == "/api/v4/projects/grp%2Fapp/merge_requests/12/pipelines"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_mr_conflicts_uses_non_api_web_path(monkeypatch):
|
|
111
|
+
# CFG.project is a path ("grp/app") — no id resolution, one request, no /api/v4.
|
|
112
|
+
seen = _capture(monkeypatch, httpx.Response(200, json={"files": []}))
|
|
113
|
+
result = get_merge_request_conflicts(CFG, 12)
|
|
114
|
+
assert result == {"files": []}
|
|
115
|
+
assert seen["path"] == "/grp/app/-/merge_requests/12/conflicts"
|
|
116
|
+
assert "/api/v4" not in seen["path"]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_mr_conflicts_resolves_numeric_id_to_path(monkeypatch):
|
|
120
|
+
calls: list[str] = []
|
|
121
|
+
|
|
122
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
123
|
+
path = request.url.raw_path.decode()
|
|
124
|
+
calls.append(path)
|
|
125
|
+
if path == "/api/v4/projects/99":
|
|
126
|
+
return httpx.Response(200, json={"path_with_namespace": "grp/app"})
|
|
127
|
+
return httpx.Response(200, json={"files": ["x"]})
|
|
128
|
+
|
|
129
|
+
mock_request(monkeypatch, handler)
|
|
130
|
+
cfg = GitlabConfig(base_url="https://gl", token="tok", project="99")
|
|
131
|
+
result = get_merge_request_conflicts(cfg, 5)
|
|
132
|
+
assert "/api/v4/projects/99" in calls # numeric id resolved via the API
|
|
133
|
+
assert "/grp/app/-/merge_requests/5/conflicts" in calls # then the web endpoint
|
|
134
|
+
assert result == {"files": ["x"]}
|
|
85
135
|
|
|
86
136
|
|
|
87
137
|
def test_list_pipelines_filters(monkeypatch):
|
|
@@ -1,45 +0,0 @@
|
|
|
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
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|