runspec-gitlab 0.1.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.
@@ -0,0 +1,61 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .eggs/
12
+ .venv/
13
+ venv/
14
+ env/
15
+ .env
16
+ pip-wheel-metadata/
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ htmlcov/
21
+ .coverage
22
+ coverage.xml
23
+ *.cover
24
+
25
+ # Node
26
+ node_modules/
27
+ dist/
28
+ *.js.map
29
+ .npm
30
+
31
+ # Go
32
+ *.exe
33
+ *.test
34
+ *.out
35
+ vendor/
36
+
37
+ # IDE
38
+ .idea/
39
+ .vscode/
40
+ *.iml
41
+ *.iws
42
+ *.ipr
43
+ .DS_Store
44
+ Thumbs.db
45
+
46
+ # Docs
47
+ site/
48
+
49
+ # Misc
50
+ *.log
51
+ *.tmp
52
+
53
+ # External reference repos (cloned locally, not committed)
54
+ chainlit-docs/
55
+ .chainlit/
56
+
57
+ # Claude Code local config (machine-specific)
58
+ .claude/launch.json
59
+
60
+ # Stray committed test venv (removed from tracking)
61
+ .venv-test/
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to `runspec-gitlab` are documented here.
4
+
5
+ ## [0.1.0]
6
+
7
+ Initial release. One runnable, `gitlab`, with a read verb per tool of the
8
+ [GitLab MCP server](https://docs.gitlab.com/user/model_context_protocol/mcp_server/),
9
+ **ported onto the GitLab REST API (v4)** rather than proxying that server — so it
10
+ runs over the runspec-console SSH fleet with no extra process. `runspec serve`
11
+ re-expands each verb into its own leaf MCP tool (`gitlab_get-issue`,
12
+ `gitlab_list-pipelines`, …).
13
+
14
+ - Verbs (all `autonomy = "autonomous"` — read-only): `whoami`, `get-project`,
15
+ `search-projects`, `list-issues`, `get-issue`, `list-merge-requests`,
16
+ `get-merge-request`, `get-merge-request-changes`, `list-pipelines`,
17
+ `get-pipeline-jobs`, `get-job-log`, `get-file`, `list-tree`.
18
+ - Global `--env` (dev/uat/prod) selects `GITLAB_<ENV>_*` env vars; auth is
19
+ `GITLAB_URL` (default `https://gitlab.com`) + `GITLAB_TOKEN`. `project` accepts
20
+ a numeric id or `group/project` path and falls back to `GITLAB_PROJECT`.
21
+ - Thin wrapper over `runspec-gitlab-core` (the dependency-free REST logic core).
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: runspec-gitlab
3
+ Version: 0.1.0
4
+ Summary: GitLab read runnable for runspec — issues, merge requests, pipelines, and repository reads, ported from the GitLab MCP server
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: runspec-gitlab-core>=0.1.0
7
+ Requires-Dist: runspec>=0.44.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: mypy; extra == 'dev'
10
+ Requires-Dist: pytest>=8.0; extra == 'dev'
11
+ Requires-Dist: ruff==0.15.20; extra == 'dev'
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "runspec-gitlab"
7
+ version = "0.1.0"
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"
10
+ dependencies = [
11
+ # 0.44.0 surfaces inherited args (the global --env) on each leaf subcommand's
12
+ # MCP tool via `runspec serve`.
13
+ "runspec>=0.44.0",
14
+ "runspec-gitlab-core>=0.1.0",
15
+ ]
16
+
17
+ [project.scripts]
18
+ # Entry point name MUST match the runnable name in runspec.toml (`gitlab`).
19
+ gitlab = "runspec_gitlab.cli:main"
20
+
21
+ [project.optional-dependencies]
22
+ dev = [
23
+ "ruff==0.15.20",
24
+ "mypy",
25
+ "pytest>=8.0",
26
+ ]
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
30
+
31
+ [tool.mypy]
32
+ python_version = "3.10"
33
+
34
+ [tool.ruff]
35
+ line-length = 200
36
+ target-version = "py310"
37
+
38
+ [tool.ruff.lint]
39
+ select = ["E", "F", "I", "UP", "B", "SIM"]
@@ -0,0 +1,54 @@
1
+ """runspec-gitlab — GitLab reads as a runspec runnable (the ``gitlab`` runnable).
2
+
3
+ Ported from the
4
+ `GitLab MCP server <https://docs.gitlab.com/user/model_context_protocol/mcp_server/>`_.
5
+ The pure REST logic lives in ``runspec-gitlab-core`` (importable with **no**
6
+ runspec dependency); this package wraps each helper as a verb of the ``gitlab``
7
+ runnable and is what ``runspec local`` / ``runspec serve`` discover.
8
+
9
+ The core helpers are re-exported here for convenience / back-compat, so a caller
10
+ that already depends on ``runspec-gitlab`` can ``from runspec_gitlab import
11
+ list_issues`` without also naming ``runspec-gitlab-core``.
12
+ """
13
+
14
+ from runspec_gitlab_core import (
15
+ GitlabConfig,
16
+ GitlabError,
17
+ config_from_env,
18
+ encode_project,
19
+ get_file,
20
+ get_issue,
21
+ get_job_log,
22
+ get_merge_request,
23
+ get_merge_request_changes,
24
+ get_pipeline_jobs,
25
+ get_project,
26
+ gitlab_request,
27
+ list_issues,
28
+ list_merge_requests,
29
+ list_pipelines,
30
+ list_tree,
31
+ search_projects,
32
+ whoami,
33
+ )
34
+
35
+ __all__ = [
36
+ "GitlabConfig",
37
+ "GitlabError",
38
+ "config_from_env",
39
+ "gitlab_request",
40
+ "encode_project",
41
+ "whoami",
42
+ "get_project",
43
+ "search_projects",
44
+ "list_issues",
45
+ "get_issue",
46
+ "list_merge_requests",
47
+ "get_merge_request",
48
+ "get_merge_request_changes",
49
+ "list_pipelines",
50
+ "get_pipeline_jobs",
51
+ "get_job_log",
52
+ "get_file",
53
+ "list_tree",
54
+ ]
@@ -0,0 +1,132 @@
1
+ """cli.py — the ``gitlab`` entry point.
2
+
3
+ One binary, a verb per GitLab MCP read tool. ``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 typing import Any
17
+
18
+ import runspec as rs
19
+ from runspec_gitlab_core import (
20
+ GitlabConfig,
21
+ GitlabError,
22
+ config_from_env,
23
+ get_file,
24
+ get_issue,
25
+ get_job_log,
26
+ get_merge_request,
27
+ get_merge_request_changes,
28
+ get_pipeline_jobs,
29
+ get_project,
30
+ list_issues,
31
+ list_merge_requests,
32
+ list_pipelines,
33
+ list_tree,
34
+ search_projects,
35
+ whoami,
36
+ )
37
+
38
+
39
+ def _emit(payload: Any) -> None:
40
+ print(json.dumps(payload, default=str))
41
+
42
+
43
+ def _dispatch(spec: rs.RunSpec, config: GitlabConfig) -> Any:
44
+ """Run the active verb against ``config``, unpacking ``spec.<arg>.value`` for
45
+ only the args the verb declares (globals + its own)."""
46
+ command = spec.runspec_command
47
+
48
+ if command == "whoami":
49
+ return whoami(config)
50
+ if command == "get-project":
51
+ return get_project(config, project=spec.project.value)
52
+ if command == "search-projects":
53
+ return search_projects(
54
+ config,
55
+ search=spec.search.value,
56
+ membership=spec.membership.value,
57
+ per_page=spec.per_page.value,
58
+ page=spec.page.value,
59
+ )
60
+ if command == "list-issues":
61
+ return list_issues(
62
+ config,
63
+ project=spec.project.value,
64
+ state=spec.state.value,
65
+ labels=spec.labels.value,
66
+ search=spec.search.value,
67
+ per_page=spec.per_page.value,
68
+ page=spec.page.value,
69
+ )
70
+ if command == "get-issue":
71
+ return get_issue(config, issue_iid=spec.issue_iid.value, project=spec.project.value)
72
+ if command == "list-merge-requests":
73
+ return list_merge_requests(
74
+ config,
75
+ project=spec.project.value,
76
+ state=spec.state.value,
77
+ per_page=spec.per_page.value,
78
+ page=spec.page.value,
79
+ )
80
+ if command == "get-merge-request":
81
+ return get_merge_request(config, mr_iid=spec.mr_iid.value, project=spec.project.value)
82
+ if command == "get-merge-request-changes":
83
+ return get_merge_request_changes(config, mr_iid=spec.mr_iid.value, project=spec.project.value)
84
+ if command == "list-pipelines":
85
+ return list_pipelines(
86
+ config,
87
+ project=spec.project.value,
88
+ ref=spec.ref.value,
89
+ status=spec.status.value,
90
+ per_page=spec.per_page.value,
91
+ page=spec.page.value,
92
+ )
93
+ if command == "get-pipeline-jobs":
94
+ return get_pipeline_jobs(
95
+ config,
96
+ pipeline_id=spec.pipeline_id.value,
97
+ project=spec.project.value,
98
+ per_page=spec.per_page.value,
99
+ page=spec.page.value,
100
+ )
101
+ if command == "get-job-log":
102
+ return get_job_log(config, job_id=spec.job_id.value, project=spec.project.value, tail=spec.tail.value)
103
+ if command == "get-file":
104
+ return get_file(config, file_path=spec.file_path.value, ref=spec.ref.value, project=spec.project.value)
105
+ if command == "list-tree":
106
+ return list_tree(
107
+ config,
108
+ path=spec.path.value,
109
+ ref=spec.ref.value,
110
+ recursive=spec.recursive.value,
111
+ project=spec.project.value,
112
+ per_page=spec.per_page.value,
113
+ page=spec.page.value,
114
+ )
115
+
116
+ # require-command = true makes a bare invocation unreachable.
117
+ raise SystemExit("no command — see `gitlab --help`") # pragma: no cover
118
+
119
+
120
+ def main() -> None:
121
+ spec = rs.parse("gitlab")
122
+ try:
123
+ config = config_from_env(env=spec.env.value)
124
+ result = _dispatch(spec, config)
125
+ except (GitlabError, ValueError) as exc:
126
+ _emit({"error": str(exc), "kind": type(exc).__name__})
127
+ sys.exit(1)
128
+ _emit(result)
129
+
130
+
131
+ if __name__ == "__main__":
132
+ main()
@@ -0,0 +1,156 @@
1
+ #:schema https://runspec.app/runspec.schema.json
2
+
3
+ # runspec-gitlab — GitLab reads as a runspec runnable, ported from the GitLab MCP
4
+ # server: https://docs.gitlab.com/user/model_context_protocol/mcp_server/
5
+ #
6
+ # One runnable, `gitlab`, with a verb per read tool. `runspec serve` re-expands
7
+ # each verb into its own leaf MCP tool (gitlab_get-issue, gitlab_list-pipelines,
8
+ # …), so a runspec-console agent calls them individually — over SSH on any fleet
9
+ # host.
10
+ #
11
+ # The tools are PORTED onto the GitLab REST API (v4), not proxied from GitLab's
12
+ # hosted MCP server, so no extra process is involved. Auth is env-var driven:
13
+ # GITLAB_URL (default https://gitlab.com) + GITLAB_TOKEN (a personal access
14
+ # token). Per-environment overrides use a GITLAB_<ENV>_ prefix (e.g.
15
+ # GITLAB_UAT_TOKEN), selected with the global --env (dev/uat/prod) arg.
16
+ #
17
+ # `project` accepts a numeric id or a URL-path (`group/subgroup/project`); it
18
+ # falls back to GITLAB_PROJECT when omitted.
19
+
20
+ [config]
21
+ autonomy-default = "confirm"
22
+
23
+ [config.logging]
24
+ store = "per-run"
25
+
26
+ [gitlab]
27
+ description = "Read GitLab issues, merge requests, pipelines, and repository files (over the REST API)"
28
+ output = "json"
29
+ require-command = true
30
+ examples = [
31
+ {cmd = "gitlab whoami", description = "Verify the token and show the authenticated user"},
32
+ {cmd = "gitlab list-issues --project group/app --state opened", description = "List a project's open issues"},
33
+ {cmd = "gitlab get-merge-request-changes --project 42 --mr-iid 17", description = "Show a merge request's diffs"},
34
+ {cmd = "gitlab get-job-log --project group/app --job-id 9001 --tail 100", description = "Tail the last 100 lines of a CI job log"},
35
+ {cmd = "gitlab get-file --project group/app --file-path src/main.py --ref main", description = "Read a repository file at a ref"},
36
+ ]
37
+
38
+ # ── global args (inherited by every verb) ──────────────────────────────────
39
+ [gitlab.args]
40
+ env = {type = "choice", options = ["dev", "uat", "prod"], short = "-e", required = false, description = "Target environment (selects GITLAB_<ENV>_* vars)"}
41
+
42
+ # ── identity / projects ────────────────────────────────────────────────────
43
+
44
+ [gitlab.commands.whoami]
45
+ description = "Return the authenticated user for the configured token (a quick identity/token check)"
46
+ autonomy = "autonomous"
47
+
48
+ [gitlab.commands.get-project]
49
+ description = "Fetch a single project's metadata by numeric id or namespace/project path"
50
+ autonomy = "autonomous"
51
+ [gitlab.commands.get-project.args]
52
+ project = {type = "str", short = "-p", required = false, description = "Project id or path (falls back to GITLAB_PROJECT)"}
53
+
54
+ [gitlab.commands.search-projects]
55
+ description = "Search visible projects by name/path"
56
+ autonomy = "autonomous"
57
+ [gitlab.commands.search-projects.args]
58
+ search = {type = "str", required = true, description = "Text to match against project name/path"}
59
+ membership = {type = "flag", default = false, description = "Only projects the token's user is a member of"}
60
+ per-page = {type = "int", default = 20, description = "Results per page (GitLab caps at 100)"}
61
+ page = {type = "int", default = 1, description = "Page number"}
62
+
63
+ # ── issues ─────────────────────────────────────────────────────────────────
64
+
65
+ [gitlab.commands.list-issues]
66
+ description = "List a project's issues, optionally filtered by state/labels/search"
67
+ autonomy = "autonomous"
68
+ [gitlab.commands.list-issues.args]
69
+ project = {type = "str", short = "-p", required = false, description = "Project id or path (falls back to GITLAB_PROJECT)"}
70
+ state = {type = "choice", options = ["opened", "closed", "all"], required = false, description = "Issue state filter"}
71
+ labels = {type = "str", required = false, description = "Comma-separated label list to filter by"}
72
+ search = {type = "str", required = false, description = "Match title/description text"}
73
+ per-page = {type = "int", default = 20, description = "Results per page (GitLab caps at 100)"}
74
+ page = {type = "int", default = 1, description = "Page number"}
75
+
76
+ [gitlab.commands.get-issue]
77
+ description = "Fetch a single issue by its project-scoped internal id (iid)"
78
+ autonomy = "autonomous"
79
+ [gitlab.commands.get-issue.args]
80
+ project = {type = "str", short = "-p", required = false, description = "Project id or path (falls back to GITLAB_PROJECT)"}
81
+ issue-iid = {type = "int", required = true, description = "Issue internal id (the #N shown in the UI)"}
82
+
83
+ # ── merge requests ─────────────────────────────────────────────────────────
84
+
85
+ [gitlab.commands.list-merge-requests]
86
+ description = "List a project's merge requests, optionally filtered by state"
87
+ autonomy = "autonomous"
88
+ [gitlab.commands.list-merge-requests.args]
89
+ project = {type = "str", short = "-p", required = false, description = "Project id or path (falls back to GITLAB_PROJECT)"}
90
+ state = {type = "choice", options = ["opened", "closed", "merged", "locked", "all"], required = false, description = "Merge request state filter"}
91
+ per-page = {type = "int", default = 20, description = "Results per page (GitLab caps at 100)"}
92
+ page = {type = "int", default = 1, description = "Page number"}
93
+
94
+ [gitlab.commands.get-merge-request]
95
+ description = "Fetch a single merge request by its project-scoped internal id (iid)"
96
+ autonomy = "autonomous"
97
+ [gitlab.commands.get-merge-request.args]
98
+ project = {type = "str", short = "-p", required = false, description = "Project id or path (falls back to GITLAB_PROJECT)"}
99
+ mr-iid = {type = "int", required = true, description = "Merge request internal id (the !N shown in the UI)"}
100
+
101
+ [gitlab.commands.get-merge-request-changes]
102
+ description = "Fetch a merge request together with its file changes / diffs"
103
+ autonomy = "autonomous"
104
+ [gitlab.commands.get-merge-request-changes.args]
105
+ project = {type = "str", short = "-p", required = false, description = "Project id or path (falls back to GITLAB_PROJECT)"}
106
+ mr-iid = {type = "int", required = true, description = "Merge request internal id (the !N shown in the UI)"}
107
+
108
+ # ── pipelines / jobs ───────────────────────────────────────────────────────
109
+
110
+ [gitlab.commands.list-pipelines]
111
+ description = "List a project's CI/CD pipelines, optionally filtered by ref/status"
112
+ autonomy = "autonomous"
113
+ [gitlab.commands.list-pipelines.args]
114
+ project = {type = "str", short = "-p", required = false, description = "Project id or path (falls back to GITLAB_PROJECT)"}
115
+ ref = {type = "str", required = false, description = "Branch or tag to filter by"}
116
+ status = {type = "choice", options = ["created", "waiting_for_resource", "preparing", "pending", "running", "success", "failed", "canceled", "skipped", "manual", "scheduled"], required = false, description = "Pipeline status filter"}
117
+ per-page = {type = "int", default = 20, description = "Results per page (GitLab caps at 100)"}
118
+ page = {type = "int", default = 1, description = "Page number"}
119
+
120
+ [gitlab.commands.get-pipeline-jobs]
121
+ description = "List the jobs of a pipeline (by pipeline id)"
122
+ autonomy = "autonomous"
123
+ [gitlab.commands.get-pipeline-jobs.args]
124
+ project = {type = "str", short = "-p", required = false, description = "Project id or path (falls back to GITLAB_PROJECT)"}
125
+ pipeline-id = {type = "int", required = true, description = "Pipeline id (not the iid)"}
126
+ per-page = {type = "int", default = 20, description = "Results per page (GitLab caps at 100)"}
127
+ page = {type = "int", default = 1, description = "Page number"}
128
+
129
+ [gitlab.commands.get-job-log]
130
+ description = "Fetch a CI job's log (trace) as text; pass --tail to return only the last N lines"
131
+ autonomy = "autonomous"
132
+ [gitlab.commands.get-job-log.args]
133
+ project = {type = "str", short = "-p", required = false, description = "Project id or path (falls back to GITLAB_PROJECT)"}
134
+ job-id = {type = "int", required = true, description = "Job id"}
135
+ tail = {type = "int", required = false, description = "Return only the last N lines (logs are large — usually pass this)"}
136
+
137
+ # ── repository ─────────────────────────────────────────────────────────────
138
+
139
+ [gitlab.commands.get-file]
140
+ description = "Read a single repository file at a ref (base64 is decoded; non-UTF-8 files are flagged binary)"
141
+ autonomy = "autonomous"
142
+ [gitlab.commands.get-file.args]
143
+ project = {type = "str", short = "-p", required = false, description = "Project id or path (falls back to GITLAB_PROJECT)"}
144
+ file-path = {type = "str", required = true, description = "Path within the repository (e.g. src/main.py)"}
145
+ ref = {type = "str", default = "HEAD", description = "Branch, tag, or commit (default HEAD = the default branch tip)"}
146
+
147
+ [gitlab.commands.list-tree]
148
+ description = "List a repository tree at a path/ref; --recursive walks subdirectories"
149
+ autonomy = "autonomous"
150
+ [gitlab.commands.list-tree.args]
151
+ project = {type = "str", short = "-p", required = false, description = "Project id or path (falls back to GITLAB_PROJECT)"}
152
+ path = {type = "str", required = false, description = "Subdirectory to list (default: repository root)"}
153
+ ref = {type = "str", required = false, description = "Branch, tag, or commit (default: the default branch)"}
154
+ recursive = {type = "flag", default = false, description = "Walk subdirectories recursively"}
155
+ per-page = {type = "int", default = 20, description = "Results per page (GitLab caps at 100)"}
156
+ page = {type = "int", default = 1, description = "Page number"}
File without changes
@@ -0,0 +1,106 @@
1
+ """Wrapper logic: real parsed spec → `_dispatch` → core helper, with the core's
2
+ httpx routed through a MockTransport (no real network), plus the main() error path."""
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import httpx
8
+ import pytest
9
+ from runspec.testing import ParseHarness
10
+ from runspec_gitlab_core import GitlabConfig
11
+ from runspec_gitlab_core import client as client_mod
12
+
13
+ from runspec_gitlab import cli
14
+
15
+ SPEC = Path(__file__).parent.parent / "runspec_gitlab" / "runspec.toml"
16
+ CFG = GitlabConfig(base_url="https://gl", token="tok", project="grp/app")
17
+
18
+
19
+ def _harness() -> ParseHarness:
20
+ return ParseHarness(script_name="gitlab", config_path=SPEC)
21
+
22
+
23
+ def _capture(monkeypatch, response: httpx.Response) -> dict:
24
+ seen: dict = {}
25
+
26
+ def handler(request: httpx.Request) -> httpx.Response:
27
+ # raw_path keeps %2F encoded (the real wire path); .path would decode it.
28
+ seen["path"] = request.url.raw_path.decode().split("?")[0]
29
+ seen["query"] = dict(request.url.params)
30
+ return response
31
+
32
+ transport = httpx.MockTransport(handler)
33
+
34
+ def fake_request(method, url, **kwargs):
35
+ with httpx.Client(transport=transport) as c:
36
+ return c.request(method, url, **kwargs)
37
+
38
+ monkeypatch.setattr(client_mod.httpx, "request", fake_request)
39
+ return seen
40
+
41
+
42
+ # ── _dispatch over a real parsed spec ───────────────────────────────────────
43
+
44
+
45
+ def test_dispatch_list_issues_unpacks_args(monkeypatch) -> None:
46
+ seen = _capture(monkeypatch, httpx.Response(200, json=[{"iid": 1}]))
47
+ h = _harness()
48
+ spec = h.expect_ok(["list-issues", "--project", "grp/app", "--state", "opened", "--labels", "bug"])
49
+ result = cli._dispatch(spec, CFG)
50
+ h.close()
51
+ assert result == [{"iid": 1}]
52
+ assert seen["path"] == "/api/v4/projects/grp%2Fapp/issues"
53
+ assert seen["query"]["state"] == "opened"
54
+ assert seen["query"]["labels"] == "bug"
55
+
56
+
57
+ def test_dispatch_get_job_log_tail(monkeypatch) -> None:
58
+ _capture(monkeypatch, httpx.Response(200, text="a\nb\nc\nd"))
59
+ h = _harness()
60
+ spec = h.expect_ok(["get-job-log", "--project", "grp/app", "--job-id", "5", "--tail", "2"])
61
+ result = cli._dispatch(spec, CFG)
62
+ h.close()
63
+ assert result["trace"] == "c\nd"
64
+ assert result["truncated"] is True
65
+
66
+
67
+ def test_dispatch_whoami(monkeypatch) -> None:
68
+ seen = _capture(monkeypatch, httpx.Response(200, json={"username": "alice"}))
69
+ h = _harness()
70
+ spec = h.expect_ok(["whoami"])
71
+ assert cli._dispatch(spec, CFG) == {"username": "alice"}
72
+ h.close()
73
+ assert seen["path"] == "/api/v4/user"
74
+
75
+
76
+ def test_dispatch_get_file_decodes(monkeypatch) -> None:
77
+ import base64
78
+
79
+ encoded = base64.b64encode(b"print('hi')").decode()
80
+ _capture(monkeypatch, httpx.Response(200, json={"encoding": "base64", "content": encoded}))
81
+ h = _harness()
82
+ spec = h.expect_ok(["get-file", "--project", "grp/app", "--file-path", "a.py", "--ref", "main"])
83
+ result = cli._dispatch(spec, CFG)
84
+ h.close()
85
+ assert result["content"] == "print('hi')"
86
+
87
+
88
+ # ── main() error path ───────────────────────────────────────────────────────
89
+
90
+
91
+ def test_main_missing_token_emits_error(monkeypatch, capsys) -> None:
92
+ # No GITLAB_* env → config_from_env raises GitlabError → JSON error + exit 1.
93
+ for var in ("GITLAB_TOKEN", "GITLAB_URL", "GITLAB_ENV"):
94
+ monkeypatch.delenv(var, raising=False)
95
+ # Build the spec via the real parser FIRST, then stub cli's parse to return
96
+ # it — patching parse before this would also break the harness's own call.
97
+ h = _harness()
98
+ spec = h.expect_ok(["whoami"])
99
+ h.close()
100
+ monkeypatch.setattr(cli.rs, "parse", lambda name: spec)
101
+ with pytest.raises(SystemExit) as exc:
102
+ cli.main()
103
+ assert exc.value.code == 1
104
+ payload = json.loads(capsys.readouterr().out)
105
+ assert payload["kind"] == "GitlabError"
106
+ assert "token" in payload["error"].lower()
@@ -0,0 +1,83 @@
1
+ """Wiring / parsing tests for the `gitlab` runnable — no mocks, real spec."""
2
+
3
+ from pathlib import Path
4
+
5
+ from runspec.testing import ParseHarness
6
+
7
+ SPEC = Path(__file__).parent.parent / "runspec_gitlab" / "runspec.toml"
8
+
9
+
10
+ def _harness() -> ParseHarness:
11
+ return ParseHarness(script_name="gitlab", config_path=SPEC)
12
+
13
+
14
+ def test_smoke_all_verbs() -> None:
15
+ h = _harness()
16
+ # Walk the whole verb tree: --help exits 0, require-command is enforced,
17
+ # omitting required args exits 1, and a fully-populated invocation parses.
18
+ h.smoke(
19
+ values={
20
+ "project": "grp/app",
21
+ "search": "x",
22
+ "issue-iid": 1,
23
+ "mr-iid": 1,
24
+ "pipeline-id": 1,
25
+ "job-id": 1,
26
+ "file-path": "README.md",
27
+ }
28
+ )
29
+ h.close()
30
+
31
+
32
+ def test_int_args_coerce() -> None:
33
+ h = _harness()
34
+ spec = h.expect_ok(["get-issue", "--project", "grp/app", "--issue-iid", "42"])
35
+ assert spec.runspec_command == "get-issue"
36
+ assert spec.issue_iid.value == 42
37
+ assert isinstance(spec.issue_iid.value, int)
38
+ h.close()
39
+
40
+
41
+ def test_default_per_page() -> None:
42
+ h = _harness()
43
+ spec = h.expect_ok(["list-issues", "--project", "grp/app"])
44
+ assert spec.per_page.value == 20
45
+ assert spec.page.value == 1
46
+ assert spec.state.value is None
47
+ h.close()
48
+
49
+
50
+ def test_global_env_choice_inherited() -> None:
51
+ h = _harness()
52
+ spec = h.expect_ok(["--env", "uat", "whoami"])
53
+ assert spec.env.value == "uat"
54
+ h.close()
55
+
56
+
57
+ def test_get_file_ref_defaults_to_head() -> None:
58
+ h = _harness()
59
+ spec = h.expect_ok(["get-file", "--project", "grp/app", "--file-path", "a.py"])
60
+ assert spec.ref.value == "HEAD"
61
+ h.close()
62
+
63
+
64
+ def test_bad_state_choice_rejected() -> None:
65
+ h = _harness()
66
+ h.expect_exit(
67
+ ["list-issues", "--project", "grp/app", "--state", "nope"],
68
+ contains="state",
69
+ )
70
+ h.close()
71
+
72
+
73
+ def test_missing_required_issue_iid_rejected() -> None:
74
+ h = _harness()
75
+ h.expect_exit(["get-issue", "--project", "grp/app"], contains="issue-iid")
76
+ h.close()
77
+
78
+
79
+ def test_list_tree_recursive_flag() -> None:
80
+ h = _harness()
81
+ spec = h.expect_ok(["list-tree", "--project", "grp/app", "--recursive"])
82
+ assert spec.recursive.value is True
83
+ h.close()