runspec-gitlab-core 0.1.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.3.0/CHANGELOG.md +57 -0
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.3.0}/PKG-INFO +2 -2
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.3.0}/pyproject.toml +1 -1
- runspec_gitlab_core-0.3.0/runspec_gitlab_core/__init__.py +121 -0
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.3.0}/runspec_gitlab_core/client.py +24 -5
- 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.3.0/runspec_gitlab_core/projects.py +95 -0
- runspec_gitlab_core-0.3.0/runspec_gitlab_core/repository.py +166 -0
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.3.0}/tests/test_api.py +55 -5
- runspec_gitlab_core-0.3.0/tests/test_writes.py +305 -0
- runspec_gitlab_core-0.1.0/CHANGELOG.md +0 -19
- runspec_gitlab_core-0.1.0/runspec_gitlab_core/__init__.py +0 -63
- runspec_gitlab_core-0.1.0/runspec_gitlab_core/issues.py +0 -45
- runspec_gitlab_core-0.1.0/runspec_gitlab_core/merge_requests.py +0 -45
- runspec_gitlab_core-0.1.0/runspec_gitlab_core/pipelines.py +0 -68
- runspec_gitlab_core-0.1.0/runspec_gitlab_core/projects.py +0 -51
- runspec_gitlab_core-0.1.0/runspec_gitlab_core/repository.py +0 -68
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.3.0}/.gitignore +0 -0
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.3.0}/tests/__init__.py +0 -0
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.3.0}/tests/conftest.py +0 -0
- {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.3.0}/tests/test_client.py +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `runspec-gitlab-core` are documented here.
|
|
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
|
+
|
|
26
|
+
## [0.2.0]
|
|
27
|
+
|
|
28
|
+
Reconciled against GitLab's canonical MCP tool set.
|
|
29
|
+
|
|
30
|
+
- **Renamed** `get_merge_request_changes` → `get_merge_request_diffs` — the
|
|
31
|
+
canonical name; targets the paginated `/diffs` endpoint instead of the
|
|
32
|
+
deprecated `/changes`. **Breaking** API change vs 0.1.0.
|
|
33
|
+
- **Added** read helpers: `get_merge_request_commits`, `get_merge_request_notes`,
|
|
34
|
+
`get_merge_request_pipelines`, `get_merge_request_conflicts`, and
|
|
35
|
+
`resolve_project_path`.
|
|
36
|
+
- `gitlab_request` gains an `api=False` mode to target `{base_url}{path}` (no
|
|
37
|
+
`/api/v4` prefix) — used only for the non-public MR conflicts web endpoint.
|
|
38
|
+
- `get_merge_request_conflicts` is **best-effort**: it relays GitLab's
|
|
39
|
+
non-public web-UI conflicts endpoint (there is no public `/api/v4` one) and
|
|
40
|
+
returns its JSON verbatim, resolving a numeric project id to its
|
|
41
|
+
`namespace/path` via `resolve_project_path`.
|
|
42
|
+
|
|
43
|
+
## [0.1.0]
|
|
44
|
+
|
|
45
|
+
Initial release. Pure-Python GitLab REST (v4) read helpers — the importable core
|
|
46
|
+
behind `runspec-gitlab`, with **no runspec dependency, no runspec.toml, and no
|
|
47
|
+
entry points** (so it surfaces no runnables and stays importable on its own).
|
|
48
|
+
|
|
49
|
+
- `GitlabConfig` + `config_from_env` — env-var driven connection/identity
|
|
50
|
+
(`GITLAB_URL` defaulting to `https://gitlab.com`, `GITLAB_TOKEN`, optional
|
|
51
|
+
`GITLAB_PROJECT`), with `GITLAB_<ENV>_*` prefixes selected by `env`.
|
|
52
|
+
- `gitlab_request` / `encode_project` — authenticated `PRIVATE-TOKEN` request
|
|
53
|
+
helper against `{base_url}/api/v4`, raising `GitlabError` on non-2xx.
|
|
54
|
+
- Read helpers ported from the read tools of GitLab's hosted MCP server:
|
|
55
|
+
`whoami`, `get_project`, `search_projects`, `list_issues`, `get_issue`,
|
|
56
|
+
`list_merge_requests`, `get_merge_request`, `get_merge_request_changes`,
|
|
57
|
+
`list_pipelines`, `get_pipeline_jobs`, `get_job_log`, `get_file`, `list_tree`.
|
|
@@ -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 = [
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""runspec-gitlab-core — pure-Python GitLab REST (v4) read + write 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 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
|
+
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 (
|
|
27
|
+
add_issue_note,
|
|
28
|
+
create_issue,
|
|
29
|
+
delete_issue,
|
|
30
|
+
get_issue,
|
|
31
|
+
list_issues,
|
|
32
|
+
update_issue,
|
|
33
|
+
)
|
|
34
|
+
from runspec_gitlab_core.merge_requests import (
|
|
35
|
+
add_merge_request_note,
|
|
36
|
+
create_merge_request,
|
|
37
|
+
get_merge_request,
|
|
38
|
+
get_merge_request_commits,
|
|
39
|
+
get_merge_request_conflicts,
|
|
40
|
+
get_merge_request_diffs,
|
|
41
|
+
get_merge_request_notes,
|
|
42
|
+
get_merge_request_pipelines,
|
|
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,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
# client / config
|
|
71
|
+
"GitlabConfig",
|
|
72
|
+
"GitlabError",
|
|
73
|
+
"config_from_env",
|
|
74
|
+
"gitlab_request",
|
|
75
|
+
"encode_project",
|
|
76
|
+
"resolve_project_path",
|
|
77
|
+
"resolve_user_ids",
|
|
78
|
+
"VALID_ENVS",
|
|
79
|
+
"DEFAULT_URL",
|
|
80
|
+
# identity / projects
|
|
81
|
+
"whoami",
|
|
82
|
+
"get_project",
|
|
83
|
+
"search_projects",
|
|
84
|
+
# issues
|
|
85
|
+
"list_issues",
|
|
86
|
+
"get_issue",
|
|
87
|
+
"create_issue",
|
|
88
|
+
"update_issue",
|
|
89
|
+
"add_issue_note",
|
|
90
|
+
"delete_issue",
|
|
91
|
+
# merge requests
|
|
92
|
+
"list_merge_requests",
|
|
93
|
+
"get_merge_request",
|
|
94
|
+
"get_merge_request_diffs",
|
|
95
|
+
"get_merge_request_commits",
|
|
96
|
+
"get_merge_request_notes",
|
|
97
|
+
"get_merge_request_pipelines",
|
|
98
|
+
"get_merge_request_conflicts",
|
|
99
|
+
"create_merge_request",
|
|
100
|
+
"update_merge_request",
|
|
101
|
+
"add_merge_request_note",
|
|
102
|
+
"merge_merge_request",
|
|
103
|
+
# pipelines / jobs
|
|
104
|
+
"list_pipelines",
|
|
105
|
+
"get_pipeline_jobs",
|
|
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",
|
|
114
|
+
# repository
|
|
115
|
+
"get_file",
|
|
116
|
+
"list_tree",
|
|
117
|
+
"create_branch",
|
|
118
|
+
"write_file",
|
|
119
|
+
"delete_branch",
|
|
120
|
+
"delete_file",
|
|
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,14 +118,23 @@ 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,
|
|
123
|
+
api: bool = True,
|
|
114
124
|
timeout: int = 30,
|
|
115
125
|
) -> Any:
|
|
116
|
-
"""Make an authenticated request
|
|
117
|
-
|
|
126
|
+
"""Make an authenticated request and return the parsed JSON body (or the raw
|
|
127
|
+
text when ``raw=True`` — used for job traces).
|
|
128
|
+
|
|
129
|
+
By default the request targets the public REST API at ``{base_url}/api/v4``.
|
|
130
|
+
Pass ``api=False`` to hit ``{base_url}{path}`` directly — used only for the
|
|
131
|
+
non-public web endpoints GitLab does not expose under ``/api/v4`` (e.g. the
|
|
132
|
+
merge-request conflicts endpoint).
|
|
118
133
|
|
|
119
134
|
``path`` should start with ``/`` (e.g. ``/projects/1/issues``). ``None`` values
|
|
120
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.
|
|
121
138
|
Raises :class:`GitlabError` on any non-2xx response, surfacing the server's
|
|
122
139
|
error message.
|
|
123
140
|
"""
|
|
@@ -133,15 +150,17 @@ def gitlab_request(
|
|
|
133
150
|
continue
|
|
134
151
|
clean[key] = "true" if value is True else "false" if value is False else value
|
|
135
152
|
|
|
153
|
+
prefix = "/api/v4" if api else ""
|
|
136
154
|
resp = httpx.request(
|
|
137
155
|
method,
|
|
138
|
-
f"{config.base_url}
|
|
156
|
+
f"{config.base_url}{prefix}{path}",
|
|
139
157
|
headers=headers,
|
|
140
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,
|
|
141
160
|
timeout=timeout,
|
|
142
161
|
)
|
|
143
162
|
if resp.status_code >= 400:
|
|
144
|
-
raise GitlabError(_error_message(resp))
|
|
163
|
+
raise GitlabError(_error_message(resp), status_code=resp.status_code)
|
|
145
164
|
if resp.status_code == 204 or not resp.content:
|
|
146
165
|
return "" if raw else None
|
|
147
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
|
+
)
|