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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runspec-gitlab-core
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Pure-Python GitLab REST helpers — the importable core behind runspec-gitlab (no runspec dependency, no runnables)
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: httpx
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runspec-gitlab-core"
7
- version = "0.1.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
- get_merge_request_changes,
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
- "get_merge_request_changes",
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 against ``{base_url}/api/v4`` and return the
117
- parsed JSON body (or the raw text when ``raw=True`` — used for job traces).
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}/api/v4{path}",
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
- get_merge_request_changes,
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 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
+ 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
- )