confpub-cli 0.6.0__tar.gz → 0.7.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-0.6.0 → confpub_cli-0.7.0}/PKG-INFO +1 -1
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/__init__.py +1 -1
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/cli.py +31 -8
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/confluence.py +18 -3
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/guide.py +26 -3
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/publish.py +27 -1
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/puller.py +4 -1
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/verifier.py +38 -7
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_confluence.py +1 -1
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_puller.py +4 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_verifier.py +2 -1
- confpub_cli-0.6.0/FEEDBACK.md +0 -197
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/.github/workflows/publish.yml +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/.gitignore +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/LICENSE +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/PRD.md +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/README.md +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/applier.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/assets.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/config.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/converter.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/envelope.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/errors.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/lockfile.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/manifest.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/output.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/planner.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/py.typed +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/reverse_converter.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub/validator.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/confpub.lock +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/pyproject.toml +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/__init__.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/conftest.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_applier.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_assets.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_config.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_converter.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_envelope.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_errors.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_guide.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_integration.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_lockfile.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_manifest.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_output.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_planner.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_publish.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_reverse_converter.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/tests/test_validator.py +0 -0
- {confpub_cli-0.6.0 → confpub_cli-0.7.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: confpub-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -222,22 +222,31 @@ def page_inspect(
|
|
|
222
222
|
def page_publish(
|
|
223
223
|
file: str = typer.Argument(..., help="Markdown file to publish"),
|
|
224
224
|
space: str = typer.Option(..., "--space", help="Confluence space key"),
|
|
225
|
-
parent: str = typer.Option(
|
|
225
|
+
parent: Optional[str] = typer.Option(None, "--parent", help="Parent page title"),
|
|
226
226
|
title: Optional[str] = typer.Option(None, "--title", help="Page title (defaults to filename stem, hyphen/underscore→spaces, title-cased)"),
|
|
227
|
+
page_id: Optional[str] = typer.Option(None, "--page-id", help="Confluence page ID (skip lookup, update directly)"),
|
|
227
228
|
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without writing"),
|
|
228
229
|
backup: bool = typer.Option(False, "--backup", help="Backup existing page before overwriting"),
|
|
229
230
|
) -> None:
|
|
230
231
|
"""Publish a single Markdown file to Confluence."""
|
|
231
232
|
from confpub.publish import derive_title
|
|
232
233
|
resolved_title = derive_title(file, title)
|
|
234
|
+
if not page_id and not parent:
|
|
235
|
+
raise ConfpubError(
|
|
236
|
+
"ERR_VALIDATION_REQUIRED",
|
|
237
|
+
"Either --page-id or --parent is required",
|
|
238
|
+
)
|
|
233
239
|
target = {"space": space, "title": resolved_title, "file": file}
|
|
240
|
+
if page_id:
|
|
241
|
+
target["page_id"] = page_id
|
|
234
242
|
with command_context("page.publish", target=target) as ctx:
|
|
235
243
|
from confpub.publish import publish_page
|
|
236
244
|
result = publish_page(
|
|
237
245
|
file=file,
|
|
238
246
|
space=space,
|
|
239
|
-
parent=parent,
|
|
247
|
+
parent=parent or "",
|
|
240
248
|
title=title,
|
|
249
|
+
page_id=page_id,
|
|
241
250
|
dry_run=dry_run,
|
|
242
251
|
backup=backup,
|
|
243
252
|
progress_callback=ctx,
|
|
@@ -284,15 +293,26 @@ def page_pull(
|
|
|
284
293
|
|
|
285
294
|
@page_app.command("delete")
|
|
286
295
|
def page_delete(
|
|
287
|
-
space: str = typer.Option(
|
|
288
|
-
title: str = typer.Option(
|
|
296
|
+
space: Optional[str] = typer.Option(None, "--space", help="Confluence space key"),
|
|
297
|
+
title: Optional[str] = typer.Option(None, "--title", help="Page title"),
|
|
298
|
+
page_id: Optional[str] = typer.Option(None, "--page-id", help="Confluence page ID"),
|
|
289
299
|
cascade: bool = typer.Option(False, "--cascade", help="Also delete child pages"),
|
|
290
300
|
) -> None:
|
|
291
301
|
"""Delete a Confluence page."""
|
|
292
|
-
with command_context("page.delete", target={"space": space, "title": title}) as ctx:
|
|
302
|
+
with command_context("page.delete", target={"space": space, "title": title, "page_id": page_id}) as ctx:
|
|
303
|
+
if not page_id and not (space and title):
|
|
304
|
+
raise ConfpubError(
|
|
305
|
+
"ERR_VALIDATION_REQUIRED",
|
|
306
|
+
"Either --page-id or both --space and --title are required",
|
|
307
|
+
)
|
|
293
308
|
from confpub.confluence import build_client
|
|
294
309
|
client = build_client()
|
|
295
|
-
|
|
310
|
+
if page_id:
|
|
311
|
+
if cascade:
|
|
312
|
+
client._delete_descendants(page_id)
|
|
313
|
+
result = client.delete_page(page_id)
|
|
314
|
+
else:
|
|
315
|
+
result = client.delete_page_by_title(space, title, cascade=cascade)
|
|
296
316
|
ctx.result = result
|
|
297
317
|
|
|
298
318
|
|
|
@@ -435,6 +455,7 @@ def config_inspect() -> None:
|
|
|
435
455
|
def search(
|
|
436
456
|
cql: Optional[str] = typer.Option(None, "--cql", help="Raw CQL query"),
|
|
437
457
|
space: Optional[str] = typer.Option(None, "--space", help="Filter by space key"),
|
|
458
|
+
title: Optional[str] = typer.Option(None, "--title", help="Search by page title (fuzzy match)"),
|
|
438
459
|
content_type: Optional[str] = typer.Option(None, "--type", help="Filter by content type (page, blogpost, etc.)"),
|
|
439
460
|
limit: int = typer.Option(25, "--limit", help="Maximum results to return"),
|
|
440
461
|
start: int = typer.Option(0, "--start", help="Starting offset for pagination"),
|
|
@@ -442,12 +463,14 @@ def search(
|
|
|
442
463
|
excerpt_length: int = typer.Option(200, "--excerpt-length", help="Max excerpt chars (0 = unlimited)"),
|
|
443
464
|
) -> None:
|
|
444
465
|
"""Search Confluence content using CQL."""
|
|
445
|
-
target = {"cql": cql, "space": space, "type": content_type}
|
|
466
|
+
target = {"cql": cql, "space": space, "title": title, "type": content_type}
|
|
446
467
|
with command_context("search", target=target) as ctx:
|
|
447
468
|
# Build effective CQL from flags
|
|
448
469
|
fragments: list[str] = []
|
|
449
470
|
if space:
|
|
450
471
|
fragments.append(f'space = "{space}"')
|
|
472
|
+
if title:
|
|
473
|
+
fragments.append(f'title ~ "{title}"')
|
|
451
474
|
if content_type:
|
|
452
475
|
fragments.append(f'type = "{content_type}"')
|
|
453
476
|
if cql:
|
|
@@ -456,7 +479,7 @@ def search(
|
|
|
456
479
|
if not fragments:
|
|
457
480
|
raise ConfpubError(
|
|
458
481
|
"ERR_VALIDATION_REQUIRED",
|
|
459
|
-
"At least one of --cql, --space, or --type is required",
|
|
482
|
+
"At least one of --cql, --space, --title, or --type is required",
|
|
460
483
|
)
|
|
461
484
|
|
|
462
485
|
effective_cql = " AND ".join(fragments)
|
|
@@ -93,7 +93,7 @@ class ConfluenceClient:
|
|
|
93
93
|
"""Get a page by space key and title."""
|
|
94
94
|
self._call_count += 1
|
|
95
95
|
try:
|
|
96
|
-
result = self._api.get_page_by_title(space, title, expand="version,body.storage,space")
|
|
96
|
+
result = self._api.get_page_by_title(space, title, expand="version,body.storage,space,ancestors")
|
|
97
97
|
return result if result else None
|
|
98
98
|
except Exception as exc:
|
|
99
99
|
self._handle_error(exc, "get_page")
|
|
@@ -103,7 +103,7 @@ class ConfluenceClient:
|
|
|
103
103
|
"""Get a page by its Confluence ID."""
|
|
104
104
|
self._call_count += 1
|
|
105
105
|
try:
|
|
106
|
-
return self._api.get_page_by_id(page_id, expand="version,body.storage,space")
|
|
106
|
+
return self._api.get_page_by_id(page_id, expand="version,body.storage,space,ancestors")
|
|
107
107
|
except Exception as exc:
|
|
108
108
|
self._handle_error(exc, "get_page_by_id")
|
|
109
109
|
return {}
|
|
@@ -230,6 +230,16 @@ class ConfluenceClient:
|
|
|
230
230
|
start += limit
|
|
231
231
|
return all_children
|
|
232
232
|
|
|
233
|
+
def get_page_ancestors(self, page_id: str) -> list[dict[str, Any]]:
|
|
234
|
+
"""Get the ancestor chain of a page (root first, immediate parent last)."""
|
|
235
|
+
self._call_count += 1
|
|
236
|
+
try:
|
|
237
|
+
page = self._api.get_page_by_id(page_id, expand="ancestors")
|
|
238
|
+
return page.get("ancestors", [])
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
self._handle_error(exc, "get_page_ancestors")
|
|
241
|
+
return []
|
|
242
|
+
|
|
233
243
|
# ------------------------------------------------------------------
|
|
234
244
|
# Space operations
|
|
235
245
|
# ------------------------------------------------------------------
|
|
@@ -419,9 +429,14 @@ def _slim_page(page: dict[str, Any], *, base_url: str = "") -> dict[str, Any]:
|
|
|
419
429
|
body = page.get("body", {}).get("storage", {}).get("value")
|
|
420
430
|
if body is not None:
|
|
421
431
|
result["body_storage"] = body
|
|
432
|
+
ancestors = page.get("ancestors")
|
|
433
|
+
if isinstance(ancestors, list) and ancestors:
|
|
434
|
+
parent = ancestors[-1]
|
|
435
|
+
result["parent_id"] = parent.get("id")
|
|
436
|
+
result["parent_title"] = parent.get("title")
|
|
422
437
|
links = page.get("_links", {})
|
|
423
438
|
if "webui" in links:
|
|
424
|
-
base = links.get("base", "")
|
|
439
|
+
base = base_url or links.get("base", "")
|
|
425
440
|
result["webui"] = base + links["webui"]
|
|
426
441
|
return result
|
|
427
442
|
|
|
@@ -65,7 +65,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
65
65
|
"group": "read",
|
|
66
66
|
"mutates": False,
|
|
67
67
|
"description": "Search Confluence content using CQL",
|
|
68
|
-
"flags": ["--cql", "--space", "--type", "--limit", "--start", "--include-archived", "--excerpt-length"],
|
|
68
|
+
"flags": ["--cql", "--space", "--title", "--type", "--limit", "--start", "--include-archived", "--excerpt-length"],
|
|
69
69
|
"agent_hint": "Most agent workflows should include --type page to exclude attachments and space entities from results.",
|
|
70
70
|
"result_schema": {
|
|
71
71
|
"cql_query": "string — effective CQL sent to the API",
|
|
@@ -79,6 +79,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
79
79
|
'confpub search --cql \'label = "api-docs"\'',
|
|
80
80
|
"confpub search --space DEV --type page --limit 10",
|
|
81
81
|
'confpub search --space DEV --cql \'title ~ "deploy"\'',
|
|
82
|
+
'confpub search --title "deploy guide" --space DEV',
|
|
82
83
|
],
|
|
83
84
|
},
|
|
84
85
|
"page.list": {
|
|
@@ -97,7 +98,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
97
98
|
"group": "write",
|
|
98
99
|
"mutates": True,
|
|
99
100
|
"description": "Publish a single Markdown file to Confluence",
|
|
100
|
-
"flags": ["--space", "--parent", "--title", "--dry-run", "--backup"],
|
|
101
|
+
"flags": ["--space", "--parent", "--title", "--page-id", "--dry-run", "--backup"],
|
|
101
102
|
},
|
|
102
103
|
"page.pull": {
|
|
103
104
|
"group": "read",
|
|
@@ -116,7 +117,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
116
117
|
"group": "write",
|
|
117
118
|
"mutates": True,
|
|
118
119
|
"description": "Delete a Confluence page",
|
|
119
|
-
"flags": ["--space", "--title", "--cascade"],
|
|
120
|
+
"flags": ["--space", "--title", "--page-id", "--cascade"],
|
|
120
121
|
"safety_flags": {
|
|
121
122
|
"--cascade": "Also deletes child pages",
|
|
122
123
|
},
|
|
@@ -248,6 +249,28 @@ def build_guide() -> dict[str, Any]:
|
|
|
248
249
|
"Does not prevent concurrent operations — purely local state tracking",
|
|
249
250
|
],
|
|
250
251
|
},
|
|
252
|
+
"assertions": {
|
|
253
|
+
"description": "Post-condition assertions verified by plan.verify.",
|
|
254
|
+
"file_format": "JSON array of assertion objects, or embedded in confpub.yaml under the 'assertions' key.",
|
|
255
|
+
"auto_generation": "When --plan is passed without --assertions, plan.verify auto-generates page.exists assertions for every create/update page in the plan.",
|
|
256
|
+
"types": {
|
|
257
|
+
"page.exists": {
|
|
258
|
+
"description": "Verify that a page exists in the given space.",
|
|
259
|
+
"required_fields": ["type", "space", "title"],
|
|
260
|
+
"example": {"type": "page.exists", "space": "DEV", "title": "My Page"},
|
|
261
|
+
},
|
|
262
|
+
"page.parent": {
|
|
263
|
+
"description": "Verify that a page has the expected parent.",
|
|
264
|
+
"required_fields": ["type", "space", "title", "expected_parent"],
|
|
265
|
+
"example": {"type": "page.parent", "space": "DEV", "title": "My Page", "expected_parent": "Parent Page"},
|
|
266
|
+
},
|
|
267
|
+
"attachment.exists": {
|
|
268
|
+
"description": "Verify that an attachment exists on a page.",
|
|
269
|
+
"required_fields": ["type", "space", "page", "filename"],
|
|
270
|
+
"example": {"type": "attachment.exists", "space": "DEV", "page": "My Page", "filename": "diagram.png"},
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
},
|
|
251
274
|
"auth": {
|
|
252
275
|
"precedence": [
|
|
253
276
|
"--token + --user",
|
|
@@ -35,6 +35,7 @@ def publish_page(
|
|
|
35
35
|
space: str,
|
|
36
36
|
parent: str,
|
|
37
37
|
title: str | None = None,
|
|
38
|
+
page_id: str | None = None,
|
|
38
39
|
dry_run: bool = False,
|
|
39
40
|
backup: bool = False,
|
|
40
41
|
progress_callback: Any = None,
|
|
@@ -73,7 +74,14 @@ def publish_page(
|
|
|
73
74
|
existing_page_id = None
|
|
74
75
|
current_version = None
|
|
75
76
|
|
|
76
|
-
if
|
|
77
|
+
if page_id:
|
|
78
|
+
# Direct page ID provided — skip lockfile/title lookup
|
|
79
|
+
existing_page_id = page_id
|
|
80
|
+
page_data = client.get_page_by_id(existing_page_id)
|
|
81
|
+
if page_data:
|
|
82
|
+
version = page_data.get("version", {})
|
|
83
|
+
current_version = version.get("number") if isinstance(version, dict) else version
|
|
84
|
+
elif page_title in lockfile.pages:
|
|
77
85
|
existing_page_id = lockfile.pages[page_title].page_id
|
|
78
86
|
page_data = client.get_page_by_id(existing_page_id)
|
|
79
87
|
if page_data:
|
|
@@ -99,6 +107,12 @@ def publish_page(
|
|
|
99
107
|
# Determine operation
|
|
100
108
|
operation = "update" if existing_page_id else "create"
|
|
101
109
|
|
|
110
|
+
# Detect noop — skip update when content is unchanged
|
|
111
|
+
if operation == "update":
|
|
112
|
+
remote_fingerprint = client.fingerprint_page(existing_page_id)
|
|
113
|
+
if remote_fingerprint and remote_fingerprint == local_fingerprint:
|
|
114
|
+
operation = "noop"
|
|
115
|
+
|
|
102
116
|
if dry_run:
|
|
103
117
|
change: dict[str, Any] = {
|
|
104
118
|
"type": f"page.{operation}",
|
|
@@ -124,6 +138,18 @@ def publish_page(
|
|
|
124
138
|
return result_dict
|
|
125
139
|
|
|
126
140
|
# Real publish
|
|
141
|
+
if operation == "noop":
|
|
142
|
+
return {
|
|
143
|
+
"dry_run": False,
|
|
144
|
+
"changes": [{
|
|
145
|
+
"type": "page.noop",
|
|
146
|
+
"title": page_title,
|
|
147
|
+
"confluence_page_id": existing_page_id,
|
|
148
|
+
"before": {"version": current_version} if current_version else None,
|
|
149
|
+
}],
|
|
150
|
+
"summary": {"noop": 1},
|
|
151
|
+
}
|
|
152
|
+
|
|
127
153
|
backup_file_path: str | None = None
|
|
128
154
|
if operation == "create":
|
|
129
155
|
# Find parent page
|
|
@@ -312,8 +312,11 @@ def pull_pages(
|
|
|
312
312
|
manifest_file: str | None = None
|
|
313
313
|
if generate_manifest:
|
|
314
314
|
root_title = root_page.get("title", "")
|
|
315
|
+
# Determine the actual parent of the root page
|
|
316
|
+
ancestors = client.get_page_ancestors(root_id)
|
|
317
|
+
manifest_parent = ancestors[-1].get("title", root_title) if ancestors else root_title
|
|
315
318
|
page_tree = _build_page_tree(all_pages, file_paths, root_id, output_dir)
|
|
316
|
-
manifest_yaml = generate_manifest_yaml(root_space,
|
|
319
|
+
manifest_yaml = generate_manifest_yaml(root_space, manifest_parent, page_tree)
|
|
317
320
|
manifest_path = os.path.join(output_dir, "confpub.yaml")
|
|
318
321
|
Path(manifest_path).write_text(manifest_yaml, encoding="utf-8")
|
|
319
322
|
manifest_file = manifest_path
|
|
@@ -35,7 +35,6 @@ def _load_assertions(assertions_path: str | None, plan_path: str | None) -> list
|
|
|
35
35
|
raise ConfpubError(ERR_VALIDATION_MANIFEST, f"Invalid assertions JSON: {exc}") from exc
|
|
36
36
|
|
|
37
37
|
if plan_path:
|
|
38
|
-
# Try to load assertions from the plan's source manifest
|
|
39
38
|
p = Path(plan_path)
|
|
40
39
|
if not p.exists():
|
|
41
40
|
raise ConfpubError(
|
|
@@ -44,9 +43,33 @@ def _load_assertions(assertions_path: str | None, plan_path: str | None) -> list
|
|
|
44
43
|
retryable=False,
|
|
45
44
|
suggested_action="fix_input",
|
|
46
45
|
)
|
|
46
|
+
|
|
47
|
+
# Check for confpub.yaml alongside the plan file
|
|
48
|
+
manifest_path = p.parent / "confpub.yaml"
|
|
49
|
+
if manifest_path.exists():
|
|
50
|
+
try:
|
|
51
|
+
import yaml
|
|
52
|
+
manifest_data = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
|
|
53
|
+
if isinstance(manifest_data, dict) and manifest_data.get("assertions"):
|
|
54
|
+
raw = manifest_data["assertions"]
|
|
55
|
+
if isinstance(raw, list):
|
|
56
|
+
return raw
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
# Auto-generate page.exists assertions from the plan
|
|
47
61
|
plan_data = json.loads(p.read_text(encoding="utf-8"))
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
auto: list[dict[str, Any]] = []
|
|
63
|
+
space = plan_data.get("space", "")
|
|
64
|
+
for page in plan_data.get("pages", []):
|
|
65
|
+
op = page.get("operation", "")
|
|
66
|
+
if op in ("create", "update"):
|
|
67
|
+
auto.append({
|
|
68
|
+
"type": "page.exists",
|
|
69
|
+
"space": space,
|
|
70
|
+
"title": page.get("title", ""),
|
|
71
|
+
})
|
|
72
|
+
return auto
|
|
50
73
|
|
|
51
74
|
return []
|
|
52
75
|
|
|
@@ -88,14 +111,22 @@ def verify_assertions(
|
|
|
88
111
|
all_passed = False
|
|
89
112
|
|
|
90
113
|
elif a_type == "page.parent":
|
|
114
|
+
space = assertion.get("space", "")
|
|
91
115
|
title = assertion.get("title", "")
|
|
92
116
|
expected_parent = assertion.get("expected_parent", "")
|
|
93
117
|
result["title"] = title
|
|
94
|
-
# Get page and check its parent
|
|
95
|
-
# This is a simplified check — in a full implementation
|
|
96
|
-
# we'd look up the page's ancestor chain
|
|
97
|
-
result["passed"] = True # Simplified for now
|
|
98
118
|
result["expected_parent"] = expected_parent
|
|
119
|
+
page = client.get_page(space, title) if space else None
|
|
120
|
+
if page:
|
|
121
|
+
ancestors = client.get_page_ancestors(str(page["id"]))
|
|
122
|
+
actual_parent = ancestors[-1].get("title", "") if ancestors else ""
|
|
123
|
+
result["actual_parent"] = actual_parent
|
|
124
|
+
result["passed"] = actual_parent == expected_parent
|
|
125
|
+
else:
|
|
126
|
+
result["passed"] = False
|
|
127
|
+
result["error"] = f"Page '{title}' not found"
|
|
128
|
+
if not result["passed"]:
|
|
129
|
+
all_passed = False
|
|
99
130
|
|
|
100
131
|
elif a_type == "attachment.exists":
|
|
101
132
|
page_title = assertion.get("page", "")
|
|
@@ -52,7 +52,7 @@ class TestGetPage:
|
|
|
52
52
|
result = client.get_page("DEV", "Test")
|
|
53
53
|
assert result["id"] == "123"
|
|
54
54
|
client._mock_api.get_page_by_title.assert_called_once_with(
|
|
55
|
-
"DEV", "Test", expand="version,body.storage,space"
|
|
55
|
+
"DEV", "Test", expand="version,body.storage,space,ancestors"
|
|
56
56
|
)
|
|
57
57
|
|
|
58
58
|
def test_returns_none_when_not_found(self, client):
|
|
@@ -58,11 +58,15 @@ def _mock_client(pages: dict[str, dict], children: dict[str, list] | None = None
|
|
|
58
58
|
def download_attachment(pid, filename, path):
|
|
59
59
|
return False
|
|
60
60
|
|
|
61
|
+
def get_page_ancestors(pid):
|
|
62
|
+
return []
|
|
63
|
+
|
|
61
64
|
client.get_page_by_id = get_page_by_id
|
|
62
65
|
client.get_page = get_page
|
|
63
66
|
client.get_page_children_deep = get_page_children_deep
|
|
64
67
|
client.get_attachments = get_attachments
|
|
65
68
|
client.download_attachment = download_attachment
|
|
69
|
+
client.get_page_ancestors = get_page_ancestors
|
|
66
70
|
return client
|
|
67
71
|
|
|
68
72
|
|
|
@@ -12,7 +12,7 @@ from confpub.verifier import verify_assertions
|
|
|
12
12
|
|
|
13
13
|
SAMPLE_ASSERTIONS = [
|
|
14
14
|
{"type": "page.exists", "space": "DEV", "title": "Overview"},
|
|
15
|
-
{"type": "page.parent", "title": "Child", "expected_parent": "Overview"},
|
|
15
|
+
{"type": "page.parent", "space": "DEV", "title": "Child", "expected_parent": "Overview"},
|
|
16
16
|
{"type": "attachment.exists", "space": "DEV", "page": "Overview", "filename": "arch.png"},
|
|
17
17
|
]
|
|
18
18
|
|
|
@@ -31,6 +31,7 @@ class TestVerifyAssertions:
|
|
|
31
31
|
mock_client = MagicMock()
|
|
32
32
|
mock_client.get_page.return_value = {"id": "123", "title": "Overview"}
|
|
33
33
|
mock_client.get_attachments.return_value = [{"title": "arch.png"}]
|
|
34
|
+
mock_client.get_page_ancestors.return_value = [{"id": "100", "title": "Overview"}]
|
|
34
35
|
MockClient.return_value = mock_client
|
|
35
36
|
mock_config.return_value = MagicMock()
|
|
36
37
|
|
confpub_cli-0.6.0/FEEDBACK.md
DELETED
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
# confpub-cli v0.5.0 — Blind Test Feedback
|
|
2
|
-
|
|
3
|
-
Tested by: Claude Code (Opus 4.6) on 2026-03-01, Windows 11, via `uvx confpub-cli`.
|
|
4
|
-
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
## Summary
|
|
8
|
-
|
|
9
|
-
confpub v0.5.0 is a well-designed, agent-friendly CLI. The structured JSON envelope, clear error taxonomy, and comprehensive `guide` command make it straightforward for an LLM agent to drive programmatically. The full publish/pull/delete lifecycle works correctly, and round-trip Markdown fidelity is excellent. This review covers what works well, what could be improved, and specific bugs encountered.
|
|
10
|
-
|
|
11
|
-
---
|
|
12
|
-
|
|
13
|
-
## What Works Well
|
|
14
|
-
|
|
15
|
-
### Structured JSON Envelope
|
|
16
|
-
Every command returns the same `{ ok, command, target, result, warnings, errors, metrics }` shape. This is the single most important design decision for agent consumption — no output parsing required.
|
|
17
|
-
|
|
18
|
-
### Error Taxonomy
|
|
19
|
-
Typed error codes (`ERR_VALIDATION_*`, `ERR_AUTH_*`, `ERR_IO_*`, `ERR_CONFLICT_*`) with `retryable` and `suggested_action` fields make programmatic error handling trivial. Exit codes are consistent with the documented schema.
|
|
20
|
-
|
|
21
|
-
### `guide` Command
|
|
22
|
-
The machine-readable schema is comprehensive: command metadata, flags, safety annotations, concurrency rules, error codes, and auth precedence. The `--section` flag works for top-level keys (`commands`, `auth`, `error_codes`, `concurrency`).
|
|
23
|
-
|
|
24
|
-
### Markdown Round-Trip Fidelity
|
|
25
|
-
Published a file with headings, bullet lists, tables, blockquotes, and fenced code blocks. Pulled it back — the Markdown was virtually identical (only trivial whitespace differences in table alignment `| --- |` vs `|---------|`). This is a strong result.
|
|
26
|
-
|
|
27
|
-
### Publish Lifecycle
|
|
28
|
-
- `page.publish` with `--dry-run` correctly reports what would happen before writing.
|
|
29
|
-
- Re-publishing an existing page updates it in-place with version increment.
|
|
30
|
-
- `--backup` flag saves the previous HTML to `.confpub-backup-{id}.html`.
|
|
31
|
-
- `--title` override works as expected.
|
|
32
|
-
- `page.delete` cleanly removes the page.
|
|
33
|
-
|
|
34
|
-
### Recursive Pull + Manifest Generation
|
|
35
|
-
`page pull --recursive --manifest` correctly traverses child pages and generates a well-structured `confpub.yaml` manifest. The flat layout manifest correctly represents the page hierarchy with `children:` nesting.
|
|
36
|
-
|
|
37
|
-
### Plan Workflow
|
|
38
|
-
The `plan create` / `plan validate` / `plan apply --dry-run` workflow functions correctly. Plans include fingerprints for stale-state detection, which is a nice safety feature.
|
|
39
|
-
|
|
40
|
-
### Lock File
|
|
41
|
-
`confpub.lock` tracks page IDs and versions, providing local state awareness across commands.
|
|
42
|
-
|
|
43
|
-
### Auth & Config
|
|
44
|
-
`auth inspect` and `config inspect` return clear, useful information. Token masking in `config inspect` is a good security practice.
|
|
45
|
-
|
|
46
|
-
---
|
|
47
|
-
|
|
48
|
-
## Bugs
|
|
49
|
-
|
|
50
|
-
### 1. Stderr Message Leaks on Expected "Not Found" Lookups
|
|
51
|
-
**Severity: Medium**
|
|
52
|
-
|
|
53
|
-
When publishing a new page (or doing a dry-run), the CLI prints `Can't find '<title>' page on ...` to stderr before the JSON output. This happens because the tool checks whether the page exists first. For a *new* page, not finding it is the expected path — not an error. The message is confusing, especially during `--dry-run` where the user explicitly wants to preview a creation.
|
|
54
|
-
|
|
55
|
-
`--quiet` suppresses it, but agents shouldn't need `--quiet` for expected behavior.
|
|
56
|
-
|
|
57
|
-
**Suggestion:** Only emit this message at `--verbose` level, or suppress it when the subsequent operation succeeds.
|
|
58
|
-
|
|
59
|
-
### 2. Relative Paths Fail for `plan validate` and `plan apply`
|
|
60
|
-
**Severity: Medium**
|
|
61
|
-
|
|
62
|
-
```
|
|
63
|
-
uvx confpub-cli plan validate --plan plan-test/test-plan.json
|
|
64
|
-
# ERR_IO_FILE_NOT_FOUND: Plan file not found: plan-test/test-plan.json
|
|
65
|
-
|
|
66
|
-
uvx confpub-cli plan validate --plan "C:/Users/.../test-plan.json"
|
|
67
|
-
# Works fine
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
Relative paths resolve correctly for `page publish` (the FILE argument) but not for `--plan` in plan commands. This is likely a `Path.resolve()` vs `Path()` issue.
|
|
71
|
-
|
|
72
|
-
### 3. `page inspect` `webui` Field Inconsistent Format
|
|
73
|
-
**Severity: Low**
|
|
74
|
-
|
|
75
|
-
- With `--space SD --title "..."`: returns relative URL `/spaces/SD/pages/327981/Test+Page`
|
|
76
|
-
- With `--page-id 327981`: returns absolute URL `https://...atlassian.net/wiki/spaces/SD/pages/327981/Test+Page`
|
|
77
|
-
|
|
78
|
-
Should be consistent — preferably always absolute, since the agent may not know the base URL.
|
|
79
|
-
|
|
80
|
-
### 4. `ERR_AUTH_FORBIDDEN` for Nonexistent Space
|
|
81
|
-
**Severity: Low**
|
|
82
|
-
|
|
83
|
-
```
|
|
84
|
-
uvx confpub-cli page inspect --space FAKESPACE --title "Test"
|
|
85
|
-
# ERR_AUTH_FORBIDDEN: "Permission denied (get_page)"
|
|
86
|
-
# suggested_action: "escalate"
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
A nonexistent space key returns a permission error rather than a "space not found" validation error. The `suggested_action` is `"escalate"` but the guide says ERR_AUTH_FORBIDDEN should suggest `"reauth"`. An agent following the guide would incorrectly attempt to re-authenticate.
|
|
90
|
-
|
|
91
|
-
### 5. `guide --section` Does Not List Valid Sections on Error
|
|
92
|
-
**Severity: Low**
|
|
93
|
-
|
|
94
|
-
```
|
|
95
|
-
uvx confpub-cli guide --section search
|
|
96
|
-
# ERR_VALIDATION_REQUIRED: "Unknown guide section: search"
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
The error doesn't tell you what the valid sections are. Adding `"valid_sections": ["commands", "auth", "error_codes", "concurrency", "compatibility"]` to the error details would save a round-trip.
|
|
100
|
-
|
|
101
|
-
### 6. Nested Layout Manifest Uses Ambiguous `file: index.md` for All Pages
|
|
102
|
-
**Severity: Medium**
|
|
103
|
-
|
|
104
|
-
When using `--layout nested`, every page gets `file: index.md` in the manifest without a directory prefix:
|
|
105
|
-
|
|
106
|
-
```yaml
|
|
107
|
-
pages:
|
|
108
|
-
- title: confpub v0.3.0 Blind Test Report
|
|
109
|
-
file: index.md
|
|
110
|
-
children:
|
|
111
|
-
- title: What Works Well
|
|
112
|
-
file: index.md
|
|
113
|
-
- title: Bugs and Issues
|
|
114
|
-
file: index.md
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
These are all `index.md` — a plan created from this manifest would have no way to distinguish them. The file paths should include the relative directory (e.g., `confpub-v030-blind-test-report/index.md`).
|
|
118
|
-
|
|
119
|
-
### 7. Nested Layout Doesn't Actually Nest Directories
|
|
120
|
-
**Severity: Low**
|
|
121
|
-
|
|
122
|
-
The `--layout nested` option creates a flat set of directories rather than truly nesting children inside parents:
|
|
123
|
-
|
|
124
|
-
```
|
|
125
|
-
pulled-nested/
|
|
126
|
-
confpub-v030-blind-test-report/index.md # parent
|
|
127
|
-
what-works-well/index.md # child (not nested inside parent dir)
|
|
128
|
-
bugs-and-issues/index.md # child
|
|
129
|
-
full-test-matrix/index.md # child
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
Expected behavior for "nested" layout would place children inside the parent directory.
|
|
133
|
-
|
|
134
|
-
### 8. `--layout nested` Generates Manifest Without `--manifest` Flag
|
|
135
|
-
**Severity: Low**
|
|
136
|
-
|
|
137
|
-
Using `page pull --layout nested` generates a `confpub.yaml` even without the `--manifest` flag. The flat layout correctly requires `--manifest` to generate one.
|
|
138
|
-
|
|
139
|
-
---
|
|
140
|
-
|
|
141
|
-
## Suggestions (Not Bugs)
|
|
142
|
-
|
|
143
|
-
### Add `page.inspect --format markdown`
|
|
144
|
-
Currently `page inspect` returns raw Confluence storage XML. An option to return the reverse-converted Markdown would be useful for quick content review without pulling to a file.
|
|
145
|
-
|
|
146
|
-
### Add `search --type page` Default for Agent Usage
|
|
147
|
-
Agents almost always want pages, not attachments or space entities. Consider a default or a `--pages-only` shorthand.
|
|
148
|
-
|
|
149
|
-
### Document `confpub.lock` in `guide`
|
|
150
|
-
The lock file is created implicitly but isn't documented in the guide schema. Agents should know it exists and what it tracks.
|
|
151
|
-
|
|
152
|
-
### `ERR_IO_FILE_NOT_FOUND` Suggestion for Missing Source
|
|
153
|
-
When the source file doesn't exist, `retryable: true` with `suggested_action: "retry"` is misleading — a file that doesn't exist won't appear on retry. Consider `retryable: false` with `suggested_action: "fix_input"` for local file-not-found errors (vs. transient network errors).
|
|
154
|
-
|
|
155
|
-
---
|
|
156
|
-
|
|
157
|
-
## Test Matrix
|
|
158
|
-
|
|
159
|
-
| Command | Flags Tested | Result | Notes |
|
|
160
|
-
|---|---|---|---|
|
|
161
|
-
| `guide` | (none), `--section commands`, `--section auth`, `--section error_codes` | Pass | `--section` works for top-level keys |
|
|
162
|
-
| `guide --section` | invalid section name | Pass (with note) | Error lacks valid section list |
|
|
163
|
-
| `auth inspect` | (none) | Pass | |
|
|
164
|
-
| `config inspect` | (none) | Pass | Token correctly masked |
|
|
165
|
-
| `space list` | (none) | Pass | |
|
|
166
|
-
| `page list` | `--space` | Pass | |
|
|
167
|
-
| `page inspect` | `--space --title`, `--page-id` | Pass | webui format inconsistency |
|
|
168
|
-
| `page publish` | `--dry-run`, `--backup`, `--title` | Pass | stderr leak on new page |
|
|
169
|
-
| `page publish` (update) | `--backup` | Pass | Version incremented correctly |
|
|
170
|
-
| `page pull` | `--output`, `--force`, `--manifest` | Pass | |
|
|
171
|
-
| `page pull` | `--recursive`, `--layout flat` | Pass | |
|
|
172
|
-
| `page pull` | `--recursive`, `--layout nested` | Pass (with notes) | Manifest ambiguity, not truly nested |
|
|
173
|
-
| `page delete` | `--space --title` | Pass | |
|
|
174
|
-
| `search` | `--space`, `--limit`, `--cql` | Pass | |
|
|
175
|
-
| `plan create` | `--manifest`, `--output` | Pass | |
|
|
176
|
-
| `plan validate` | `--plan` (absolute path) | Pass | Relative path fails |
|
|
177
|
-
| `plan apply` | `--plan --dry-run` | Pass | |
|
|
178
|
-
| `attachment list` | `--page-id` | Pass | |
|
|
179
|
-
| `--quiet` | global flag | Pass | Suppresses stderr messages |
|
|
180
|
-
| `--verbose` | global flag | Pass | Adds diagnostics to metrics |
|
|
181
|
-
| Error: missing file | `page publish nonexistent.md` | Pass | Clear error |
|
|
182
|
-
| Error: missing page | `page inspect --title "..."` | Pass | |
|
|
183
|
-
| Error: missing args | `page publish` (no file) | Pass | |
|
|
184
|
-
| Error: missing space | `--space FAKESPACE` | Fail | Misleading ERR_AUTH_FORBIDDEN |
|
|
185
|
-
|
|
186
|
-
---
|
|
187
|
-
|
|
188
|
-
## Overall Assessment
|
|
189
|
-
|
|
190
|
-
**confpub v0.5.0 is production-ready for the core publish/pull/delete workflow.** The agent-first JSON design, error taxonomy, and guide command set a high bar for CLI ergonomics. The main areas for improvement are:
|
|
191
|
-
|
|
192
|
-
1. Fix relative path resolution for plan commands (blocks scripted workflows)
|
|
193
|
-
2. Fix the nested layout manifest ambiguity (blocks round-trip with nested trees)
|
|
194
|
-
3. Suppress the "Can't find" stderr noise on expected new-page creation
|
|
195
|
-
4. Normalize the `webui` field format
|
|
196
|
-
|
|
197
|
-
None of these are blockers for single-page publishing, which is the most common use case.
|
|
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
|
|
File without changes
|
|
File without changes
|