runspec-gitlab-core 0.2.0__tar.gz → 0.3.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.2.0 → runspec_gitlab_core-0.3.0}/CHANGELOG.md +21 -0
- {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/PKG-INFO +2 -2
- {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/pyproject.toml +1 -1
- {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/runspec_gitlab_core/__init__.py +56 -7
- {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/runspec_gitlab_core/client.py +14 -2
- runspec_gitlab_core-0.3.0/runspec_gitlab_core/issues.py +143 -0
- runspec_gitlab_core-0.3.0/runspec_gitlab_core/merge_requests.py +251 -0
- runspec_gitlab_core-0.3.0/runspec_gitlab_core/pipelines.py +155 -0
- {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/runspec_gitlab_core/projects.py +25 -0
- runspec_gitlab_core-0.3.0/runspec_gitlab_core/repository.py +166 -0
- runspec_gitlab_core-0.3.0/tests/test_writes.py +305 -0
- runspec_gitlab_core-0.2.0/runspec_gitlab_core/issues.py +0 -45
- runspec_gitlab_core-0.2.0/runspec_gitlab_core/merge_requests.py +0 -127
- runspec_gitlab_core-0.2.0/runspec_gitlab_core/pipelines.py +0 -68
- runspec_gitlab_core-0.2.0/runspec_gitlab_core/repository.py +0 -68
- {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/.gitignore +0 -0
- {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/tests/__init__.py +0 -0
- {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/tests/conftest.py +0 -0
- {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/tests/test_api.py +0 -0
- {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/tests/test_client.py +0 -0
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `runspec-gitlab-core` are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.3.0]
|
|
6
|
+
|
|
7
|
+
Write helpers, each taking a `GitlabConfig` first like the reads.
|
|
8
|
+
|
|
9
|
+
- **Issues:** `create_issue`, `update_issue`, `add_issue_note`.
|
|
10
|
+
- **Merge requests:** `create_merge_request`, `update_merge_request`,
|
|
11
|
+
`add_merge_request_note`, `merge_merge_request`.
|
|
12
|
+
- **Repository:** `create_branch`, `write_file` (checks whether the file exists
|
|
13
|
+
on the starting branch, then creates or updates it; returns `action`
|
|
14
|
+
`created`/`updated`).
|
|
15
|
+
- **Pipelines/jobs:** `create_pipeline`, `retry_pipeline`, `cancel_pipeline`,
|
|
16
|
+
`retry_job`, `cancel_job`, `play_job` (variables as `KEY=VALUE` strings).
|
|
17
|
+
- **Deletes:** `delete_issue`, `delete_branch`, `delete_file`,
|
|
18
|
+
`delete_pipeline` (each returns a small `{"deleted": ...}` summary).
|
|
19
|
+
- `resolve_user_ids` maps usernames to the numeric ids the write APIs take.
|
|
20
|
+
- `gitlab_request` gains a `body=` JSON request body (its `None` values are
|
|
21
|
+
dropped), and `GitlabError` now carries the HTTP `status_code` of an API
|
|
22
|
+
error (`None` for a configuration error).
|
|
23
|
+
- `update_issue`/`update_merge_request` raise `ValueError` when no field is
|
|
24
|
+
given, rather than sending an empty edit.
|
|
25
|
+
|
|
5
26
|
## [0.2.0]
|
|
6
27
|
|
|
7
28
|
Reconciled against GitLab's canonical MCP tool set.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: runspec-gitlab-core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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.
|
|
7
|
+
version = "0.3.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 = [
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""runspec-gitlab-core — pure-Python GitLab REST (v4) read helpers.
|
|
1
|
+
"""runspec-gitlab-core — pure-Python GitLab REST (v4) read + write helpers.
|
|
2
2
|
|
|
3
3
|
This package depends only on ``httpx`` — it has **no dependency on runspec** and
|
|
4
4
|
ships **no runspec.toml and no entry points**, so installing it exposes the
|
|
@@ -6,8 +6,8 @@ helper functions for import without surfacing any runnables (it is invisible to
|
|
|
6
6
|
``runspec local`` / ``runspec serve`` discovery). ``runspec-gitlab`` depends on it
|
|
7
7
|
and wraps each helper as a verb of the ``gitlab`` runnable.
|
|
8
8
|
|
|
9
|
-
The helpers port the
|
|
10
|
-
|
|
9
|
+
The helpers port the tools of GitLab's own (hosted) MCP server onto the public
|
|
10
|
+
REST API, plus REST reads and writes it has no tool for. Every operation takes an explicit :class:`GitlabConfig` first
|
|
11
11
|
argument, so a private (e.g. Nexus-hosted) wrapper package can inject credentials
|
|
12
12
|
in code, bake in a default ``project`` / ``env``, and build "one runnable per
|
|
13
13
|
project" on top without re-implementing the REST plumbing. Each function does the
|
|
@@ -23,8 +23,17 @@ from runspec_gitlab_core.client import (
|
|
|
23
23
|
encode_project,
|
|
24
24
|
gitlab_request,
|
|
25
25
|
)
|
|
26
|
-
from runspec_gitlab_core.issues import
|
|
26
|
+
from runspec_gitlab_core.issues import (
|
|
27
|
+
add_issue_note,
|
|
28
|
+
create_issue,
|
|
29
|
+
delete_issue,
|
|
30
|
+
get_issue,
|
|
31
|
+
list_issues,
|
|
32
|
+
update_issue,
|
|
33
|
+
)
|
|
27
34
|
from runspec_gitlab_core.merge_requests import (
|
|
35
|
+
add_merge_request_note,
|
|
36
|
+
create_merge_request,
|
|
28
37
|
get_merge_request,
|
|
29
38
|
get_merge_request_commits,
|
|
30
39
|
get_merge_request_conflicts,
|
|
@@ -32,10 +41,30 @@ from runspec_gitlab_core.merge_requests import (
|
|
|
32
41
|
get_merge_request_notes,
|
|
33
42
|
get_merge_request_pipelines,
|
|
34
43
|
list_merge_requests,
|
|
44
|
+
merge_merge_request,
|
|
45
|
+
update_merge_request,
|
|
46
|
+
)
|
|
47
|
+
from runspec_gitlab_core.pipelines import (
|
|
48
|
+
cancel_job,
|
|
49
|
+
cancel_pipeline,
|
|
50
|
+
create_pipeline,
|
|
51
|
+
delete_pipeline,
|
|
52
|
+
get_job_log,
|
|
53
|
+
get_pipeline_jobs,
|
|
54
|
+
list_pipelines,
|
|
55
|
+
play_job,
|
|
56
|
+
retry_job,
|
|
57
|
+
retry_pipeline,
|
|
58
|
+
)
|
|
59
|
+
from runspec_gitlab_core.projects import get_project, resolve_project_path, resolve_user_ids, search_projects, whoami
|
|
60
|
+
from runspec_gitlab_core.repository import (
|
|
61
|
+
create_branch,
|
|
62
|
+
delete_branch,
|
|
63
|
+
delete_file,
|
|
64
|
+
get_file,
|
|
65
|
+
list_tree,
|
|
66
|
+
write_file,
|
|
35
67
|
)
|
|
36
|
-
from runspec_gitlab_core.pipelines import get_job_log, get_pipeline_jobs, list_pipelines
|
|
37
|
-
from runspec_gitlab_core.projects import get_project, resolve_project_path, search_projects, whoami
|
|
38
|
-
from runspec_gitlab_core.repository import get_file, list_tree
|
|
39
68
|
|
|
40
69
|
__all__ = [
|
|
41
70
|
# client / config
|
|
@@ -45,6 +74,7 @@ __all__ = [
|
|
|
45
74
|
"gitlab_request",
|
|
46
75
|
"encode_project",
|
|
47
76
|
"resolve_project_path",
|
|
77
|
+
"resolve_user_ids",
|
|
48
78
|
"VALID_ENVS",
|
|
49
79
|
"DEFAULT_URL",
|
|
50
80
|
# identity / projects
|
|
@@ -54,6 +84,10 @@ __all__ = [
|
|
|
54
84
|
# issues
|
|
55
85
|
"list_issues",
|
|
56
86
|
"get_issue",
|
|
87
|
+
"create_issue",
|
|
88
|
+
"update_issue",
|
|
89
|
+
"add_issue_note",
|
|
90
|
+
"delete_issue",
|
|
57
91
|
# merge requests
|
|
58
92
|
"list_merge_requests",
|
|
59
93
|
"get_merge_request",
|
|
@@ -62,11 +96,26 @@ __all__ = [
|
|
|
62
96
|
"get_merge_request_notes",
|
|
63
97
|
"get_merge_request_pipelines",
|
|
64
98
|
"get_merge_request_conflicts",
|
|
99
|
+
"create_merge_request",
|
|
100
|
+
"update_merge_request",
|
|
101
|
+
"add_merge_request_note",
|
|
102
|
+
"merge_merge_request",
|
|
65
103
|
# pipelines / jobs
|
|
66
104
|
"list_pipelines",
|
|
67
105
|
"get_pipeline_jobs",
|
|
68
106
|
"get_job_log",
|
|
107
|
+
"create_pipeline",
|
|
108
|
+
"retry_pipeline",
|
|
109
|
+
"cancel_pipeline",
|
|
110
|
+
"retry_job",
|
|
111
|
+
"cancel_job",
|
|
112
|
+
"play_job",
|
|
113
|
+
"delete_pipeline",
|
|
69
114
|
# repository
|
|
70
115
|
"get_file",
|
|
71
116
|
"list_tree",
|
|
117
|
+
"create_branch",
|
|
118
|
+
"write_file",
|
|
119
|
+
"delete_branch",
|
|
120
|
+
"delete_file",
|
|
72
121
|
]
|
|
@@ -29,7 +29,15 @@ DEFAULT_URL = "https://gitlab.com"
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
class GitlabError(Exception):
|
|
32
|
-
"""Raised on configuration problems and non-2xx API responses.
|
|
32
|
+
"""Raised on configuration problems and non-2xx API responses.
|
|
33
|
+
|
|
34
|
+
``status_code`` carries the HTTP status for an API error (``None`` for a
|
|
35
|
+
configuration problem), so a caller can tell e.g. a 404 from a 403.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, message: str, status_code: int | None = None) -> None:
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.status_code = status_code
|
|
33
41
|
|
|
34
42
|
|
|
35
43
|
@dataclass
|
|
@@ -110,6 +118,7 @@ def gitlab_request(
|
|
|
110
118
|
*,
|
|
111
119
|
config: GitlabConfig,
|
|
112
120
|
params: dict[str, Any] | None = None,
|
|
121
|
+
body: dict[str, Any] | None = None,
|
|
113
122
|
raw: bool = False,
|
|
114
123
|
api: bool = True,
|
|
115
124
|
timeout: int = 30,
|
|
@@ -124,6 +133,8 @@ def gitlab_request(
|
|
|
124
133
|
|
|
125
134
|
``path`` should start with ``/`` (e.g. ``/projects/1/issues``). ``None`` values
|
|
126
135
|
in ``params`` are dropped and booleans are rendered as ``"true"``/``"false"``.
|
|
136
|
+
``body`` is sent as a JSON request body (write operations); its ``None``
|
|
137
|
+
values are dropped too, so callers can pass every optional field.
|
|
127
138
|
Raises :class:`GitlabError` on any non-2xx response, surfacing the server's
|
|
128
139
|
error message.
|
|
129
140
|
"""
|
|
@@ -145,10 +156,11 @@ def gitlab_request(
|
|
|
145
156
|
f"{config.base_url}{prefix}{path}",
|
|
146
157
|
headers=headers,
|
|
147
158
|
params=clean,
|
|
159
|
+
json={k: v for k, v in body.items() if v is not None} if body is not None else None,
|
|
148
160
|
timeout=timeout,
|
|
149
161
|
)
|
|
150
162
|
if resp.status_code >= 400:
|
|
151
|
-
raise GitlabError(_error_message(resp))
|
|
163
|
+
raise GitlabError(_error_message(resp), status_code=resp.status_code)
|
|
152
164
|
if resp.status_code == 204 or not resp.content:
|
|
153
165
|
return "" if raw else None
|
|
154
166
|
if raw:
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""issues.py — project issue operations (GitLab REST v4): reads + writes."""
|
|
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_user_ids
|
|
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
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _labels(labels: list[str] | None) -> str | None:
|
|
49
|
+
"""Render a label list as GitLab's comma-separated form (``None`` = unset)."""
|
|
50
|
+
if labels is None:
|
|
51
|
+
return None
|
|
52
|
+
return ",".join(label.strip() for label in labels if label.strip())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def create_issue(
|
|
56
|
+
config: GitlabConfig,
|
|
57
|
+
title: str,
|
|
58
|
+
description: str | None = None,
|
|
59
|
+
labels: list[str] | None = None,
|
|
60
|
+
assignees: list[str] | None = None,
|
|
61
|
+
milestone_id: int | None = None,
|
|
62
|
+
due_date: str | None = None,
|
|
63
|
+
confidential: bool = False,
|
|
64
|
+
project: str | None = None,
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
"""Create an issue (the canonical ``create_issue`` tool).
|
|
67
|
+
|
|
68
|
+
``assignees`` are usernames (or numeric ids); ``due_date`` is ``YYYY-MM-DD``.
|
|
69
|
+
Returns the created issue.
|
|
70
|
+
"""
|
|
71
|
+
return gitlab_request(
|
|
72
|
+
"POST",
|
|
73
|
+
f"/projects/{_project(config, project)}/issues",
|
|
74
|
+
config=config,
|
|
75
|
+
body={
|
|
76
|
+
"title": title,
|
|
77
|
+
"description": description,
|
|
78
|
+
"labels": _labels(labels),
|
|
79
|
+
"assignee_ids": resolve_user_ids(config, assignees),
|
|
80
|
+
"milestone_id": milestone_id,
|
|
81
|
+
"due_date": due_date,
|
|
82
|
+
"confidential": confidential or None,
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def update_issue(
|
|
88
|
+
config: GitlabConfig,
|
|
89
|
+
issue_iid: int,
|
|
90
|
+
title: str | None = None,
|
|
91
|
+
description: str | None = None,
|
|
92
|
+
add_labels: list[str] | None = None,
|
|
93
|
+
remove_labels: list[str] | None = None,
|
|
94
|
+
assignees: list[str] | None = None,
|
|
95
|
+
milestone_id: int | None = None,
|
|
96
|
+
due_date: str | None = None,
|
|
97
|
+
state_event: str | None = None,
|
|
98
|
+
project: str | None = None,
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
"""Edit an issue. Only the fields passed change; ``state_event`` is
|
|
101
|
+
``close``/``reopen``. Raises :class:`ValueError` when nothing would change."""
|
|
102
|
+
body = {
|
|
103
|
+
"title": title,
|
|
104
|
+
"description": description,
|
|
105
|
+
"add_labels": _labels(add_labels),
|
|
106
|
+
"remove_labels": _labels(remove_labels),
|
|
107
|
+
"assignee_ids": resolve_user_ids(config, assignees),
|
|
108
|
+
"milestone_id": milestone_id,
|
|
109
|
+
"due_date": due_date,
|
|
110
|
+
"state_event": state_event,
|
|
111
|
+
}
|
|
112
|
+
if all(value is None for value in body.values()):
|
|
113
|
+
raise ValueError("Nothing to update — pass at least one field to change")
|
|
114
|
+
return gitlab_request(
|
|
115
|
+
"PUT",
|
|
116
|
+
f"/projects/{_project(config, project)}/issues/{issue_iid}",
|
|
117
|
+
config=config,
|
|
118
|
+
body=body,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def add_issue_note(
|
|
123
|
+
config: GitlabConfig,
|
|
124
|
+
issue_iid: int,
|
|
125
|
+
body: str,
|
|
126
|
+
internal: bool = False,
|
|
127
|
+
project: str | None = None,
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
"""Comment on an issue. ``internal`` makes it an internal note (visible to
|
|
130
|
+
project members with at least Reporter access only)."""
|
|
131
|
+
return gitlab_request(
|
|
132
|
+
"POST",
|
|
133
|
+
f"/projects/{_project(config, project)}/issues/{issue_iid}/notes",
|
|
134
|
+
config=config,
|
|
135
|
+
body={"body": body, "internal": internal or None},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def delete_issue(config: GitlabConfig, issue_iid: int, project: str | None = None) -> dict[str, Any]:
|
|
140
|
+
"""Delete an issue permanently. GitLab only allows this for project owners
|
|
141
|
+
and administrators."""
|
|
142
|
+
gitlab_request("DELETE", f"/projects/{_project(config, project)}/issues/{issue_iid}", config=config)
|
|
143
|
+
return {"deleted": "issue", "issue_iid": issue_iid}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""merge_requests.py — merge-request operations (GitLab REST v4): reads + writes."""
|
|
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.issues import _labels
|
|
9
|
+
from runspec_gitlab_core.projects import _project, resolve_project_path, resolve_user_ids
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def list_merge_requests(
|
|
13
|
+
config: GitlabConfig,
|
|
14
|
+
project: str | None = None,
|
|
15
|
+
state: str | None = None,
|
|
16
|
+
per_page: int = 20,
|
|
17
|
+
page: int = 1,
|
|
18
|
+
) -> list[dict[str, Any]]:
|
|
19
|
+
"""List a project's merge requests.
|
|
20
|
+
|
|
21
|
+
``state`` is ``opened``/``closed``/``merged``/``locked``/``all``.
|
|
22
|
+
"""
|
|
23
|
+
return gitlab_request(
|
|
24
|
+
"GET",
|
|
25
|
+
f"/projects/{_project(config, project)}/merge_requests",
|
|
26
|
+
config=config,
|
|
27
|
+
params={"state": state, "per_page": per_page, "page": page},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_merge_request(config: GitlabConfig, mr_iid: int, project: str | None = None) -> dict[str, Any]:
|
|
32
|
+
"""Fetch a single merge request by its project-scoped internal id (``iid``)."""
|
|
33
|
+
return gitlab_request(
|
|
34
|
+
"GET",
|
|
35
|
+
f"/projects/{_project(config, project)}/merge_requests/{mr_iid}",
|
|
36
|
+
config=config,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_merge_request_diffs(
|
|
41
|
+
config: GitlabConfig,
|
|
42
|
+
mr_iid: int,
|
|
43
|
+
project: str | None = None,
|
|
44
|
+
per_page: int = 20,
|
|
45
|
+
page: int = 1,
|
|
46
|
+
) -> list[dict[str, Any]]:
|
|
47
|
+
"""List a merge request's file diffs.
|
|
48
|
+
|
|
49
|
+
Mirrors the canonical ``get_merge_request_diffs`` MCP tool — the paginated
|
|
50
|
+
``/merge_requests/:iid/diffs`` endpoint, not the deprecated ``/changes``.
|
|
51
|
+
"""
|
|
52
|
+
return gitlab_request(
|
|
53
|
+
"GET",
|
|
54
|
+
f"/projects/{_project(config, project)}/merge_requests/{mr_iid}/diffs",
|
|
55
|
+
config=config,
|
|
56
|
+
params={"per_page": per_page, "page": page},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_merge_request_commits(
|
|
61
|
+
config: GitlabConfig,
|
|
62
|
+
mr_iid: int,
|
|
63
|
+
project: str | None = None,
|
|
64
|
+
per_page: int = 20,
|
|
65
|
+
page: int = 1,
|
|
66
|
+
) -> list[dict[str, Any]]:
|
|
67
|
+
"""List the commits on a merge request (canonical ``get_merge_request_commits``)."""
|
|
68
|
+
return gitlab_request(
|
|
69
|
+
"GET",
|
|
70
|
+
f"/projects/{_project(config, project)}/merge_requests/{mr_iid}/commits",
|
|
71
|
+
config=config,
|
|
72
|
+
params={"per_page": per_page, "page": page},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_merge_request_notes(
|
|
77
|
+
config: GitlabConfig,
|
|
78
|
+
mr_iid: int,
|
|
79
|
+
project: str | None = None,
|
|
80
|
+
sort: str | None = None,
|
|
81
|
+
per_page: int = 20,
|
|
82
|
+
page: int = 1,
|
|
83
|
+
) -> list[dict[str, Any]]:
|
|
84
|
+
"""List a merge request's notes (comments) — the REST equivalent of the
|
|
85
|
+
canonical ``get_merge_request_notes`` tool. ``sort`` is ``asc``/``desc``."""
|
|
86
|
+
return gitlab_request(
|
|
87
|
+
"GET",
|
|
88
|
+
f"/projects/{_project(config, project)}/merge_requests/{mr_iid}/notes",
|
|
89
|
+
config=config,
|
|
90
|
+
params={"sort": sort, "per_page": per_page, "page": page},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_merge_request_pipelines(
|
|
95
|
+
config: GitlabConfig,
|
|
96
|
+
mr_iid: int,
|
|
97
|
+
project: str | None = None,
|
|
98
|
+
per_page: int = 20,
|
|
99
|
+
page: int = 1,
|
|
100
|
+
) -> list[dict[str, Any]]:
|
|
101
|
+
"""List the pipelines run for a merge request (canonical ``get_merge_request_pipelines``)."""
|
|
102
|
+
return gitlab_request(
|
|
103
|
+
"GET",
|
|
104
|
+
f"/projects/{_project(config, project)}/merge_requests/{mr_iid}/pipelines",
|
|
105
|
+
config=config,
|
|
106
|
+
params={"per_page": per_page, "page": page},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_merge_request_conflicts(config: GitlabConfig, mr_iid: int, project: str | None = None) -> Any:
|
|
111
|
+
"""Fetch a merge request's conflict files (the canonical ``get_merge_request_conflicts``).
|
|
112
|
+
|
|
113
|
+
**Best-effort — not a public REST API.** GitLab exposes MR conflict *contents*
|
|
114
|
+
only through its web-UI endpoint (``{base}/{namespace}/{project}/-/merge_requests/
|
|
115
|
+
:iid/conflicts``), not under ``/api/v4``; the MCP server's own tool calls an
|
|
116
|
+
internal service in-process. This relays that endpoint's JSON verbatim (files
|
|
117
|
+
with conflict markers/sections), so its shape is GitLab's, not ours, and may
|
|
118
|
+
change between versions. Requires a token with read access to the MR (and,
|
|
119
|
+
per GitLab, push access to the source branch). A non-conflicting or
|
|
120
|
+
unmergeable MR typically yields a non-2xx surfaced as :class:`GitlabError`.
|
|
121
|
+
"""
|
|
122
|
+
path = resolve_project_path(config, project)
|
|
123
|
+
return gitlab_request(
|
|
124
|
+
"GET",
|
|
125
|
+
f"/{path}/-/merge_requests/{mr_iid}/conflicts",
|
|
126
|
+
config=config,
|
|
127
|
+
api=False,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
DRAFT_PREFIX = "Draft: "
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def create_merge_request(
|
|
135
|
+
config: GitlabConfig,
|
|
136
|
+
source_branch: str,
|
|
137
|
+
target_branch: str,
|
|
138
|
+
title: str,
|
|
139
|
+
description: str | None = None,
|
|
140
|
+
draft: bool = False,
|
|
141
|
+
labels: list[str] | None = None,
|
|
142
|
+
assignees: list[str] | None = None,
|
|
143
|
+
reviewers: list[str] | None = None,
|
|
144
|
+
remove_source_branch: bool = False,
|
|
145
|
+
squash: bool = False,
|
|
146
|
+
project: str | None = None,
|
|
147
|
+
) -> dict[str, Any]:
|
|
148
|
+
"""Open a merge request (the canonical ``create_merge_request`` tool).
|
|
149
|
+
|
|
150
|
+
``draft`` prefixes the title with ``Draft: `` (GitLab's draft marker);
|
|
151
|
+
``assignees``/``reviewers`` are usernames (or numeric ids).
|
|
152
|
+
"""
|
|
153
|
+
if draft and not title.lower().startswith(("draft:", "[draft]", "(draft)")):
|
|
154
|
+
title = DRAFT_PREFIX + title
|
|
155
|
+
return gitlab_request(
|
|
156
|
+
"POST",
|
|
157
|
+
f"/projects/{_project(config, project)}/merge_requests",
|
|
158
|
+
config=config,
|
|
159
|
+
body={
|
|
160
|
+
"source_branch": source_branch,
|
|
161
|
+
"target_branch": target_branch,
|
|
162
|
+
"title": title,
|
|
163
|
+
"description": description,
|
|
164
|
+
"labels": _labels(labels),
|
|
165
|
+
"assignee_ids": resolve_user_ids(config, assignees),
|
|
166
|
+
"reviewer_ids": resolve_user_ids(config, reviewers),
|
|
167
|
+
"remove_source_branch": remove_source_branch or None,
|
|
168
|
+
"squash": squash or None,
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def update_merge_request(
|
|
174
|
+
config: GitlabConfig,
|
|
175
|
+
mr_iid: int,
|
|
176
|
+
title: str | None = None,
|
|
177
|
+
description: str | None = None,
|
|
178
|
+
target_branch: str | None = None,
|
|
179
|
+
add_labels: list[str] | None = None,
|
|
180
|
+
remove_labels: list[str] | None = None,
|
|
181
|
+
assignees: list[str] | None = None,
|
|
182
|
+
reviewers: list[str] | None = None,
|
|
183
|
+
state_event: str | None = None,
|
|
184
|
+
project: str | None = None,
|
|
185
|
+
) -> dict[str, Any]:
|
|
186
|
+
"""Edit a merge request. Only the fields passed change; ``state_event`` is
|
|
187
|
+
``close``/``reopen``. Raises :class:`ValueError` when nothing would change."""
|
|
188
|
+
body = {
|
|
189
|
+
"title": title,
|
|
190
|
+
"description": description,
|
|
191
|
+
"target_branch": target_branch,
|
|
192
|
+
"add_labels": _labels(add_labels),
|
|
193
|
+
"remove_labels": _labels(remove_labels),
|
|
194
|
+
"assignee_ids": resolve_user_ids(config, assignees),
|
|
195
|
+
"reviewer_ids": resolve_user_ids(config, reviewers),
|
|
196
|
+
"state_event": state_event,
|
|
197
|
+
}
|
|
198
|
+
if all(value is None for value in body.values()):
|
|
199
|
+
raise ValueError("Nothing to update — pass at least one field to change")
|
|
200
|
+
return gitlab_request(
|
|
201
|
+
"PUT",
|
|
202
|
+
f"/projects/{_project(config, project)}/merge_requests/{mr_iid}",
|
|
203
|
+
config=config,
|
|
204
|
+
body=body,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def add_merge_request_note(
|
|
209
|
+
config: GitlabConfig,
|
|
210
|
+
mr_iid: int,
|
|
211
|
+
body: str,
|
|
212
|
+
internal: bool = False,
|
|
213
|
+
project: str | None = None,
|
|
214
|
+
) -> dict[str, Any]:
|
|
215
|
+
"""Comment on a merge request (a general, non-diff note)."""
|
|
216
|
+
return gitlab_request(
|
|
217
|
+
"POST",
|
|
218
|
+
f"/projects/{_project(config, project)}/merge_requests/{mr_iid}/notes",
|
|
219
|
+
config=config,
|
|
220
|
+
body={"body": body, "internal": internal or None},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def merge_merge_request(
|
|
225
|
+
config: GitlabConfig,
|
|
226
|
+
mr_iid: int,
|
|
227
|
+
sha: str | None = None,
|
|
228
|
+
squash: bool | None = None,
|
|
229
|
+
remove_source_branch: bool | None = None,
|
|
230
|
+
when_pipeline_succeeds: bool = False,
|
|
231
|
+
merge_commit_message: str | None = None,
|
|
232
|
+
project: str | None = None,
|
|
233
|
+
) -> dict[str, Any]:
|
|
234
|
+
"""Merge a merge request (or set it to merge when its pipeline succeeds).
|
|
235
|
+
|
|
236
|
+
``sha`` guards the merge: GitLab refuses if the source branch head moved
|
|
237
|
+
since you reviewed it. ``squash``/``remove_source_branch`` left as ``None``
|
|
238
|
+
keep the MR's own settings.
|
|
239
|
+
"""
|
|
240
|
+
return gitlab_request(
|
|
241
|
+
"PUT",
|
|
242
|
+
f"/projects/{_project(config, project)}/merge_requests/{mr_iid}/merge",
|
|
243
|
+
config=config,
|
|
244
|
+
body={
|
|
245
|
+
"sha": sha,
|
|
246
|
+
"squash": squash,
|
|
247
|
+
"should_remove_source_branch": remove_source_branch,
|
|
248
|
+
"merge_when_pipeline_succeeds": when_pipeline_succeeds or None,
|
|
249
|
+
"merge_commit_message": merge_commit_message,
|
|
250
|
+
},
|
|
251
|
+
)
|