runspec-gitlab 0.2.1__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.
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to `runspec-gitlab` are documented here.
4
4
 
5
+ ## [0.3.0] — 2026-10-10
6
+
7
+ ### Added
8
+
9
+ - **15 write verbs**, all `autonomy = "confirm"` (the 17 read verbs stay
10
+ `autonomous`):
11
+ - issues: `create-issue`, `update-issue` (title/description, add/remove
12
+ labels, assignees, milestone, due date, close/reopen), `add-issue-note`;
13
+ - merge requests: `create-merge-request` (`--draft`, reviewers, squash,
14
+ remove source branch), `update-merge-request`, `add-merge-request-note`,
15
+ `merge-merge-request` (`--sha` guard, `--when-pipeline-succeeds`);
16
+ - repository: `create-branch`, `write-file` (creates or replaces a text file
17
+ in one commit, from `--content` or `--content-file`, optionally on a new
18
+ branch via `--start-branch`);
19
+ - pipelines/jobs: `create-pipeline` (`--variable KEY=VALUE`),
20
+ `retry-pipeline`, `cancel-pipeline`, `retry-job`, `cancel-job`, `play-job`.
21
+ - **4 delete verbs**, all `autonomy = "manual"` so an agent can never run
22
+ them (a person can, from the CLI or the console's Forms tab):
23
+ `delete-issue`, `delete-branch`, `delete-file`, `delete-pipeline`.
24
+ - Assignees and reviewers are taken as usernames. Writes and deletes need a
25
+ token with the `api` scope.
26
+ - Requires `runspec-gitlab-core >= 0.3.0`.
27
+
5
28
  ## [0.2.1] — 2026-09-20
6
29
 
7
30
  ### Changed
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: runspec-gitlab
3
- Version: 0.2.1
4
- Summary: GitLab read runnable for runspec — issues, merge requests, pipelines, and repository reads, ported from the GitLab MCP server
3
+ Version: 0.3.0
4
+ Summary: GitLab runnable for runspec — read and update issues, merge requests, pipelines, and repository files, ported from the GitLab MCP server
5
5
  Project-URL: Documentation, https://runspec.app/
6
6
  Project-URL: Source, https://github.com/jasonfinestone/runspec/tree/main/packages/python/runspec-gitlab
7
7
  Project-URL: Changelog, https://github.com/jasonfinestone/runspec/blob/main/packages/python/runspec-gitlab/CHANGELOG.md
8
8
  Project-URL: Issues, https://github.com/jasonfinestone/runspec/issues
9
9
  Keywords: gitlab,mcp,runnable,runspec
10
10
  Requires-Python: >=3.10
11
- Requires-Dist: runspec-gitlab-core>=0.2.0
11
+ Requires-Dist: runspec-gitlab-core>=0.3.0
12
12
  Requires-Dist: runspec>=0.44.0
13
13
  Provides-Extra: dev
14
14
  Requires-Dist: mypy; extra == 'dev'
@@ -18,10 +18,11 @@ Description-Content-Type: text/markdown
18
18
 
19
19
  # runspec-gitlab
20
20
 
21
- A read-only GitLab runnable for [runspec](https://pypi.org/project/runspec/),
22
- ported onto the GitLab REST API (v4) — **not** proxying GitLab's hosted MCP
23
- server. One runnable — **`gitlab`** — with 17 read verbs, all `autonomy =
24
- "autonomous"`. `pip install` it into a venv and it's discoverable by `runspec
21
+ A GitLab runnable for [runspec](https://pypi.org/project/runspec/), ported onto
22
+ the GitLab REST API (v4) — **not** proxying GitLab's hosted MCP server. One
23
+ runnable — **`gitlab`** — with 17 read verbs (`autonomy = "autonomous"`), 15
24
+ write verbs (`autonomy = "confirm"`, so an agent asks before each one) and 4
25
+ delete verbs (`autonomy = "manual"`, so only a person can run them). `pip install` it into a venv and it's discoverable by `runspec
25
26
  local`, `runspec serve` (MCP), and runspec-console.
26
27
 
27
28
  `runspec serve` flattens each verb into a leaf MCP tool (`gitlab_get-issue`, …).
@@ -44,6 +45,24 @@ pip install runspec-gitlab
44
45
  | Merge requests | `list-merge-requests`, `get-merge-request`, `get-merge-request-diffs`, `get-merge-request-commits`, `get-merge-request-notes`, `get-merge-request-pipelines`, `get-merge-request-conflicts` |
45
46
  | Pipelines | `list-pipelines`, `get-pipeline-jobs`, `get-job-log` |
46
47
 
48
+ Writes (all `confirm`):
49
+
50
+ | Area | Verbs |
51
+ |---|---|
52
+ | Issues | `create-issue`, `update-issue` (edit, labels, assignees, close/reopen), `add-issue-note` |
53
+ | Merge requests | `create-merge-request`, `update-merge-request` (edit, labels, assignees, reviewers, close/reopen), `add-merge-request-note`, `merge-merge-request` |
54
+ | Repository | `create-branch`, `write-file` (create or replace a text file in one commit) |
55
+ | Pipelines / jobs | `create-pipeline`, `retry-pipeline`, `cancel-pipeline`, `retry-job`, `cancel-job`, `play-job` |
56
+
57
+ Assignees and reviewers are given as usernames. Labels, assignees, reviewers and
58
+ pipeline variables (`KEY=VALUE`) are repeatable args. Writes need a token with
59
+ the `api` scope; reads work with `read_api`.
60
+
61
+ Deletes (all `manual` — refused to an agent, run from the command line or the
62
+ console's Forms tab): `delete-issue`, `delete-branch`, `delete-file`,
63
+ `delete-pipeline`. GitLab itself limits issue and pipeline deletes to project
64
+ owners/admins and refuses protected or default branches.
65
+
47
66
  `get-merge-request-conflicts` is best-effort — GitLab has no public `/api/v4`
48
67
  conflicts endpoint, so it relays the web-UI endpoint and returns its JSON
49
68
  verbatim.
@@ -64,6 +83,9 @@ accepts a numeric id or a `group/project` path and falls back to
64
83
  gitlab whoami
65
84
  gitlab list-merge-requests --project group/app --state opened
66
85
  gitlab get-job-log --project group/app --job-id 998877
86
+ gitlab create-issue --project group/app --title "Login fails" --label bug --assignee alice
87
+ gitlab write-file --project group/app --branch fix --start-branch main \
88
+ --file-path docs/notes.md --content-file notes.md --commit-message "Add notes"
67
89
  ```
68
90
 
69
91
  ## Public Python API
@@ -1,9 +1,10 @@
1
1
  # runspec-gitlab
2
2
 
3
- A read-only GitLab runnable for [runspec](https://pypi.org/project/runspec/),
4
- ported onto the GitLab REST API (v4) — **not** proxying GitLab's hosted MCP
5
- server. One runnable — **`gitlab`** — with 17 read verbs, all `autonomy =
6
- "autonomous"`. `pip install` it into a venv and it's discoverable by `runspec
3
+ A GitLab runnable for [runspec](https://pypi.org/project/runspec/), ported onto
4
+ the GitLab REST API (v4) — **not** proxying GitLab's hosted MCP server. One
5
+ runnable — **`gitlab`** — with 17 read verbs (`autonomy = "autonomous"`), 15
6
+ write verbs (`autonomy = "confirm"`, so an agent asks before each one) and 4
7
+ delete verbs (`autonomy = "manual"`, so only a person can run them). `pip install` it into a venv and it's discoverable by `runspec
7
8
  local`, `runspec serve` (MCP), and runspec-console.
8
9
 
9
10
  `runspec serve` flattens each verb into a leaf MCP tool (`gitlab_get-issue`, …).
@@ -26,6 +27,24 @@ pip install runspec-gitlab
26
27
  | Merge requests | `list-merge-requests`, `get-merge-request`, `get-merge-request-diffs`, `get-merge-request-commits`, `get-merge-request-notes`, `get-merge-request-pipelines`, `get-merge-request-conflicts` |
27
28
  | Pipelines | `list-pipelines`, `get-pipeline-jobs`, `get-job-log` |
28
29
 
30
+ Writes (all `confirm`):
31
+
32
+ | Area | Verbs |
33
+ |---|---|
34
+ | Issues | `create-issue`, `update-issue` (edit, labels, assignees, close/reopen), `add-issue-note` |
35
+ | Merge requests | `create-merge-request`, `update-merge-request` (edit, labels, assignees, reviewers, close/reopen), `add-merge-request-note`, `merge-merge-request` |
36
+ | Repository | `create-branch`, `write-file` (create or replace a text file in one commit) |
37
+ | Pipelines / jobs | `create-pipeline`, `retry-pipeline`, `cancel-pipeline`, `retry-job`, `cancel-job`, `play-job` |
38
+
39
+ Assignees and reviewers are given as usernames. Labels, assignees, reviewers and
40
+ pipeline variables (`KEY=VALUE`) are repeatable args. Writes need a token with
41
+ the `api` scope; reads work with `read_api`.
42
+
43
+ Deletes (all `manual` — refused to an agent, run from the command line or the
44
+ console's Forms tab): `delete-issue`, `delete-branch`, `delete-file`,
45
+ `delete-pipeline`. GitLab itself limits issue and pipeline deletes to project
46
+ owners/admins and refuses protected or default branches.
47
+
29
48
  `get-merge-request-conflicts` is best-effort — GitLab has no public `/api/v4`
30
49
  conflicts endpoint, so it relays the web-UI endpoint and returns its JSON
31
50
  verbatim.
@@ -46,6 +65,9 @@ accepts a numeric id or a `group/project` path and falls back to
46
65
  gitlab whoami
47
66
  gitlab list-merge-requests --project group/app --state opened
48
67
  gitlab get-job-log --project group/app --job-id 998877
68
+ gitlab create-issue --project group/app --title "Login fails" --label bug --assignee alice
69
+ gitlab write-file --project group/app --branch fix --start-branch main \
70
+ --file-path docs/notes.md --content-file notes.md --commit-message "Add notes"
49
71
  ```
50
72
 
51
73
  ## Public Python API
@@ -4,16 +4,16 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runspec-gitlab"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  requires-python = ">=3.10"
9
- description = "GitLab read runnable for runspec — issues, merge requests, pipelines, and repository reads, ported from the GitLab MCP server"
9
+ description = "GitLab runnable for runspec — read and update issues, merge requests, pipelines, and repository files, ported from the GitLab MCP server"
10
10
  readme = "README.md"
11
11
  keywords = ["runspec", "runnable", "gitlab", "mcp"]
12
12
  dependencies = [
13
13
  # 0.44.0 surfaces inherited args (the global --env) on each leaf subcommand's
14
14
  # MCP tool via `runspec serve`.
15
15
  "runspec>=0.44.0",
16
- "runspec-gitlab-core>=0.2.0",
16
+ "runspec-gitlab-core>=0.3.0",
17
17
  ]
18
18
 
19
19
  [project.urls]
@@ -0,0 +1,330 @@
1
+ """cli.py — the ``gitlab`` entry point.
2
+
3
+ One binary, a verb per GitLab tool (reads + writes). ``rs.parse("gitlab")`` resolves the
4
+ subcommand and its args; we build a :class:`GitlabConfig` from the environment
5
+ (scoped by the global ``--env``), unpack each ``.value`` at the boundary, and call
6
+ the matching :mod:`runspec_gitlab_core` helper.
7
+
8
+ Failures (the ``runspec_gitlab_core`` :class:`GitlabError` and arg-shape
9
+ ``ValueError``s) become a JSON ``{"error": ...}`` payload + a non-zero exit.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import runspec as rs
20
+ from runspec_gitlab_core import (
21
+ GitlabConfig,
22
+ GitlabError,
23
+ add_issue_note,
24
+ add_merge_request_note,
25
+ cancel_job,
26
+ cancel_pipeline,
27
+ config_from_env,
28
+ create_branch,
29
+ create_issue,
30
+ create_merge_request,
31
+ create_pipeline,
32
+ delete_branch,
33
+ delete_file,
34
+ delete_issue,
35
+ delete_pipeline,
36
+ get_file,
37
+ get_issue,
38
+ get_job_log,
39
+ get_merge_request,
40
+ get_merge_request_commits,
41
+ get_merge_request_conflicts,
42
+ get_merge_request_diffs,
43
+ get_merge_request_notes,
44
+ get_merge_request_pipelines,
45
+ get_pipeline_jobs,
46
+ get_project,
47
+ list_issues,
48
+ list_merge_requests,
49
+ list_pipelines,
50
+ list_tree,
51
+ merge_merge_request,
52
+ play_job,
53
+ retry_job,
54
+ retry_pipeline,
55
+ search_projects,
56
+ update_issue,
57
+ update_merge_request,
58
+ whoami,
59
+ write_file,
60
+ )
61
+
62
+
63
+ def _emit(payload: Any) -> None:
64
+ print(json.dumps(payload, default=str))
65
+
66
+
67
+ def _list(arg: rs.Arg) -> list[str] | None:
68
+ """A repeatable arg's values, or ``None`` when it wasn't given (so the core
69
+ leaves that field unchanged rather than clearing it)."""
70
+ return list(arg.value) if arg.value else None
71
+
72
+
73
+ def _content(spec: rs.RunSpec) -> str:
74
+ """``write-file`` content: ``--content`` verbatim, or the text of
75
+ ``--content-file`` (the group guarantees exactly one is set)."""
76
+ if spec.content_file.value is not None:
77
+ return Path(spec.content_file.value).read_text(encoding="utf-8")
78
+ return spec.content.value
79
+
80
+
81
+ def _dispatch(spec: rs.RunSpec, config: GitlabConfig) -> Any:
82
+ """Run the active verb against ``config``, unpacking ``spec.<arg>.value`` for
83
+ only the args the verb declares (globals + its own)."""
84
+ command = spec.runspec_command
85
+
86
+ if command == "whoami":
87
+ return whoami(config)
88
+ if command == "get-project":
89
+ return get_project(config, project=spec.project.value)
90
+ if command == "search-projects":
91
+ return search_projects(
92
+ config,
93
+ search=spec.search.value,
94
+ membership=spec.membership.value,
95
+ per_page=spec.per_page.value,
96
+ page=spec.page.value,
97
+ )
98
+ if command == "list-issues":
99
+ return list_issues(
100
+ config,
101
+ project=spec.project.value,
102
+ state=spec.state.value,
103
+ labels=spec.labels.value,
104
+ search=spec.search.value,
105
+ per_page=spec.per_page.value,
106
+ page=spec.page.value,
107
+ )
108
+ if command == "get-issue":
109
+ return get_issue(config, issue_iid=spec.issue_iid.value, project=spec.project.value)
110
+ if command == "list-merge-requests":
111
+ return list_merge_requests(
112
+ config,
113
+ project=spec.project.value,
114
+ state=spec.state.value,
115
+ per_page=spec.per_page.value,
116
+ page=spec.page.value,
117
+ )
118
+ if command == "get-merge-request":
119
+ return get_merge_request(config, mr_iid=spec.mr_iid.value, project=spec.project.value)
120
+ if command == "get-merge-request-diffs":
121
+ return get_merge_request_diffs(
122
+ config,
123
+ mr_iid=spec.mr_iid.value,
124
+ project=spec.project.value,
125
+ per_page=spec.per_page.value,
126
+ page=spec.page.value,
127
+ )
128
+ if command == "get-merge-request-commits":
129
+ return get_merge_request_commits(
130
+ config,
131
+ mr_iid=spec.mr_iid.value,
132
+ project=spec.project.value,
133
+ per_page=spec.per_page.value,
134
+ page=spec.page.value,
135
+ )
136
+ if command == "get-merge-request-notes":
137
+ return get_merge_request_notes(
138
+ config,
139
+ mr_iid=spec.mr_iid.value,
140
+ project=spec.project.value,
141
+ sort=spec.sort.value,
142
+ per_page=spec.per_page.value,
143
+ page=spec.page.value,
144
+ )
145
+ if command == "get-merge-request-pipelines":
146
+ return get_merge_request_pipelines(
147
+ config,
148
+ mr_iid=spec.mr_iid.value,
149
+ project=spec.project.value,
150
+ per_page=spec.per_page.value,
151
+ page=spec.page.value,
152
+ )
153
+ if command == "get-merge-request-conflicts":
154
+ return get_merge_request_conflicts(config, mr_iid=spec.mr_iid.value, project=spec.project.value)
155
+ if command == "list-pipelines":
156
+ return list_pipelines(
157
+ config,
158
+ project=spec.project.value,
159
+ ref=spec.ref.value,
160
+ status=spec.status.value,
161
+ per_page=spec.per_page.value,
162
+ page=spec.page.value,
163
+ )
164
+ if command == "get-pipeline-jobs":
165
+ return get_pipeline_jobs(
166
+ config,
167
+ pipeline_id=spec.pipeline_id.value,
168
+ project=spec.project.value,
169
+ per_page=spec.per_page.value,
170
+ page=spec.page.value,
171
+ )
172
+ if command == "get-job-log":
173
+ return get_job_log(config, job_id=spec.job_id.value, project=spec.project.value, tail=spec.tail.value)
174
+ if command == "get-file":
175
+ return get_file(config, file_path=spec.file_path.value, ref=spec.ref.value, project=spec.project.value)
176
+ if command == "list-tree":
177
+ return list_tree(
178
+ config,
179
+ path=spec.path.value,
180
+ ref=spec.ref.value,
181
+ recursive=spec.recursive.value,
182
+ project=spec.project.value,
183
+ per_page=spec.per_page.value,
184
+ page=spec.page.value,
185
+ )
186
+
187
+ # ── writes (all autonomy = "confirm") ──
188
+ if command == "create-issue":
189
+ return create_issue(
190
+ config,
191
+ title=spec.title.value,
192
+ description=spec.description.value,
193
+ labels=_list(spec.label),
194
+ assignees=_list(spec.assignee),
195
+ milestone_id=spec.milestone_id.value,
196
+ due_date=spec.due_date.value,
197
+ confidential=spec.confidential.value,
198
+ project=spec.project.value,
199
+ )
200
+ if command == "update-issue":
201
+ return update_issue(
202
+ config,
203
+ issue_iid=spec.issue_iid.value,
204
+ title=spec.title.value,
205
+ description=spec.description.value,
206
+ add_labels=_list(spec.add_label),
207
+ remove_labels=_list(spec.remove_label),
208
+ assignees=_list(spec.assignee),
209
+ milestone_id=spec.milestone_id.value,
210
+ due_date=spec.due_date.value,
211
+ state_event=spec.state.value,
212
+ project=spec.project.value,
213
+ )
214
+ if command == "add-issue-note":
215
+ return add_issue_note(
216
+ config,
217
+ issue_iid=spec.issue_iid.value,
218
+ body=spec.body.value,
219
+ internal=spec.internal.value,
220
+ project=spec.project.value,
221
+ )
222
+ if command == "create-merge-request":
223
+ return create_merge_request(
224
+ config,
225
+ source_branch=spec.source_branch.value,
226
+ target_branch=spec.target_branch.value,
227
+ title=spec.title.value,
228
+ description=spec.description.value,
229
+ draft=spec.draft.value,
230
+ labels=_list(spec.label),
231
+ assignees=_list(spec.assignee),
232
+ reviewers=_list(spec.reviewer),
233
+ remove_source_branch=spec.remove_source_branch.value,
234
+ squash=spec.squash.value,
235
+ project=spec.project.value,
236
+ )
237
+ if command == "update-merge-request":
238
+ return update_merge_request(
239
+ config,
240
+ mr_iid=spec.mr_iid.value,
241
+ title=spec.title.value,
242
+ description=spec.description.value,
243
+ target_branch=spec.target_branch.value,
244
+ add_labels=_list(spec.add_label),
245
+ remove_labels=_list(spec.remove_label),
246
+ assignees=_list(spec.assignee),
247
+ reviewers=_list(spec.reviewer),
248
+ state_event=spec.state.value,
249
+ project=spec.project.value,
250
+ )
251
+ if command == "add-merge-request-note":
252
+ return add_merge_request_note(
253
+ config,
254
+ mr_iid=spec.mr_iid.value,
255
+ body=spec.body.value,
256
+ internal=spec.internal.value,
257
+ project=spec.project.value,
258
+ )
259
+ if command == "merge-merge-request":
260
+ return merge_merge_request(
261
+ config,
262
+ mr_iid=spec.mr_iid.value,
263
+ sha=spec.sha.value,
264
+ # An unset flag keeps the merge request's own setting.
265
+ squash=spec.squash.value or None,
266
+ remove_source_branch=spec.remove_source_branch.value or None,
267
+ when_pipeline_succeeds=spec.when_pipeline_succeeds.value,
268
+ merge_commit_message=spec.merge_commit_message.value,
269
+ project=spec.project.value,
270
+ )
271
+ if command == "create-branch":
272
+ return create_branch(config, branch=spec.branch.value, ref=spec.ref.value, project=spec.project.value)
273
+ if command == "write-file":
274
+ return write_file(
275
+ config,
276
+ file_path=spec.file_path.value,
277
+ content=_content(spec),
278
+ branch=spec.branch.value,
279
+ commit_message=spec.commit_message.value,
280
+ start_branch=spec.start_branch.value,
281
+ last_commit_id=spec.last_commit_id.value,
282
+ project=spec.project.value,
283
+ )
284
+ if command == "create-pipeline":
285
+ return create_pipeline(config, ref=spec.ref.value, variables=_list(spec.variable), project=spec.project.value)
286
+ if command == "retry-pipeline":
287
+ return retry_pipeline(config, pipeline_id=spec.pipeline_id.value, project=spec.project.value)
288
+ if command == "cancel-pipeline":
289
+ return cancel_pipeline(config, pipeline_id=spec.pipeline_id.value, project=spec.project.value)
290
+ if command == "retry-job":
291
+ return retry_job(config, job_id=spec.job_id.value, project=spec.project.value)
292
+ if command == "cancel-job":
293
+ return cancel_job(config, job_id=spec.job_id.value, project=spec.project.value)
294
+ if command == "play-job":
295
+ return play_job(config, job_id=spec.job_id.value, variables=_list(spec.variable), project=spec.project.value)
296
+
297
+ # ── deletes (autonomy = "manual": human-only) ──
298
+ if command == "delete-issue":
299
+ return delete_issue(config, issue_iid=spec.issue_iid.value, project=spec.project.value)
300
+ if command == "delete-branch":
301
+ return delete_branch(config, branch=spec.branch.value, project=spec.project.value)
302
+ if command == "delete-file":
303
+ return delete_file(
304
+ config,
305
+ file_path=spec.file_path.value,
306
+ branch=spec.branch.value,
307
+ commit_message=spec.commit_message.value,
308
+ last_commit_id=spec.last_commit_id.value,
309
+ project=spec.project.value,
310
+ )
311
+ if command == "delete-pipeline":
312
+ return delete_pipeline(config, pipeline_id=spec.pipeline_id.value, project=spec.project.value)
313
+
314
+ # require-command = true makes a bare invocation unreachable.
315
+ raise SystemExit("no command — see `gitlab --help`") # pragma: no cover
316
+
317
+
318
+ def main() -> None:
319
+ spec = rs.parse("gitlab")
320
+ try:
321
+ config = config_from_env(env=spec.env.value)
322
+ result = _dispatch(spec, config)
323
+ except (GitlabError, ValueError, OSError) as exc:
324
+ _emit({"error": str(exc), "kind": type(exc).__name__})
325
+ sys.exit(1)
326
+ _emit(result)
327
+
328
+
329
+ if __name__ == "__main__":
330
+ main()