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.
- runspec_gitlab-0.1.0/.gitignore +61 -0
- runspec_gitlab-0.1.0/CHANGELOG.md +21 -0
- runspec_gitlab-0.1.0/PKG-INFO +11 -0
- runspec_gitlab-0.1.0/pyproject.toml +39 -0
- runspec_gitlab-0.1.0/runspec_gitlab/__init__.py +54 -0
- runspec_gitlab-0.1.0/runspec_gitlab/cli.py +132 -0
- runspec_gitlab-0.1.0/runspec_gitlab/runspec.toml +156 -0
- runspec_gitlab-0.1.0/tests/__init__.py +0 -0
- runspec_gitlab-0.1.0/tests/test_cli.py +106 -0
- runspec_gitlab-0.1.0/tests/test_spec.py +83 -0
|
@@ -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()
|