confpub-cli 1.0.0__tar.gz → 1.2.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.
- confpub_cli-1.2.0/CLAUDE.md +56 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/PKG-INFO +1 -1
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/__init__.py +1 -1
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/applier.py +12 -4
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/cli.py +27 -42
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/confluence.py +47 -7
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/guide.py +74 -2
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/lockfile.py +19 -1
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/publish.py +10 -4
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/puller.py +1 -1
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/test_applier.py +21 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/test_confluence.py +66 -1
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/test_guide.py +61 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/test_integration.py +45 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/.github/workflows/publish.yml +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/.gitignore +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/LICENSE +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/PRD.md +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/README.md +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/assets.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/config.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/converter.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/envelope.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/errors.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/manifest.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/output.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/planner.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/py.typed +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/reverse_converter.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/validator.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub/verifier.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/confpub.lock +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/pyproject.toml +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/__init__.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/conftest.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/test_assets.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/test_config.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/test_converter.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/test_envelope.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/test_errors.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/test_lockfile.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/test_manifest.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/test_output.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/test_planner.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/test_publish.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/test_puller.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/test_reverse_converter.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/test_validator.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/tests/test_verifier.py +0 -0
- {confpub_cli-1.0.0 → confpub_cli-1.2.0}/uv.lock +0 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Build & Test Commands
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv pip install -e ".[dev]" # Install with dev deps
|
|
9
|
+
pytest tests/ -v # Run all tests
|
|
10
|
+
pytest tests/test_converter.py -v # Run one test module
|
|
11
|
+
pytest tests/test_converter.py::TestHeadings::test_h1 -v # Run single test
|
|
12
|
+
pytest tests/ -k "fingerprint" -v # Run tests matching name pattern
|
|
13
|
+
pytest tests/ -v --cov=confpub # Run with coverage
|
|
14
|
+
uvx hatch version minor # Bump version (patch/minor/major)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
No linter is configured. Python 3.10+ required.
|
|
18
|
+
|
|
19
|
+
## Architecture
|
|
20
|
+
|
|
21
|
+
Agent-first CLI for publishing Markdown to Confluence. Every command returns structured JSON on stdout; stderr is reserved for progress/diagnostics.
|
|
22
|
+
|
|
23
|
+
### Command flow
|
|
24
|
+
|
|
25
|
+
All commands follow the same pattern through `cli.py`:
|
|
26
|
+
|
|
27
|
+
1. Typer handler receives flags
|
|
28
|
+
2. `command_context()` context manager wraps execution — catches `ConfpubError` and unexpected exceptions, records timing, emits the JSON envelope via `Envelope` (envelope.py)
|
|
29
|
+
3. Handler delegates to a domain module (publish.py, applier.py, puller.py, etc.)
|
|
30
|
+
4. Domain module calls `ConfluenceClient` (confluence.py) which wraps atlassian-python-api and translates its exceptions into `ConfpubError`
|
|
31
|
+
|
|
32
|
+
### Key design rules
|
|
33
|
+
|
|
34
|
+
- **stdout is JSON-only** — one envelope object per invocation, never mixed with text
|
|
35
|
+
- **Error codes are stable** — prefixed by category (`ERR_VALIDATION_*`, `ERR_AUTH_*`, `ERR_CONFLICT_*`, `ERR_IO_*`, `ERR_INTERNAL_*`), each maps to a fixed exit code via prefix (10/20/40/50/90). Never rename or remove error codes without a major version bump.
|
|
36
|
+
- **`ConfpubError`** carries code, message, retryable flag, suggested_action, and details dict — all fields flow into the envelope's `errors` array
|
|
37
|
+
- **Lockfile** (`confpub.lock`) maps page titles to Confluence page IDs for idempotent re-publishing. Updated by publish, pull, apply, and delete. Uses atomic writes (tempfile + rename).
|
|
38
|
+
|
|
39
|
+
### Transactional workflow
|
|
40
|
+
|
|
41
|
+
`plan create` → `plan validate` → `plan apply` → `plan verify` — each step is a separate command with no side effects except apply. Fingerprinting (SHA-256 of storage-format body) detects external edits between plan creation and apply.
|
|
42
|
+
|
|
43
|
+
### Markdown conversion
|
|
44
|
+
|
|
45
|
+
`converter.py` uses markdown-it-py with a custom `ConfluenceRenderer` to produce Confluence Storage Format XHTML. `reverse_converter.py` does the inverse using BeautifulSoup4 + markdownify. Both are pure functions (no I/O, no network).
|
|
46
|
+
|
|
47
|
+
### Test conventions
|
|
48
|
+
|
|
49
|
+
- Tests use `typer.testing.CliRunner` via `run_cli` fixture (conftest.py)
|
|
50
|
+
- Confluence API calls are mocked with `unittest.mock` — no live integration tests
|
|
51
|
+
- Test classes group related cases (e.g., `TestHeadings`, `TestApplyPlanReal`)
|
|
52
|
+
- New domain functions get unit tests; CLI-level behavior gets integration tests in `test_integration.py`
|
|
53
|
+
|
|
54
|
+
## Version
|
|
55
|
+
|
|
56
|
+
Defined in `confpub/__init__.py`, read by hatch from `pyproject.toml` config. Bump with `uvx hatch version`. Push to `main` triggers GitHub Actions publish to PyPI.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: confpub-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Agent-first CLI to publish Markdown to Confluence
|
|
5
5
|
Project-URL: Homepage, https://github.com/ThomasRohde/confpub-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/ThomasRohde/confpub-cli.git
|
|
@@ -13,7 +13,7 @@ from typing import Any
|
|
|
13
13
|
|
|
14
14
|
from confpub.assets import AssetRef, discover_assets, rewrite_image_urls, upload_assets
|
|
15
15
|
from confpub.config import load_config
|
|
16
|
-
from confpub.confluence import ConfluenceClient
|
|
16
|
+
from confpub.confluence import ConfluenceClient, build_page_url
|
|
17
17
|
from confpub.converter import convert_markdown, fingerprint_content
|
|
18
18
|
from confpub.errors import ERR_CONFLICT_FINGERPRINT, ERR_IO_FILE_NOT_FOUND, ConfpubError
|
|
19
19
|
from confpub.lockfile import Lockfile, load_lockfile, save_lockfile, update_lockfile
|
|
@@ -113,6 +113,10 @@ def apply_plan(
|
|
|
113
113
|
new_version = new_version.get("number", 1)
|
|
114
114
|
change["after"]["page_id"] = new_id
|
|
115
115
|
change["after"]["version"] = new_version
|
|
116
|
+
change["after"]["webui"] = build_page_url(
|
|
117
|
+
config.base_url or "", config.is_cloud,
|
|
118
|
+
plan.space, new_id, page.title,
|
|
119
|
+
)
|
|
116
120
|
|
|
117
121
|
# Upload attachments
|
|
118
122
|
if assets:
|
|
@@ -125,8 +129,7 @@ def apply_plan(
|
|
|
125
129
|
counts["attachments_upload"] += len(assets)
|
|
126
130
|
|
|
127
131
|
# Update lockfile and parent tracking
|
|
128
|
-
update_lockfile(lockfile, page.title, new_id, new_version if isinstance(new_version, int) else 1)
|
|
129
|
-
lockfile.pages[page.title].content_fingerprint = fingerprint_content(storage)
|
|
132
|
+
update_lockfile(lockfile, page.title, new_id, new_version if isinstance(new_version, int) else 1, content_fingerprint=fingerprint_content(storage))
|
|
130
133
|
parent_ids[page.title] = new_id
|
|
131
134
|
|
|
132
135
|
counts["create"] += 1
|
|
@@ -168,6 +171,10 @@ def apply_plan(
|
|
|
168
171
|
if isinstance(new_version, dict):
|
|
169
172
|
new_version = new_version.get("number", (before_version or 0) + 1)
|
|
170
173
|
change["after"]["version"] = new_version
|
|
174
|
+
change["after"]["webui"] = build_page_url(
|
|
175
|
+
config.base_url or "", config.is_cloud,
|
|
176
|
+
plan.space, page.confluence_page_id, page.title,
|
|
177
|
+
)
|
|
171
178
|
|
|
172
179
|
# Upload attachments
|
|
173
180
|
if assets:
|
|
@@ -180,8 +187,8 @@ def apply_plan(
|
|
|
180
187
|
update_lockfile(
|
|
181
188
|
lockfile, page.title, page.confluence_page_id,
|
|
182
189
|
new_version if isinstance(new_version, int) else 1,
|
|
190
|
+
content_fingerprint=fingerprint_content(storage),
|
|
183
191
|
)
|
|
184
|
-
lockfile.pages[page.title].content_fingerprint = fingerprint_content(storage)
|
|
185
192
|
parent_ids[page.title] = page.confluence_page_id
|
|
186
193
|
|
|
187
194
|
counts["update"] += 1
|
|
@@ -196,4 +203,5 @@ def apply_plan(
|
|
|
196
203
|
"changes": changes,
|
|
197
204
|
"summary": counts,
|
|
198
205
|
"lockfile_updated": not dry_run and len(changes) > 0,
|
|
206
|
+
"lockfile_path": str(lockfile_path) if not dry_run else None,
|
|
199
207
|
}
|
|
@@ -22,12 +22,22 @@ from confpub.output import emit_stderr, emit_stdout, is_verbose, set_quiet, set_
|
|
|
22
22
|
# Subcommand group apps
|
|
23
23
|
# ---------------------------------------------------------------------------
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
|
|
26
|
+
def _group_callback(
|
|
27
|
+
quiet: bool = typer.Option(False, "--quiet", help="Suppress progress output on stderr"),
|
|
28
|
+
verbose: bool = typer.Option(False, "--verbose", help="Include diagnostics in result"),
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Allow --quiet/--verbose between the group name and the subcommand."""
|
|
31
|
+
set_quiet(quiet)
|
|
32
|
+
set_verbose(verbose)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
page_app = typer.Typer(help="Page operations", callback=_group_callback)
|
|
36
|
+
plan_app = typer.Typer(help="Transactional plan workflow", callback=_group_callback)
|
|
37
|
+
auth_app = typer.Typer(help="Authentication", callback=_group_callback)
|
|
38
|
+
config_app = typer.Typer(help="Configuration", callback=_group_callback)
|
|
39
|
+
space_app = typer.Typer(help="Space operations", callback=_group_callback)
|
|
40
|
+
attachment_app = typer.Typer(help="Attachment operations", callback=_group_callback)
|
|
31
41
|
|
|
32
42
|
# ---------------------------------------------------------------------------
|
|
33
43
|
# Main app
|
|
@@ -179,7 +189,7 @@ def page_list(
|
|
|
179
189
|
from confpub.confluence import build_client, _slim_page
|
|
180
190
|
client = build_client()
|
|
181
191
|
pages = client.list_pages(space, start=start, limit=limit)
|
|
182
|
-
ctx.result = {"pages": [_slim_page(p, base_url=client._config.base_url.rstrip("/")) for p in pages]}
|
|
192
|
+
ctx.result = {"pages": [_slim_page(p, base_url=client._config.base_url.rstrip("/"), is_cloud=client._config.is_cloud) for p in pages]}
|
|
183
193
|
|
|
184
194
|
|
|
185
195
|
@page_app.command("inspect")
|
|
@@ -207,7 +217,7 @@ def page_inspect(
|
|
|
207
217
|
if raw:
|
|
208
218
|
ctx.result = page
|
|
209
219
|
else:
|
|
210
|
-
result = _slim_page(page, base_url=client._config.base_url.rstrip("/"))
|
|
220
|
+
result = _slim_page(page, base_url=client._config.base_url.rstrip("/"), is_cloud=client._config.is_cloud)
|
|
211
221
|
if format == "markdown" and "body_storage" in result:
|
|
212
222
|
from confpub.reverse_converter import convert_storage_to_markdown
|
|
213
223
|
conversion = convert_storage_to_markdown(result["body_storage"])
|
|
@@ -237,12 +247,6 @@ def page_publish(
|
|
|
237
247
|
if page_id:
|
|
238
248
|
target["page_id"] = page_id
|
|
239
249
|
with command_context("page.publish", target=target) as ctx:
|
|
240
|
-
from pathlib import Path as _Path
|
|
241
|
-
if not _Path(file).exists():
|
|
242
|
-
raise ConfpubError(
|
|
243
|
-
"ERR_IO_FILE_NOT_FOUND",
|
|
244
|
-
f"File not found: {file}",
|
|
245
|
-
)
|
|
246
250
|
if not page_id and not parent:
|
|
247
251
|
raise ConfpubError(
|
|
248
252
|
"ERR_VALIDATION_REQUIRED",
|
|
@@ -316,18 +320,11 @@ def page_delete(
|
|
|
316
320
|
from confpub.confluence import build_client
|
|
317
321
|
client = build_client()
|
|
318
322
|
|
|
319
|
-
# Collect
|
|
323
|
+
# Collect descendant IDs before deleting (for lockfile cleanup)
|
|
320
324
|
deleted_ids: set[str] = set()
|
|
321
325
|
if page_id:
|
|
322
326
|
if cascade:
|
|
323
|
-
|
|
324
|
-
def _collect_ids(pid: str) -> None:
|
|
325
|
-
children = client.get_page_children(pid)
|
|
326
|
-
for child in children:
|
|
327
|
-
cid = str(child["id"])
|
|
328
|
-
deleted_ids.add(cid)
|
|
329
|
-
_collect_ids(cid)
|
|
330
|
-
_collect_ids(page_id)
|
|
327
|
+
deleted_ids.update(client.get_descendant_ids(page_id))
|
|
331
328
|
client._delete_descendants(page_id)
|
|
332
329
|
deleted_ids.add(page_id)
|
|
333
330
|
result = client.delete_page(page_id)
|
|
@@ -336,33 +333,21 @@ def page_delete(
|
|
|
336
333
|
page = client.get_page(space, title)
|
|
337
334
|
if page:
|
|
338
335
|
pid = str(page["id"])
|
|
339
|
-
|
|
340
|
-
children = client.get_page_children(p)
|
|
341
|
-
for child in children:
|
|
342
|
-
cid = str(child["id"])
|
|
343
|
-
deleted_ids.add(cid)
|
|
344
|
-
_collect_ids_by_title(cid)
|
|
345
|
-
_collect_ids_by_title(pid)
|
|
336
|
+
deleted_ids.update(client.get_descendant_ids(pid))
|
|
346
337
|
deleted_ids.add(pid)
|
|
347
338
|
result = client.delete_page_by_title(space, title, cascade=cascade)
|
|
348
339
|
|
|
349
340
|
# Clean up lockfile entries for deleted pages
|
|
350
341
|
from pathlib import Path
|
|
351
|
-
from confpub.lockfile import
|
|
342
|
+
from confpub.lockfile import load_lockfile, save_lockfile, remove_by_page_ids
|
|
352
343
|
lockfile_path = Path.cwd() / "confpub.lock"
|
|
353
344
|
lockfile = load_lockfile(lockfile_path)
|
|
354
|
-
if lockfile:
|
|
355
|
-
|
|
356
|
-
if title and not page_id:
|
|
357
|
-
removed = remove_from_lockfile(lockfile, title) or removed
|
|
358
|
-
# Remove entries matching any deleted page ID
|
|
359
|
-
for lf_title, entry in list(lockfile.pages.items()):
|
|
360
|
-
if entry.page_id in deleted_ids:
|
|
361
|
-
remove_from_lockfile(lockfile, lf_title)
|
|
362
|
-
removed = True
|
|
363
|
-
if removed:
|
|
364
|
-
save_lockfile(lockfile_path, lockfile)
|
|
345
|
+
if lockfile and remove_by_page_ids(lockfile, deleted_ids, title=title if not page_id else None):
|
|
346
|
+
save_lockfile(lockfile_path, lockfile)
|
|
365
347
|
|
|
348
|
+
# Enrich result with deleted ID summary
|
|
349
|
+
result["deleted_ids"] = sorted(deleted_ids)
|
|
350
|
+
result["deleted_count"] = len(deleted_ids)
|
|
366
351
|
ctx.result = result
|
|
367
352
|
|
|
368
353
|
|
|
@@ -187,6 +187,16 @@ class ConfluenceClient:
|
|
|
187
187
|
self._delete_descendants(page_id)
|
|
188
188
|
return self.delete_page(page_id)
|
|
189
189
|
|
|
190
|
+
def get_descendant_ids(self, page_id: str) -> set[str]:
|
|
191
|
+
"""Recursively collect all descendant page IDs."""
|
|
192
|
+
ids: set[str] = set()
|
|
193
|
+
children = self.get_page_children(page_id)
|
|
194
|
+
for child in children:
|
|
195
|
+
child_id = str(child["id"])
|
|
196
|
+
ids.add(child_id)
|
|
197
|
+
ids.update(self.get_descendant_ids(child_id))
|
|
198
|
+
return ids
|
|
199
|
+
|
|
190
200
|
def _delete_descendants(self, page_id: str) -> None:
|
|
191
201
|
"""Recursively delete all descendants depth-first (leaves first)."""
|
|
192
202
|
children = self.get_page_children(page_id)
|
|
@@ -257,7 +267,8 @@ class ConfluenceClient:
|
|
|
257
267
|
raw = result
|
|
258
268
|
else:
|
|
259
269
|
raw = []
|
|
260
|
-
|
|
270
|
+
base_url = self._config.base_url.rstrip("/") if self._config.base_url else ""
|
|
271
|
+
return [_slim_space(s, base_url=base_url, is_cloud=self._config.is_cloud) for s in raw]
|
|
261
272
|
except Exception as exc:
|
|
262
273
|
self._handle_error(exc, "list_spaces")
|
|
263
274
|
return []
|
|
@@ -397,7 +408,7 @@ class ConfluenceClient:
|
|
|
397
408
|
api_limit = raw.get("limit", limit) if isinstance(raw, dict) else limit
|
|
398
409
|
|
|
399
410
|
results = [
|
|
400
|
-
_slim_search_result(r, base_url=base_url, excerpt_length=excerpt_length)
|
|
411
|
+
_slim_search_result(r, base_url=base_url, is_cloud=self._config.is_cloud, excerpt_length=excerpt_length)
|
|
401
412
|
for r in results_raw
|
|
402
413
|
]
|
|
403
414
|
return {
|
|
@@ -423,7 +434,30 @@ class ConfluenceClient:
|
|
|
423
434
|
return None
|
|
424
435
|
|
|
425
436
|
|
|
426
|
-
def
|
|
437
|
+
def _webui_base(base_url: str, is_cloud: bool) -> str:
|
|
438
|
+
"""Return the correct base URL for webui links.
|
|
439
|
+
|
|
440
|
+
Cloud instances need ``/wiki`` in the path; DC/Server do not.
|
|
441
|
+
Handles base_url configured with or without the ``/wiki`` suffix.
|
|
442
|
+
"""
|
|
443
|
+
base = base_url.rstrip("/")
|
|
444
|
+
if is_cloud and not base.endswith("/wiki"):
|
|
445
|
+
base += "/wiki"
|
|
446
|
+
return base
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def build_page_url(
|
|
450
|
+
base_url: str, is_cloud: bool, space: str, page_id: str, title: str = "",
|
|
451
|
+
) -> str:
|
|
452
|
+
"""Construct a full webui URL for a Confluence page."""
|
|
453
|
+
base = _webui_base(base_url, is_cloud)
|
|
454
|
+
if title:
|
|
455
|
+
encoded_title = title.replace(" ", "+")
|
|
456
|
+
return f"{base}/spaces/{space}/pages/{page_id}/{encoded_title}"
|
|
457
|
+
return f"{base}/spaces/{space}/pages/{page_id}"
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _slim_page(page: dict[str, Any], *, base_url: str = "", is_cloud: bool = False) -> dict[str, Any]:
|
|
427
461
|
"""Extract agent-relevant fields from a raw Confluence page object."""
|
|
428
462
|
result: dict[str, Any] = {
|
|
429
463
|
"id": page.get("id"),
|
|
@@ -446,12 +480,12 @@ def _slim_page(page: dict[str, Any], *, base_url: str = "") -> dict[str, Any]:
|
|
|
446
480
|
result["parent_title"] = parent.get("title")
|
|
447
481
|
links = page.get("_links", {})
|
|
448
482
|
if "webui" in links:
|
|
449
|
-
base = base_url
|
|
483
|
+
base = _webui_base(base_url, is_cloud) if base_url else links.get("base", "")
|
|
450
484
|
result["webui"] = base + links["webui"]
|
|
451
485
|
return result
|
|
452
486
|
|
|
453
487
|
|
|
454
|
-
def _slim_space(space: dict[str, Any]) -> dict[str, Any]:
|
|
488
|
+
def _slim_space(space: dict[str, Any], *, base_url: str = "", is_cloud: bool = False) -> dict[str, Any]:
|
|
455
489
|
"""Extract agent-relevant fields from a raw Confluence space object."""
|
|
456
490
|
result: dict[str, Any] = {
|
|
457
491
|
"id": space.get("id"),
|
|
@@ -468,7 +502,12 @@ def _slim_space(space: dict[str, Any]) -> dict[str, Any]:
|
|
|
468
502
|
result["description"] = value
|
|
469
503
|
links = space.get("_links", {})
|
|
470
504
|
if "webui" in links:
|
|
471
|
-
|
|
505
|
+
webui_path = links.get("webui", "")
|
|
506
|
+
if base_url and webui_path:
|
|
507
|
+
base = _webui_base(base_url, is_cloud)
|
|
508
|
+
result["webui"] = base + webui_path
|
|
509
|
+
else:
|
|
510
|
+
result["webui"] = webui_path
|
|
472
511
|
return result
|
|
473
512
|
|
|
474
513
|
|
|
@@ -501,6 +540,7 @@ def _slim_search_result(
|
|
|
501
540
|
item: dict[str, Any],
|
|
502
541
|
*,
|
|
503
542
|
base_url: str = "",
|
|
543
|
+
is_cloud: bool = False,
|
|
504
544
|
excerpt_length: int = 0,
|
|
505
545
|
) -> dict[str, Any]:
|
|
506
546
|
"""Extract agent-relevant fields from a raw Confluence search result.
|
|
@@ -526,7 +566,7 @@ def _slim_search_result(
|
|
|
526
566
|
|
|
527
567
|
webui = content.get("_links", {}).get("webui", "")
|
|
528
568
|
if webui:
|
|
529
|
-
_set("url", base_url + webui)
|
|
569
|
+
_set("url", _webui_base(base_url, is_cloud) + webui if base_url else webui)
|
|
530
570
|
|
|
531
571
|
_set("space_key", content.get("space", {}).get("key"))
|
|
532
572
|
|
|
@@ -66,7 +66,11 @@ def build_guide() -> dict[str, Any]:
|
|
|
66
66
|
"mutates": False,
|
|
67
67
|
"description": "Search Confluence content using CQL",
|
|
68
68
|
"flags": ["--cql", "--space", "--title", "--type", "--limit", "--start", "--include-archived", "--excerpt-length"],
|
|
69
|
-
"agent_hint":
|
|
69
|
+
"agent_hint": (
|
|
70
|
+
"Most agent workflows should include --type page to exclude attachments and space entities from results. "
|
|
71
|
+
"Use --start and --limit for pagination: first call with --start 0 --limit 25, "
|
|
72
|
+
"then if has_more is true, call again with --start 25 --limit 25, and so on."
|
|
73
|
+
),
|
|
70
74
|
"result_schema": {
|
|
71
75
|
"cql_query": "string — effective CQL sent to the API",
|
|
72
76
|
"results": "list of {id, type, title, excerpt, url, space_key, entity_type, status, last_modified, container_title}",
|
|
@@ -80,6 +84,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
80
84
|
"confpub search --space DEV --type page --limit 10",
|
|
81
85
|
'confpub search --space DEV --cql \'title ~ "deploy"\'',
|
|
82
86
|
'confpub search --title "deploy guide" --space DEV',
|
|
87
|
+
"confpub search --space DEV --type page --start 0 --limit 50",
|
|
83
88
|
],
|
|
84
89
|
},
|
|
85
90
|
"page.list": {
|
|
@@ -92,13 +97,36 @@ def build_guide() -> dict[str, Any]:
|
|
|
92
97
|
"group": "read",
|
|
93
98
|
"mutates": False,
|
|
94
99
|
"description": "Inspect a Confluence page",
|
|
95
|
-
"flags": ["--space", "--title", "--page-id", "--format"],
|
|
100
|
+
"flags": ["--space", "--title", "--page-id", "--format", "--raw"],
|
|
101
|
+
"agent_hint": (
|
|
102
|
+
"Use --format markdown to get the page body as Markdown instead of Confluence storage format. "
|
|
103
|
+
"Use --raw for the full unprocessed API response (useful for debugging)."
|
|
104
|
+
),
|
|
105
|
+
"result_schema": {
|
|
106
|
+
"page_id": "string",
|
|
107
|
+
"title": "string",
|
|
108
|
+
"space_key": "string",
|
|
109
|
+
"version": "int",
|
|
110
|
+
"url": "string",
|
|
111
|
+
"body_storage": "string (when --format storage, the default)",
|
|
112
|
+
"body_markdown": "string (when --format markdown)",
|
|
113
|
+
},
|
|
114
|
+
"examples": [
|
|
115
|
+
'confpub page inspect --space DEV --title "My Page"',
|
|
116
|
+
"confpub page inspect --page-id 12345 --format markdown",
|
|
117
|
+
"confpub page inspect --page-id 12345 --raw",
|
|
118
|
+
],
|
|
96
119
|
},
|
|
97
120
|
"page.publish": {
|
|
98
121
|
"group": "write",
|
|
99
122
|
"mutates": True,
|
|
100
123
|
"description": "Publish a single Markdown file to Confluence",
|
|
101
124
|
"flags": ["--space", "--parent", "--title", "--page-id", "--dry-run", "--backup"],
|
|
125
|
+
"agent_hint": (
|
|
126
|
+
"When --title is omitted, the title is inferred from the filename: "
|
|
127
|
+
"the stem is extracted, hyphens and underscores are replaced with spaces, "
|
|
128
|
+
"and the result is title-cased. E.g. 'my-cool-page.md' → 'My Cool Page'."
|
|
129
|
+
),
|
|
102
130
|
},
|
|
103
131
|
"page.pull": {
|
|
104
132
|
"group": "read",
|
|
@@ -112,6 +140,11 @@ def build_guide() -> dict[str, Any]:
|
|
|
112
140
|
"safety_flags": {
|
|
113
141
|
"--force": "Overwrites existing local files without confirmation",
|
|
114
142
|
},
|
|
143
|
+
"agent_hint": (
|
|
144
|
+
"During recursive pulls, NDJSON progress events are emitted on stderr "
|
|
145
|
+
"with step (running discovery count) and total (0 until known). "
|
|
146
|
+
"Use --quiet to suppress."
|
|
147
|
+
),
|
|
115
148
|
},
|
|
116
149
|
"page.delete": {
|
|
117
150
|
"group": "write",
|
|
@@ -121,6 +154,10 @@ def build_guide() -> dict[str, Any]:
|
|
|
121
154
|
"safety_flags": {
|
|
122
155
|
"--cascade": "Also deletes child pages",
|
|
123
156
|
},
|
|
157
|
+
"result_schema": {
|
|
158
|
+
"deleted_ids": "list of string — sorted page IDs that were deleted",
|
|
159
|
+
"deleted_count": "int — number of pages deleted (including children when --cascade)",
|
|
160
|
+
},
|
|
124
161
|
},
|
|
125
162
|
"space.list": {
|
|
126
163
|
"group": "read",
|
|
@@ -167,6 +204,13 @@ def build_guide() -> dict[str, Any]:
|
|
|
167
204
|
),
|
|
168
205
|
"--cascade": "Allows deletes that affect child pages",
|
|
169
206
|
},
|
|
207
|
+
"result_schema": {
|
|
208
|
+
"dry_run": "bool",
|
|
209
|
+
"changes": "list of change records",
|
|
210
|
+
"summary": "{create: int, update: int, attachments_upload: int}",
|
|
211
|
+
"lockfile_updated": "bool — true if lockfile was written",
|
|
212
|
+
"lockfile_path": "string | null — absolute path to lockfile (null on dry-run)",
|
|
213
|
+
},
|
|
170
214
|
},
|
|
171
215
|
"plan.verify": {
|
|
172
216
|
"group": "transactional",
|
|
@@ -185,6 +229,16 @@ def build_guide() -> dict[str, Any]:
|
|
|
185
229
|
"mutates": True,
|
|
186
230
|
"description": "Set a configuration value",
|
|
187
231
|
"flags": [],
|
|
232
|
+
"args": ["KEY", "VALUE"],
|
|
233
|
+
"agent_hint": (
|
|
234
|
+
"Valid keys: base_url, user, token. "
|
|
235
|
+
"Values are persisted to the config file (~/.confpub/config.json)."
|
|
236
|
+
),
|
|
237
|
+
"examples": [
|
|
238
|
+
"confpub config set base_url https://mysite.atlassian.net/wiki",
|
|
239
|
+
"confpub config set user alice@example.com",
|
|
240
|
+
"confpub config set token ATATT...",
|
|
241
|
+
],
|
|
188
242
|
},
|
|
189
243
|
"config.inspect": {
|
|
190
244
|
"group": "config",
|
|
@@ -216,6 +270,19 @@ def build_guide() -> dict[str, Any]:
|
|
|
216
270
|
ERR_INTERNAL_REVERSE_CONVERTER: _error_code_entry(ERR_INTERNAL_REVERSE_CONVERTER),
|
|
217
271
|
ERR_INTERNAL_SDK: _error_code_entry(ERR_INTERNAL_SDK),
|
|
218
272
|
},
|
|
273
|
+
"global_flags": {
|
|
274
|
+
"description": "Flags that can be placed at the top level or between group name and subcommand.",
|
|
275
|
+
"flags": {
|
|
276
|
+
"--quiet": "Suppress progress output on stderr",
|
|
277
|
+
"--verbose": "Include diagnostics in result",
|
|
278
|
+
"--version": "Show version and exit (top-level only)",
|
|
279
|
+
},
|
|
280
|
+
"placement": [
|
|
281
|
+
"confpub --quiet page publish ... (before the group)",
|
|
282
|
+
"confpub page --quiet publish ... (between group and command)",
|
|
283
|
+
"Both positions are equivalent; the flag is parsed by the group callback",
|
|
284
|
+
],
|
|
285
|
+
},
|
|
219
286
|
"concurrency": {
|
|
220
287
|
"rule": (
|
|
221
288
|
"Never run multiple write commands against the same "
|
|
@@ -248,6 +315,11 @@ def build_guide() -> dict[str, Any]:
|
|
|
248
315
|
"Written atomically (temp file + rename) for crash safety",
|
|
249
316
|
"Used by plan.create to detect existing pages and versions",
|
|
250
317
|
"Does not prevent concurrent operations — purely local state tracking",
|
|
318
|
+
(
|
|
319
|
+
"Path resolution: page.publish and page.delete use CWD/confpub.lock; "
|
|
320
|
+
"page.pull uses <output-dir>/confpub.lock; "
|
|
321
|
+
"plan.apply uses <plan-dir>/confpub.lock (same directory as the plan artifact)"
|
|
322
|
+
),
|
|
251
323
|
],
|
|
252
324
|
},
|
|
253
325
|
"assertions": {
|
|
@@ -86,9 +86,12 @@ def save_lockfile(path: str | Path, lockfile: Lockfile) -> None:
|
|
|
86
86
|
|
|
87
87
|
def update_lockfile(
|
|
88
88
|
lockfile: Lockfile, title: str, page_id: str, version: int,
|
|
89
|
+
content_fingerprint: str | None = None,
|
|
89
90
|
) -> Lockfile:
|
|
90
91
|
"""Update a page entry in the lockfile."""
|
|
91
|
-
lockfile.pages[title] = LockPageEntry(
|
|
92
|
+
lockfile.pages[title] = LockPageEntry(
|
|
93
|
+
page_id=page_id, version=version, content_fingerprint=content_fingerprint,
|
|
94
|
+
)
|
|
92
95
|
return lockfile
|
|
93
96
|
|
|
94
97
|
|
|
@@ -101,3 +104,18 @@ def remove_from_lockfile(lockfile: Lockfile, title: str) -> bool:
|
|
|
101
104
|
del lockfile.pages[title]
|
|
102
105
|
return True
|
|
103
106
|
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def remove_by_page_ids(lockfile: Lockfile, page_ids: set[str], title: str | None = None) -> bool:
|
|
110
|
+
"""Remove lockfile entries matching any of the given page IDs, plus an optional title.
|
|
111
|
+
|
|
112
|
+
Returns True if any entries were removed.
|
|
113
|
+
"""
|
|
114
|
+
removed = False
|
|
115
|
+
if title:
|
|
116
|
+
removed = remove_from_lockfile(lockfile, title) or removed
|
|
117
|
+
for lf_title, entry in list(lockfile.pages.items()):
|
|
118
|
+
if entry.page_id in page_ids:
|
|
119
|
+
del lockfile.pages[lf_title]
|
|
120
|
+
removed = True
|
|
121
|
+
return removed
|
|
@@ -12,7 +12,7 @@ from typing import Any
|
|
|
12
12
|
|
|
13
13
|
from confpub.assets import discover_assets, rewrite_image_urls, upload_assets
|
|
14
14
|
from confpub.config import load_config
|
|
15
|
-
from confpub.confluence import ConfluenceClient
|
|
15
|
+
from confpub.confluence import ConfluenceClient, build_page_url
|
|
16
16
|
from confpub.converter import convert_markdown, fingerprint_content
|
|
17
17
|
from confpub.errors import (
|
|
18
18
|
ERR_IO_FILE_NOT_FOUND,
|
|
@@ -194,8 +194,7 @@ def publish_page(
|
|
|
194
194
|
|
|
195
195
|
# Update lockfile
|
|
196
196
|
new_version_int = new_version if isinstance(new_version, int) else 1
|
|
197
|
-
update_lockfile(lockfile, page_title, page_id, new_version_int)
|
|
198
|
-
lockfile.pages[page_title].content_fingerprint = local_fingerprint
|
|
197
|
+
update_lockfile(lockfile, page_title, page_id, new_version_int, content_fingerprint=local_fingerprint)
|
|
199
198
|
save_lockfile(lockfile_path, lockfile)
|
|
200
199
|
|
|
201
200
|
change = {
|
|
@@ -203,7 +202,14 @@ def publish_page(
|
|
|
203
202
|
"title": page_title,
|
|
204
203
|
"confluence_page_id": page_id,
|
|
205
204
|
"before": {"version": current_version} if current_version else None,
|
|
206
|
-
"after": {
|
|
205
|
+
"after": {
|
|
206
|
+
"version": new_version,
|
|
207
|
+
"page_id": page_id,
|
|
208
|
+
"webui": build_page_url(
|
|
209
|
+
config.base_url or "", config.is_cloud,
|
|
210
|
+
space, page_id, page_title,
|
|
211
|
+
),
|
|
212
|
+
},
|
|
207
213
|
}
|
|
208
214
|
if backup_file_path:
|
|
209
215
|
change["backup_path"] = backup_file_path
|
|
@@ -47,7 +47,7 @@ def _collect_tree(
|
|
|
47
47
|
def _walk(pid: str, parent_id: str | None) -> None:
|
|
48
48
|
children = client.get_page_children_deep(pid)
|
|
49
49
|
if children:
|
|
50
|
-
emit_progress(
|
|
50
|
+
emit_progress(len(pages) + 1, 0, f"Found {len(children)} child page(s) under {pid}")
|
|
51
51
|
for child in children:
|
|
52
52
|
child_id = str(child["id"])
|
|
53
53
|
pages.append({
|
|
@@ -163,6 +163,27 @@ class TestApplyLockfileFingerprints:
|
|
|
163
163
|
assert result["lockfile_updated"] is False
|
|
164
164
|
|
|
165
165
|
|
|
166
|
+
class TestApplyLockfilePath:
|
|
167
|
+
@patch("confpub.applier.load_config")
|
|
168
|
+
@patch("confpub.applier.ConfluenceClient")
|
|
169
|
+
def test_lockfile_path_present_on_real_apply(self, MockClient, mock_config, plan_dir, mock_client):
|
|
170
|
+
MockClient.return_value = mock_client
|
|
171
|
+
mock_config.return_value = MagicMock()
|
|
172
|
+
|
|
173
|
+
result = apply_plan(str(plan_dir / "plan.json"), dry_run=False)
|
|
174
|
+
assert result["lockfile_path"] is not None
|
|
175
|
+
assert "confpub.lock" in result["lockfile_path"]
|
|
176
|
+
|
|
177
|
+
@patch("confpub.applier.load_config")
|
|
178
|
+
@patch("confpub.applier.ConfluenceClient")
|
|
179
|
+
def test_lockfile_path_null_on_dry_run(self, MockClient, mock_config, plan_dir, mock_client):
|
|
180
|
+
MockClient.return_value = mock_client
|
|
181
|
+
mock_config.return_value = MagicMock()
|
|
182
|
+
|
|
183
|
+
result = apply_plan(str(plan_dir / "plan.json"), dry_run=True)
|
|
184
|
+
assert result["lockfile_path"] is None
|
|
185
|
+
|
|
186
|
+
|
|
166
187
|
class TestFingerprintCheck:
|
|
167
188
|
@patch("confpub.applier.load_config")
|
|
168
189
|
@patch("confpub.applier.ConfluenceClient")
|
|
@@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
|
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
7
|
from confpub.config import ResolvedConfig
|
|
8
|
-
from confpub.confluence import ConfluenceClient, _slim_page, _slim_search_result
|
|
8
|
+
from confpub.confluence import ConfluenceClient, _slim_page, _slim_search_result, _webui_base, build_page_url
|
|
9
9
|
from confpub.errors import (
|
|
10
10
|
ERR_AUTH_FORBIDDEN,
|
|
11
11
|
ERR_AUTH_REQUIRED,
|
|
@@ -313,6 +313,36 @@ class TestSlimPage:
|
|
|
313
313
|
result = _slim_page(page)
|
|
314
314
|
assert result["webui"] == "https://wiki.example.com/spaces/DEV/pages/1"
|
|
315
315
|
|
|
316
|
+
def test_cloud_webui_adds_wiki_prefix(self):
|
|
317
|
+
"""Cloud URLs need /wiki between base and webui path."""
|
|
318
|
+
page = {
|
|
319
|
+
"id": "1",
|
|
320
|
+
"title": "T",
|
|
321
|
+
"_links": {"webui": "/spaces/SD/pages/98376/What+Works+Well"},
|
|
322
|
+
}
|
|
323
|
+
result = _slim_page(page, base_url="https://myorg.atlassian.net", is_cloud=True)
|
|
324
|
+
assert result["webui"] == "https://myorg.atlassian.net/wiki/spaces/SD/pages/98376/What+Works+Well"
|
|
325
|
+
|
|
326
|
+
def test_cloud_webui_no_double_wiki(self):
|
|
327
|
+
"""If base_url already ends with /wiki, don't duplicate it."""
|
|
328
|
+
page = {
|
|
329
|
+
"id": "1",
|
|
330
|
+
"title": "T",
|
|
331
|
+
"_links": {"webui": "/spaces/SD/pages/1"},
|
|
332
|
+
}
|
|
333
|
+
result = _slim_page(page, base_url="https://myorg.atlassian.net/wiki", is_cloud=True)
|
|
334
|
+
assert result["webui"] == "https://myorg.atlassian.net/wiki/spaces/SD/pages/1"
|
|
335
|
+
|
|
336
|
+
def test_dc_webui_no_wiki_prefix(self):
|
|
337
|
+
"""DC/Server URLs should NOT get /wiki prefix."""
|
|
338
|
+
page = {
|
|
339
|
+
"id": "1",
|
|
340
|
+
"title": "T",
|
|
341
|
+
"_links": {"webui": "/spaces/DEV/pages/1"},
|
|
342
|
+
}
|
|
343
|
+
result = _slim_page(page, base_url="https://confluence.corp.com", is_cloud=False)
|
|
344
|
+
assert result["webui"] == "https://confluence.corp.com/spaces/DEV/pages/1"
|
|
345
|
+
|
|
316
346
|
def test_omits_missing_optional_fields(self):
|
|
317
347
|
page = {"id": "1", "title": "T"}
|
|
318
348
|
result = _slim_page(page)
|
|
@@ -496,3 +526,38 @@ class TestSlimSearchResult:
|
|
|
496
526
|
result = _slim_search_result(item, excerpt_length=30)
|
|
497
527
|
assert result["excerpt"].endswith("…")
|
|
498
528
|
assert len(result["excerpt"]) <= 35
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
class TestWebuiBase:
|
|
532
|
+
def test_cloud_without_wiki(self):
|
|
533
|
+
assert _webui_base("https://myorg.atlassian.net", True) == "https://myorg.atlassian.net/wiki"
|
|
534
|
+
|
|
535
|
+
def test_cloud_with_wiki(self):
|
|
536
|
+
assert _webui_base("https://myorg.atlassian.net/wiki", True) == "https://myorg.atlassian.net/wiki"
|
|
537
|
+
|
|
538
|
+
def test_cloud_trailing_slash(self):
|
|
539
|
+
assert _webui_base("https://myorg.atlassian.net/", True) == "https://myorg.atlassian.net/wiki"
|
|
540
|
+
|
|
541
|
+
def test_dc_plain(self):
|
|
542
|
+
assert _webui_base("https://confluence.corp.com", False) == "https://confluence.corp.com"
|
|
543
|
+
|
|
544
|
+
def test_dc_context_path(self):
|
|
545
|
+
assert _webui_base("https://corp.com/confluence", False) == "https://corp.com/confluence"
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
class TestBuildPageUrl:
|
|
549
|
+
def test_cloud_with_title(self):
|
|
550
|
+
url = build_page_url("https://myorg.atlassian.net", True, "SD", "12345", "My Page")
|
|
551
|
+
assert url == "https://myorg.atlassian.net/wiki/spaces/SD/pages/12345/My+Page"
|
|
552
|
+
|
|
553
|
+
def test_cloud_without_title(self):
|
|
554
|
+
url = build_page_url("https://myorg.atlassian.net", True, "SD", "12345")
|
|
555
|
+
assert url == "https://myorg.atlassian.net/wiki/spaces/SD/pages/12345"
|
|
556
|
+
|
|
557
|
+
def test_dc_with_title(self):
|
|
558
|
+
url = build_page_url("https://confluence.corp.com", False, "DEV", "456", "Test Page")
|
|
559
|
+
assert url == "https://confluence.corp.com/spaces/DEV/pages/456/Test+Page"
|
|
560
|
+
|
|
561
|
+
def test_dc_without_title(self):
|
|
562
|
+
url = build_page_url("https://confluence.corp.com", False, "DEV", "456")
|
|
563
|
+
assert url == "https://confluence.corp.com/spaces/DEV/pages/456"
|
|
@@ -112,3 +112,64 @@ class TestBuildGuide:
|
|
|
112
112
|
assert "rule" in concurrency
|
|
113
113
|
assert "safe_patterns" in concurrency
|
|
114
114
|
assert len(concurrency["safe_patterns"]) > 0
|
|
115
|
+
|
|
116
|
+
def test_global_flags_section(self):
|
|
117
|
+
guide = build_guide()
|
|
118
|
+
assert "global_flags" in guide
|
|
119
|
+
gf = guide["global_flags"]
|
|
120
|
+
assert "--quiet" in gf["flags"]
|
|
121
|
+
assert "--verbose" in gf["flags"]
|
|
122
|
+
assert "placement" in gf
|
|
123
|
+
assert len(gf["placement"]) > 0
|
|
124
|
+
|
|
125
|
+
def test_page_inspect_has_raw_flag(self):
|
|
126
|
+
guide = build_guide()
|
|
127
|
+
cmd = guide["commands"]["page.inspect"]
|
|
128
|
+
assert "--raw" in cmd["flags"]
|
|
129
|
+
assert "result_schema" in cmd
|
|
130
|
+
assert "examples" in cmd
|
|
131
|
+
assert "agent_hint" in cmd
|
|
132
|
+
|
|
133
|
+
def test_page_publish_has_title_inference_hint(self):
|
|
134
|
+
guide = build_guide()
|
|
135
|
+
cmd = guide["commands"]["page.publish"]
|
|
136
|
+
assert "agent_hint" in cmd
|
|
137
|
+
assert "title-cased" in cmd["agent_hint"]
|
|
138
|
+
|
|
139
|
+
def test_page_pull_has_progress_hint(self):
|
|
140
|
+
guide = build_guide()
|
|
141
|
+
cmd = guide["commands"]["page.pull"]
|
|
142
|
+
assert "agent_hint" in cmd
|
|
143
|
+
assert "progress" in cmd["agent_hint"].lower()
|
|
144
|
+
|
|
145
|
+
def test_page_delete_has_result_schema(self):
|
|
146
|
+
guide = build_guide()
|
|
147
|
+
cmd = guide["commands"]["page.delete"]
|
|
148
|
+
assert "result_schema" in cmd
|
|
149
|
+
assert "deleted_ids" in cmd["result_schema"]
|
|
150
|
+
assert "deleted_count" in cmd["result_schema"]
|
|
151
|
+
|
|
152
|
+
def test_plan_apply_has_result_schema(self):
|
|
153
|
+
guide = build_guide()
|
|
154
|
+
cmd = guide["commands"]["plan.apply"]
|
|
155
|
+
assert "result_schema" in cmd
|
|
156
|
+
assert "lockfile_path" in cmd["result_schema"]
|
|
157
|
+
|
|
158
|
+
def test_config_set_has_args_and_examples(self):
|
|
159
|
+
guide = build_guide()
|
|
160
|
+
cmd = guide["commands"]["config.set"]
|
|
161
|
+
assert "args" in cmd
|
|
162
|
+
assert "agent_hint" in cmd
|
|
163
|
+
assert "examples" in cmd
|
|
164
|
+
assert len(cmd["examples"]) > 0
|
|
165
|
+
|
|
166
|
+
def test_search_has_pagination_example(self):
|
|
167
|
+
guide = build_guide()
|
|
168
|
+
cmd = guide["commands"]["search"]
|
|
169
|
+
assert any("--start" in ex for ex in cmd["examples"])
|
|
170
|
+
assert "pagination" in cmd["agent_hint"].lower()
|
|
171
|
+
|
|
172
|
+
def test_lockfile_path_resolution_documented(self):
|
|
173
|
+
guide = build_guide()
|
|
174
|
+
behaviors = guide["lockfile"]["behavior"]
|
|
175
|
+
assert any("Path resolution" in b for b in behaviors)
|
|
@@ -163,6 +163,51 @@ class TestSearchCommand:
|
|
|
163
163
|
assert "search" in result.output
|
|
164
164
|
|
|
165
165
|
|
|
166
|
+
class TestQuietFlagPosition:
|
|
167
|
+
"""Bug 1: --quiet and --verbose should work between group name and subcommand."""
|
|
168
|
+
|
|
169
|
+
def test_quiet_before_group(self):
|
|
170
|
+
result = runner.invoke(app, ["--quiet", "page", "--help"])
|
|
171
|
+
assert result.exit_code == 0
|
|
172
|
+
|
|
173
|
+
def test_quiet_between_group_and_command(self):
|
|
174
|
+
result = runner.invoke(app, ["page", "--quiet", "--help"])
|
|
175
|
+
assert result.exit_code == 0
|
|
176
|
+
|
|
177
|
+
def test_verbose_between_group_and_command(self):
|
|
178
|
+
result = runner.invoke(app, ["page", "--verbose", "--help"])
|
|
179
|
+
assert result.exit_code == 0
|
|
180
|
+
|
|
181
|
+
def test_quiet_on_plan_group(self):
|
|
182
|
+
result = runner.invoke(app, ["plan", "--quiet", "--help"])
|
|
183
|
+
assert result.exit_code == 0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class TestDeleteCascadeResult:
|
|
187
|
+
"""Bug 3: Delete result should include deleted_ids and deleted_count."""
|
|
188
|
+
|
|
189
|
+
def test_delete_result_has_deleted_ids(self, monkeypatch):
|
|
190
|
+
from confpub.confluence import ConfluenceClient
|
|
191
|
+
|
|
192
|
+
mock_client = ConfluenceClient.__new__(ConfluenceClient)
|
|
193
|
+
monkeypatch.setattr("confpub.confluence.build_client", lambda: mock_client)
|
|
194
|
+
monkeypatch.setattr(mock_client, "get_descendant_ids", lambda pid: ["child1", "child2"])
|
|
195
|
+
monkeypatch.setattr(mock_client, "_delete_descendants", lambda pid: None)
|
|
196
|
+
monkeypatch.setattr(mock_client, "delete_page", lambda pid: {"status": "deleted"})
|
|
197
|
+
|
|
198
|
+
# Prevent lockfile I/O
|
|
199
|
+
monkeypatch.setattr("confpub.lockfile.load_lockfile", lambda p: None)
|
|
200
|
+
|
|
201
|
+
result = runner.invoke(app, ["page", "delete", "--page-id", "123", "--cascade"])
|
|
202
|
+
assert result.exit_code == 0
|
|
203
|
+
data = json.loads(result.output)
|
|
204
|
+
assert data["ok"] is True
|
|
205
|
+
assert "deleted_ids" in data["result"]
|
|
206
|
+
assert "deleted_count" in data["result"]
|
|
207
|
+
assert data["result"]["deleted_count"] == 3 # page + 2 children
|
|
208
|
+
assert sorted(data["result"]["deleted_ids"]) == ["123", "child1", "child2"]
|
|
209
|
+
|
|
210
|
+
|
|
166
211
|
class TestEnvelopeContract:
|
|
167
212
|
def test_guide_returns_full_envelope(self):
|
|
168
213
|
result = runner.invoke(app, ["guide"])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|