runspec-confluence 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,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to `runspec-confluence` are documented here.
4
+
5
+ ## [0.1.0] — 2026-08-07
6
+
7
+ Initial release. A `confluence` runnable ported from the Atlassian MCP server
8
+ (sooperset/mcp-atlassian) onto `atlassian-python-api`, on Cloud and Server/Data
9
+ Center. Thin wrapper over `runspec-confluence-core`.
10
+
11
+ - One runnable, `confluence`, with a verb per tool; `runspec serve` re-expands
12
+ each into a leaf MCP tool (`confluence_get-page`, …). Reads are `autonomous`,
13
+ writes `confirm`.
14
+ - Verbs: `search` (CQL), pages (`get-page`, `get-page-children`,
15
+ `get-page-ancestors`, `create-page`, `update-page`, `delete-page`, `move-page`,
16
+ `get-space-pages`, `get-space`), and page content (`get-page-history`,
17
+ `get-labels`, `add-label`, `get-comments`, `add-comment`,
18
+ `get-page-restrictions`).
19
+ - Auth is env-var driven (`CONFLUENCE_URL` + `CONFLUENCE_PERSONAL_TOKEN` or
20
+ `CONFLUENCE_USERNAME`/`CONFLUENCE_API_TOKEN`), with `CONFLUENCE_<ENV>_*` prefixes
21
+ selected by the global `--env`. Cloud vs Server/DC via `CONFLUENCE_CLOUD` or a
22
+ `.atlassian.net` host.
23
+ - Page bodies are passed in a Confluence `representation` via `--content-format`
24
+ (`storage` default, or `wiki`/`editor`).
25
+
26
+ Deferred: markdown↔storage conversion, attachments/images, page diffs, space
27
+ page-tree, setting restrictions, inline comments, templates, analytics, OAuth 2.0.
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: runspec-confluence
3
+ Version: 0.1.0
4
+ Summary: Confluence runnable for runspec — pages, search, comments, labels, and restrictions (Cloud + Server/Data Center)
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: runspec-confluence-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-confluence"
7
+ version = "0.1.0"
8
+ requires-python = ">=3.10"
9
+ description = "Confluence runnable for runspec — pages, search, comments, labels, and restrictions (Cloud + Server/Data Center)"
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-confluence-core>=0.1.0",
15
+ ]
16
+
17
+ [project.scripts]
18
+ # Entry point name MUST match the runnable name in runspec.toml (`confluence`).
19
+ confluence = "runspec_confluence.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,52 @@
1
+ """runspec-confluence — a Confluence runnable (pages, search, comments, labels,
2
+ restrictions) over ``atlassian-python-api``, on Cloud and Server/Data Center.
3
+
4
+ The logic lives in the dependency-light ``runspec-confluence-core``; this package
5
+ is the thin ``confluence`` runnable wrapper. The core helpers are re-exported here
6
+ for back-compat so ``from runspec_confluence import get_page`` works without naming
7
+ the core package.
8
+ """
9
+
10
+ from runspec_confluence_core import (
11
+ ConfluenceConfig,
12
+ ConfluenceError,
13
+ add_comment,
14
+ add_label,
15
+ config_from_env,
16
+ create_page,
17
+ delete_page,
18
+ get_comments,
19
+ get_labels,
20
+ get_page,
21
+ get_page_ancestors,
22
+ get_page_children,
23
+ get_page_history,
24
+ get_page_restrictions,
25
+ get_space,
26
+ get_space_pages,
27
+ move_page,
28
+ search,
29
+ update_page,
30
+ )
31
+
32
+ __all__ = [
33
+ "ConfluenceConfig",
34
+ "ConfluenceError",
35
+ "config_from_env",
36
+ "get_page",
37
+ "get_page_children",
38
+ "get_page_ancestors",
39
+ "create_page",
40
+ "update_page",
41
+ "delete_page",
42
+ "move_page",
43
+ "get_space_pages",
44
+ "get_space",
45
+ "search",
46
+ "get_page_history",
47
+ "get_labels",
48
+ "add_label",
49
+ "get_comments",
50
+ "add_comment",
51
+ "get_page_restrictions",
52
+ ]
@@ -0,0 +1,125 @@
1
+ """cli.py — the ``confluence`` entry point.
2
+
3
+ One binary, a verb per Confluence tool. ``rs.parse("confluence")`` resolves the
4
+ subcommand and its args; we build a :class:`ConfluenceConfig` from the environment
5
+ (scoped by the global ``--env``), unpack each ``.value`` at the boundary, and call
6
+ the matching :mod:`runspec_confluence_core` helper.
7
+
8
+ Failures (the core :class:`ConfluenceError` and arg-shape ``ValueError``s) become a
9
+ 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_confluence_core import (
20
+ ConfluenceConfig,
21
+ ConfluenceError,
22
+ add_comment,
23
+ add_label,
24
+ config_from_env,
25
+ create_page,
26
+ delete_page,
27
+ get_comments,
28
+ get_labels,
29
+ get_page,
30
+ get_page_ancestors,
31
+ get_page_children,
32
+ get_page_history,
33
+ get_page_restrictions,
34
+ get_space,
35
+ get_space_pages,
36
+ move_page,
37
+ search,
38
+ update_page,
39
+ )
40
+
41
+
42
+ def _emit(payload: Any) -> None:
43
+ print(json.dumps(payload, default=str))
44
+
45
+
46
+ def _dispatch(spec: rs.RunSpec, config: ConfluenceConfig) -> Any:
47
+ """Run the active verb against ``config``, unpacking only its declared args."""
48
+ command = spec.runspec_command
49
+
50
+ # ── reads ──
51
+ if command == "search":
52
+ return search(config, spec.cql.value, start=spec.start.value, limit=spec.limit.value, expand=spec.expand.value)
53
+ if command == "get-page":
54
+ return get_page(config, page_id=spec.page_id.value, space=spec.space.value, title=spec.title.value, expand=spec.expand.value)
55
+ if command == "get-page-children":
56
+ return get_page_children(config, spec.page_id.value)
57
+ if command == "get-page-ancestors":
58
+ return get_page_ancestors(config, spec.page_id.value)
59
+ if command == "get-page-history":
60
+ return get_page_history(config, spec.page_id.value)
61
+ if command == "get-labels":
62
+ return get_labels(config, spec.page_id.value)
63
+ if command == "get-comments":
64
+ return get_comments(config, spec.page_id.value, limit=spec.limit.value)
65
+ if command == "get-page-restrictions":
66
+ return get_page_restrictions(config, spec.page_id.value)
67
+ if command == "get-space":
68
+ return get_space(config, spec.space_key.value)
69
+ if command == "get-space-pages":
70
+ return get_space_pages(config, spec.space.value, start=spec.start.value, limit=spec.limit.value)
71
+
72
+ # ── writes ──
73
+ if command == "create-page":
74
+ return create_page(
75
+ config,
76
+ spec.space.value,
77
+ spec.title.value,
78
+ spec.content.value,
79
+ parent_id=spec.parent_id.value,
80
+ representation=spec.content_format.value,
81
+ )
82
+ if command == "update-page":
83
+ return update_page(
84
+ config,
85
+ spec.page_id.value,
86
+ spec.title.value,
87
+ body=spec.content.value,
88
+ parent_id=spec.parent_id.value,
89
+ representation=spec.content_format.value,
90
+ version_comment=spec.version_comment.value,
91
+ minor_edit=spec.minor_edit.value,
92
+ )
93
+ if command == "delete-page":
94
+ return delete_page(config, spec.page_id.value, recursive=spec.recursive.value)
95
+ if command == "move-page":
96
+ return move_page(
97
+ config,
98
+ spec.space.value,
99
+ spec.page_id.value,
100
+ target_id=spec.target_id.value,
101
+ target_title=spec.target_title.value,
102
+ position=spec.position.value,
103
+ )
104
+ if command == "add-label":
105
+ return add_label(config, spec.page_id.value, spec.label.value)
106
+ if command == "add-comment":
107
+ return add_comment(config, spec.page_id.value, spec.text.value)
108
+
109
+ # require-command = true makes a bare invocation unreachable.
110
+ raise SystemExit("no command — see `confluence --help`") # pragma: no cover
111
+
112
+
113
+ def main() -> None:
114
+ spec = rs.parse("confluence")
115
+ try:
116
+ config = config_from_env(env=spec.env.value)
117
+ result = _dispatch(spec, config)
118
+ except (ConfluenceError, ValueError) as exc:
119
+ _emit({"error": str(exc), "kind": type(exc).__name__})
120
+ sys.exit(1)
121
+ _emit(result)
122
+
123
+
124
+ if __name__ == "__main__":
125
+ main()
@@ -0,0 +1,166 @@
1
+ #:schema https://runspec.app/runspec.schema.json
2
+
3
+ # runspec-confluence — a Confluence runnable ported from the Atlassian MCP
4
+ # server (sooperset/mcp-atlassian), onto atlassian-python-api.
5
+ #
6
+ # One runnable, `confluence`, with a verb per tool. `runspec serve` re-expands
7
+ # each verb into its own leaf MCP tool (confluence_get-page, confluence_create-page,
8
+ # …), so a runspec-console agent calls them individually — over SSH on any fleet
9
+ # host, with no second server process.
10
+ #
11
+ # Cloud and Server/Data Center. Auth is env-var driven:
12
+ # CONFLUENCE_URL + (CONFLUENCE_PERSONAL_TOKEN | CONFLUENCE_USERNAME + CONFLUENCE_API_TOKEN)
13
+ # Per-environment overrides use a CONFLUENCE_<ENV>_ prefix, selected with the
14
+ # global --env (dev/uat/prod) arg. Cloud vs Server/DC is set by CONFLUENCE_CLOUD,
15
+ # else inferred from a .atlassian.net host.
16
+ #
17
+ # Reads are autonomous; writes require confirmation. Page bodies are passed in a
18
+ # Confluence representation via --content-format (storage XHTML by default, or
19
+ # wiki/editor). Markdown conversion is not done here.
20
+
21
+ [config]
22
+ autonomy-default = "confirm"
23
+
24
+ [config.logging]
25
+ store = "per-run"
26
+
27
+ [confluence]
28
+ description = "Read and manage Confluence pages, spaces, comments, and labels (Cloud or Server/Data Center)"
29
+ output = "json"
30
+ require-command = true
31
+ examples = [
32
+ {cmd = "confluence get-page --page-id 12345 --expand body.storage", description = "Fetch a page with its storage body"},
33
+ {cmd = "confluence search --cql 'space = DOCS AND text ~ onboarding'", description = "Search content with CQL"},
34
+ {cmd = "confluence create-page --space DOCS --title 'Runbook' --content '<p>Steps</p>'", description = "Create a page"},
35
+ {cmd = "confluence add-comment --page-id 12345 --text 'Reviewed'", description = "Comment on a page"},
36
+ ]
37
+
38
+ # ── global args (inherited by every verb) ──────────────────────────────────
39
+ [confluence.args]
40
+ env = {type = "choice", options = ["dev", "uat", "prod"], short = "-e", required = false, description = "Target environment (selects CONFLUENCE_<ENV>_* vars, falling back to CONFLUENCE_*)"}
41
+
42
+ # ── reads → autonomous ─────────────────────────────────────────────────────
43
+
44
+ [confluence.commands.search]
45
+ description = "Search content with CQL (Confluence Query Language); returns one page of results"
46
+ autonomy = "autonomous"
47
+ [confluence.commands.search.args]
48
+ cql = {type = "str", required = true, description = "CQL query, e.g. 'type = page AND space = DOCS AND text ~ \"onboarding\"'"}
49
+ limit = {type = "int", default = 25, description = "Max results"}
50
+ start = {type = "int", default = 0, description = "Pagination offset"}
51
+ expand = {type = "str", required = false, description = "Comma-separated expand sections"}
52
+
53
+ [confluence.commands.get-page]
54
+ description = "Fetch a page by id, or by space + title"
55
+ autonomy = "autonomous"
56
+ [confluence.commands.get-page.args]
57
+ page-id = {type = "str", required = false, description = "Page id"}
58
+ space = {type = "str", required = false, description = "Space key (with --title; falls back to CONFLUENCE_SPACE)"}
59
+ title = {type = "str", required = false, description = "Page title (with --space)"}
60
+ expand = {type = "str", required = false, description = "Comma-separated expand sections, e.g. body.storage,version"}
61
+
62
+ [confluence.commands.get-page-children]
63
+ description = "List a page's immediate child pages"
64
+ autonomy = "autonomous"
65
+ [confluence.commands.get-page-children.args]
66
+ page-id = {type = "str", required = true, description = "Parent page id"}
67
+
68
+ [confluence.commands.get-page-ancestors]
69
+ description = "List a page's ancestor pages (breadcrumb, root first)"
70
+ autonomy = "autonomous"
71
+ [confluence.commands.get-page-ancestors.args]
72
+ page-id = {type = "str", required = true, description = "Page id"}
73
+
74
+ [confluence.commands.get-page-history]
75
+ description = "Return a page's content history (created/last-updated, versions)"
76
+ autonomy = "autonomous"
77
+ [confluence.commands.get-page-history.args]
78
+ page-id = {type = "str", required = true, description = "Page id"}
79
+
80
+ [confluence.commands.get-labels]
81
+ description = "List a page's labels"
82
+ autonomy = "autonomous"
83
+ [confluence.commands.get-labels.args]
84
+ page-id = {type = "str", required = true, description = "Page id"}
85
+
86
+ [confluence.commands.get-comments]
87
+ description = "List a page's comments (rendered bodies)"
88
+ autonomy = "autonomous"
89
+ [confluence.commands.get-comments.args]
90
+ page-id = {type = "str", required = true, description = "Page id"}
91
+ limit = {type = "int", default = 25, description = "Max comments"}
92
+
93
+ [confluence.commands.get-page-restrictions]
94
+ description = "Return the view/edit restrictions on a page"
95
+ autonomy = "autonomous"
96
+ [confluence.commands.get-page-restrictions.args]
97
+ page-id = {type = "str", required = true, description = "Page id"}
98
+
99
+ [confluence.commands.get-space]
100
+ description = "Fetch a space's metadata"
101
+ autonomy = "autonomous"
102
+ [confluence.commands.get-space.args]
103
+ space-key = {type = "str", required = true, description = "Space key"}
104
+
105
+ [confluence.commands.get-space-pages]
106
+ description = "List pages in a space (one page of results)"
107
+ autonomy = "autonomous"
108
+ [confluence.commands.get-space-pages.args]
109
+ space = {type = "str", required = true, description = "Space key"}
110
+ limit = {type = "int", default = 50, description = "Max results"}
111
+ start = {type = "int", default = 0, description = "Pagination offset"}
112
+
113
+ # ── writes → confirm ───────────────────────────────────────────────────────
114
+
115
+ [confluence.commands.create-page]
116
+ description = "Create a page. --content is in the --content-format representation."
117
+ autonomy = "confirm"
118
+ [confluence.commands.create-page.args]
119
+ space = {type = "str", required = true, description = "Space key"}
120
+ title = {type = "str", required = true, description = "Page title"}
121
+ content = {type = "str", required = true, description = "Page body in the --content-format representation"}
122
+ parent-id = {type = "str", required = false, description = "Parent page id"}
123
+ content-format = {type = "choice", options = ["storage", "wiki", "editor"], default = "storage", description = "Body representation (storage = Confluence XHTML)"}
124
+
125
+ [confluence.commands.update-page]
126
+ description = "Update a page. --title is required by the API even when only the body changes."
127
+ autonomy = "confirm"
128
+ [confluence.commands.update-page.args]
129
+ page-id = {type = "str", required = true, description = "Page id"}
130
+ title = {type = "str", required = true, description = "Page title (pass the current title if unchanged)"}
131
+ content = {type = "str", required = false, description = "New body in the --content-format representation"}
132
+ parent-id = {type = "str", required = false, description = "New parent page id"}
133
+ content-format = {type = "choice", options = ["storage", "wiki", "editor"], default = "storage", description = "Body representation"}
134
+ version-comment = {type = "str", required = false, description = "Version comment for the edit"}
135
+ minor-edit = {type = "flag", default = false, description = "Mark as a minor edit (no notifications)"}
136
+
137
+ [confluence.commands.delete-page]
138
+ description = "Delete a page (and, with --recursive, its descendants)"
139
+ autonomy = "confirm"
140
+ [confluence.commands.delete-page.args]
141
+ page-id = {type = "str", required = true, description = "Page id"}
142
+ recursive = {type = "flag", default = false, description = "Also delete descendant pages"}
143
+
144
+ [confluence.commands.move-page]
145
+ description = "Move a page relative to a target (append under it, or above/below)"
146
+ autonomy = "confirm"
147
+ [confluence.commands.move-page.args]
148
+ space = {type = "str", required = true, description = "Space key of the page"}
149
+ page-id = {type = "str", required = true, description = "Page id to move"}
150
+ target-id = {type = "str", required = false, description = "Target page id"}
151
+ target-title = {type = "str", required = false, description = "Target page title (alternative to --target-id)"}
152
+ position = {type = "choice", options = ["append", "above", "below"], default = "append", description = "Placement relative to the target"}
153
+
154
+ [confluence.commands.add-label]
155
+ description = "Add a label to a page"
156
+ autonomy = "confirm"
157
+ [confluence.commands.add-label.args]
158
+ page-id = {type = "str", required = true, description = "Page id"}
159
+ label = {type = "str", required = true, description = "Label to add"}
160
+
161
+ [confluence.commands.add-comment]
162
+ description = "Add a comment to a page"
163
+ autonomy = "confirm"
164
+ [confluence.commands.add-comment.args]
165
+ page-id = {type = "str", required = true, description = "Page id"}
166
+ text = {type = "str", required = true, description = "Comment text"}
File without changes
@@ -0,0 +1,102 @@
1
+ """Wrapper logic: real parsed spec → `_dispatch` → core helper, with the core's
2
+ atlassian Confluence client faked, plus the main() error path."""
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import pytest
9
+ from runspec.testing import ParseHarness
10
+ from runspec_confluence_core import ConfluenceConfig
11
+ from runspec_confluence_core import client as core_client
12
+
13
+ from runspec_confluence import cli
14
+
15
+ SPEC = Path(__file__).parent.parent / "runspec_confluence" / "runspec.toml"
16
+ CFG = ConfluenceConfig(url="https://acme.atlassian.net/wiki", cloud=True, username="me@x.com", api_token="tok", space="DOCS")
17
+
18
+
19
+ class FakeClient:
20
+ def __init__(self, returns: dict[str, Any] | None = None):
21
+ self.calls: list[tuple[str, tuple, dict]] = []
22
+ self._returns = returns or {}
23
+
24
+ def __getattr__(self, name: str):
25
+ def method(*args: Any, **kwargs: Any) -> Any:
26
+ self.calls.append((name, args, kwargs))
27
+ return self._returns.get(name)
28
+
29
+ return method
30
+
31
+ def last(self, name: str) -> tuple[tuple, dict]:
32
+ for called, args, kwargs in reversed(self.calls):
33
+ if called == name:
34
+ return args, kwargs
35
+ raise AssertionError(f"{name} not called; saw {[c[0] for c in self.calls]}")
36
+
37
+
38
+ @pytest.fixture
39
+ def fake(monkeypatch):
40
+ client = FakeClient()
41
+ monkeypatch.setattr(core_client, "Confluence", lambda **kw: client)
42
+ return client
43
+
44
+
45
+ def _harness() -> ParseHarness:
46
+ return ParseHarness(script_name="confluence", config_path=SPEC)
47
+
48
+
49
+ def test_dispatch_get_page_by_id(fake) -> None:
50
+ fake._returns["get_page_by_id"] = {"id": "1"}
51
+ h = _harness()
52
+ spec = h.expect_ok(["get-page", "--page-id", "1", "--expand", "body.storage"])
53
+ assert cli._dispatch(spec, CFG) == {"id": "1"}
54
+ h.close()
55
+ args, kwargs = fake.last("get_page_by_id")
56
+ assert args == ("1",)
57
+ assert kwargs["expand"] == "body.storage"
58
+
59
+
60
+ def test_dispatch_create_page_representation(fake) -> None:
61
+ h = _harness()
62
+ spec = h.expect_ok(["create-page", "--space", "DOCS", "--title", "New", "--content", "<p>x</p>", "--content-format", "wiki"])
63
+ cli._dispatch(spec, CFG)
64
+ h.close()
65
+ args, kwargs = fake.last("create_page")
66
+ assert args == ("DOCS", "New", "<p>x</p>")
67
+ assert kwargs["representation"] == "wiki"
68
+
69
+
70
+ def test_dispatch_search(fake) -> None:
71
+ fake._returns["cql"] = {"results": []}
72
+ h = _harness()
73
+ spec = h.expect_ok(["search", "--cql", "type = page", "--limit", "3"])
74
+ cli._dispatch(spec, CFG)
75
+ h.close()
76
+ args, kwargs = fake.last("cql")
77
+ assert args == ("type = page",)
78
+ assert kwargs["limit"] == 3
79
+
80
+
81
+ def test_dispatch_delete_page(fake) -> None:
82
+ h = _harness()
83
+ spec = h.expect_ok(["delete-page", "--page-id", "9", "--recursive"])
84
+ result = cli._dispatch(spec, CFG)
85
+ h.close()
86
+ assert result == {"page_id": "9", "deleted": True}
87
+ _, kwargs = fake.last("remove_page")
88
+ assert kwargs["recursive"] is True
89
+
90
+
91
+ def test_main_missing_config_emits_error(monkeypatch, capsys) -> None:
92
+ for var in ("CONFLUENCE_URL", "CONFLUENCE_USERNAME", "CONFLUENCE_API_TOKEN", "CONFLUENCE_PERSONAL_TOKEN", "CONFLUENCE_ENV"):
93
+ monkeypatch.delenv(var, raising=False)
94
+ h = _harness()
95
+ spec = h.expect_ok(["get-page", "--page-id", "1"])
96
+ h.close()
97
+ monkeypatch.setattr(cli.rs, "parse", lambda name: spec)
98
+ with pytest.raises(SystemExit) as exc:
99
+ cli.main()
100
+ assert exc.value.code == 1
101
+ payload = json.loads(capsys.readouterr().out)
102
+ assert payload["kind"] == "ConfluenceError"
@@ -0,0 +1,79 @@
1
+ """Wiring / parsing tests for the `confluence` 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_confluence" / "runspec.toml"
8
+
9
+ VALUES = {
10
+ "cql": "type = page",
11
+ "page-id": "12345",
12
+ "space": "DOCS",
13
+ "space-key": "DOCS",
14
+ "title": "Home",
15
+ "content": "<p>hi</p>",
16
+ "label": "runbook",
17
+ "text": "hello",
18
+ }
19
+
20
+
21
+ def _harness() -> ParseHarness:
22
+ return ParseHarness(script_name="confluence", config_path=SPEC)
23
+
24
+
25
+ def test_smoke_all_verbs() -> None:
26
+ h = _harness()
27
+ done = h.smoke(values=VALUES)
28
+ assert len(done) > 30
29
+ h.close()
30
+
31
+
32
+ def test_global_env_choice_inherited() -> None:
33
+ h = _harness()
34
+ spec = h.expect_ok(["--env", "prod", "get-page", "--page-id", "1"])
35
+ assert spec.env.value == "prod"
36
+ h.close()
37
+
38
+
39
+ def test_content_format_defaults_storage() -> None:
40
+ h = _harness()
41
+ spec = h.expect_ok(["create-page", "--space", "DOCS", "--title", "T", "--content", "<p/>"])
42
+ assert spec.content_format.value == "storage"
43
+ h.close()
44
+
45
+
46
+ def test_bad_content_format_rejected() -> None:
47
+ h = _harness()
48
+ h.expect_exit(
49
+ ["create-page", "--space", "DOCS", "--title", "T", "--content", "x", "--content-format", "markdown"],
50
+ contains="content-format",
51
+ )
52
+ h.close()
53
+
54
+
55
+ def test_limit_coerces_int() -> None:
56
+ h = _harness()
57
+ spec = h.expect_ok(["search", "--cql", "type = page", "--limit", "7"])
58
+ assert spec.limit.value == 7
59
+ assert isinstance(spec.limit.value, int)
60
+ h.close()
61
+
62
+
63
+ def test_minor_edit_flag() -> None:
64
+ h = _harness()
65
+ spec = h.expect_ok(["update-page", "--page-id", "1", "--title", "T", "--minor-edit"])
66
+ assert spec.minor_edit.value is True
67
+ h.close()
68
+
69
+
70
+ def test_missing_required_cql_rejected() -> None:
71
+ h = _harness()
72
+ h.expect_exit(["search"], contains="cql")
73
+ h.close()
74
+
75
+
76
+ def test_require_command_enforced() -> None:
77
+ h = _harness()
78
+ h.expect_exit([])
79
+ h.close()