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.
- runspec_confluence-0.1.0/.gitignore +61 -0
- runspec_confluence-0.1.0/CHANGELOG.md +27 -0
- runspec_confluence-0.1.0/PKG-INFO +11 -0
- runspec_confluence-0.1.0/pyproject.toml +39 -0
- runspec_confluence-0.1.0/runspec_confluence/__init__.py +52 -0
- runspec_confluence-0.1.0/runspec_confluence/cli.py +125 -0
- runspec_confluence-0.1.0/runspec_confluence/runspec.toml +166 -0
- runspec_confluence-0.1.0/tests/__init__.py +0 -0
- runspec_confluence-0.1.0/tests/test_cli.py +102 -0
- runspec_confluence-0.1.0/tests/test_spec.py +79 -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,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()
|