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.
Files changed (23) hide show
  1. runspec_gitlab_core-0.3.0/CHANGELOG.md +57 -0
  2. {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.3.0}/PKG-INFO +2 -2
  3. {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.3.0}/pyproject.toml +1 -1
  4. runspec_gitlab_core-0.3.0/runspec_gitlab_core/__init__.py +121 -0
  5. {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.3.0}/runspec_gitlab_core/client.py +24 -5
  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.3.0/runspec_gitlab_core/projects.py +95 -0
  10. runspec_gitlab_core-0.3.0/runspec_gitlab_core/repository.py +166 -0
  11. {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.3.0}/tests/test_api.py +55 -5
  12. runspec_gitlab_core-0.3.0/tests/test_writes.py +305 -0
  13. runspec_gitlab_core-0.1.0/CHANGELOG.md +0 -19
  14. runspec_gitlab_core-0.1.0/runspec_gitlab_core/__init__.py +0 -63
  15. runspec_gitlab_core-0.1.0/runspec_gitlab_core/issues.py +0 -45
  16. runspec_gitlab_core-0.1.0/runspec_gitlab_core/merge_requests.py +0 -45
  17. runspec_gitlab_core-0.1.0/runspec_gitlab_core/pipelines.py +0 -68
  18. runspec_gitlab_core-0.1.0/runspec_gitlab_core/projects.py +0 -51
  19. runspec_gitlab_core-0.1.0/runspec_gitlab_core/repository.py +0 -68
  20. {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.3.0}/.gitignore +0 -0
  21. {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.3.0}/tests/__init__.py +0 -0
  22. {runspec_gitlab_core-0.1.0 → runspec_gitlab_core-0.3.0}/tests/conftest.py +0 -0
  23. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: runspec-gitlab-core
3
- Version: 0.1.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.1.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 against ``{base_url}/api/v4`` and return the
117
- parsed JSON body (or the raw text when ``raw=True`` — used for job traces).
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}/api/v4{path}",
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
+ )