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.
Files changed (20) hide show
  1. {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/CHANGELOG.md +21 -0
  2. {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/PKG-INFO +2 -2
  3. {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/pyproject.toml +1 -1
  4. {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/runspec_gitlab_core/__init__.py +56 -7
  5. {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/runspec_gitlab_core/client.py +14 -2
  6. runspec_gitlab_core-0.3.0/runspec_gitlab_core/issues.py +143 -0
  7. runspec_gitlab_core-0.3.0/runspec_gitlab_core/merge_requests.py +251 -0
  8. runspec_gitlab_core-0.3.0/runspec_gitlab_core/pipelines.py +155 -0
  9. {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/runspec_gitlab_core/projects.py +25 -0
  10. runspec_gitlab_core-0.3.0/runspec_gitlab_core/repository.py +166 -0
  11. runspec_gitlab_core-0.3.0/tests/test_writes.py +305 -0
  12. runspec_gitlab_core-0.2.0/runspec_gitlab_core/issues.py +0 -45
  13. runspec_gitlab_core-0.2.0/runspec_gitlab_core/merge_requests.py +0 -127
  14. runspec_gitlab_core-0.2.0/runspec_gitlab_core/pipelines.py +0 -68
  15. runspec_gitlab_core-0.2.0/runspec_gitlab_core/repository.py +0 -68
  16. {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/.gitignore +0 -0
  17. {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/tests/__init__.py +0 -0
  18. {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/tests/conftest.py +0 -0
  19. {runspec_gitlab_core-0.2.0 → runspec_gitlab_core-0.3.0}/tests/test_api.py +0 -0
  20. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: runspec-gitlab-core
3
- Version: 0.2.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.2.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 read tools of GitLab's own (hosted) MCP server onto the
10
- public REST API. Every operation takes an explicit :class:`GitlabConfig` first
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 get_issue, list_issues
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
+ )