confpub-cli 1.1.0__tar.gz → 1.3.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.1.0 → confpub_cli-1.3.0}/PKG-INFO +1 -1
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/__init__.py +1 -1
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/applier.py +30 -2
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/cli.py +150 -2
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/confluence.py +135 -7
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/errors.py +1 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/guide.py +42 -2
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/manifest.py +12 -1
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/planner.py +4 -1
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/publish.py +29 -8
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/puller.py +15 -2
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_applier.py +85 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_confluence.py +165 -1
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_guide.py +45 -3
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_integration.py +101 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_manifest.py +83 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_publish.py +76 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/.github/workflows/publish.yml +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/.gitignore +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/CLAUDE.md +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/LICENSE +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/PRD.md +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/README.md +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/assets.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/config.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/converter.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/envelope.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/lockfile.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/output.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/py.typed +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/reverse_converter.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/validator.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub/verifier.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/confpub.lock +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/pyproject.toml +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/__init__.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/conftest.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_assets.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_config.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_converter.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_envelope.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_errors.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_lockfile.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_output.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_planner.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_puller.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_reverse_converter.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_validator.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/tests/test_verifier.py +0 -0
- {confpub_cli-1.1.0 → confpub_cli-1.3.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: confpub-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.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
|
|
@@ -43,7 +43,7 @@ def apply_plan(
|
|
|
43
43
|
lockfile = load_lockfile(lockfile_path) or Lockfile()
|
|
44
44
|
|
|
45
45
|
changes: list[dict[str, Any]] = []
|
|
46
|
-
counts = {"create": 0, "update": 0, "attachments_upload": 0}
|
|
46
|
+
counts = {"create": 0, "update": 0, "attachments_upload": 0, "labels_applied": 0}
|
|
47
47
|
|
|
48
48
|
# Resolve parent page IDs by title
|
|
49
49
|
parent_ids: dict[str, str] = {} # title → page_id
|
|
@@ -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:
|
|
@@ -124,9 +128,19 @@ def apply_plan(
|
|
|
124
128
|
change["attachments_added"] = [a.source_path for a in assets]
|
|
125
129
|
counts["attachments_upload"] += len(assets)
|
|
126
130
|
|
|
131
|
+
# Apply labels
|
|
132
|
+
if page.labels:
|
|
133
|
+
client.set_labels(new_id, page.labels)
|
|
134
|
+
change["labels_added"] = page.labels
|
|
135
|
+
counts["labels_applied"] += len(page.labels)
|
|
136
|
+
|
|
127
137
|
# Update lockfile and parent tracking
|
|
128
138
|
update_lockfile(lockfile, page.title, new_id, new_version if isinstance(new_version, int) else 1, content_fingerprint=fingerprint_content(storage))
|
|
129
139
|
parent_ids[page.title] = new_id
|
|
140
|
+
else:
|
|
141
|
+
# Dry-run: report labels
|
|
142
|
+
if page.labels:
|
|
143
|
+
change["labels_to_apply"] = page.labels
|
|
130
144
|
|
|
131
145
|
counts["create"] += 1
|
|
132
146
|
changes.append(change)
|
|
@@ -167,6 +181,10 @@ def apply_plan(
|
|
|
167
181
|
if isinstance(new_version, dict):
|
|
168
182
|
new_version = new_version.get("number", (before_version or 0) + 1)
|
|
169
183
|
change["after"]["version"] = new_version
|
|
184
|
+
change["after"]["webui"] = build_page_url(
|
|
185
|
+
config.base_url or "", config.is_cloud,
|
|
186
|
+
plan.space, page.confluence_page_id, page.title,
|
|
187
|
+
)
|
|
170
188
|
|
|
171
189
|
# Upload attachments
|
|
172
190
|
if assets:
|
|
@@ -176,12 +194,22 @@ def apply_plan(
|
|
|
176
194
|
change["attachments_added"] = [a.source_path for a in assets]
|
|
177
195
|
counts["attachments_upload"] += len(assets)
|
|
178
196
|
|
|
197
|
+
# Apply labels
|
|
198
|
+
if page.labels:
|
|
199
|
+
client.set_labels(page.confluence_page_id, page.labels)
|
|
200
|
+
change["labels_added"] = page.labels
|
|
201
|
+
counts["labels_applied"] += len(page.labels)
|
|
202
|
+
|
|
179
203
|
update_lockfile(
|
|
180
204
|
lockfile, page.title, page.confluence_page_id,
|
|
181
205
|
new_version if isinstance(new_version, int) else 1,
|
|
182
206
|
content_fingerprint=fingerprint_content(storage),
|
|
183
207
|
)
|
|
184
208
|
parent_ids[page.title] = page.confluence_page_id
|
|
209
|
+
else:
|
|
210
|
+
# Dry-run: report labels
|
|
211
|
+
if page.labels:
|
|
212
|
+
change["labels_to_apply"] = page.labels
|
|
185
213
|
|
|
186
214
|
counts["update"] += 1
|
|
187
215
|
changes.append(change)
|
|
@@ -38,6 +38,8 @@ auth_app = typer.Typer(help="Authentication", callback=_group_callback)
|
|
|
38
38
|
config_app = typer.Typer(help="Configuration", callback=_group_callback)
|
|
39
39
|
space_app = typer.Typer(help="Space operations", callback=_group_callback)
|
|
40
40
|
attachment_app = typer.Typer(help="Attachment operations", callback=_group_callback)
|
|
41
|
+
label_app = typer.Typer(help="Label operations", callback=_group_callback)
|
|
42
|
+
comment_app = typer.Typer(help="Comment operations", callback=_group_callback)
|
|
41
43
|
|
|
42
44
|
# ---------------------------------------------------------------------------
|
|
43
45
|
# Main app
|
|
@@ -55,6 +57,8 @@ app.add_typer(auth_app, name="auth")
|
|
|
55
57
|
app.add_typer(config_app, name="config")
|
|
56
58
|
app.add_typer(space_app, name="space")
|
|
57
59
|
app.add_typer(attachment_app, name="attachment")
|
|
60
|
+
app.add_typer(label_app, name="label")
|
|
61
|
+
app.add_typer(comment_app, name="comment")
|
|
58
62
|
|
|
59
63
|
|
|
60
64
|
def _version_callback(value: bool) -> None:
|
|
@@ -189,7 +193,7 @@ def page_list(
|
|
|
189
193
|
from confpub.confluence import build_client, _slim_page
|
|
190
194
|
client = build_client()
|
|
191
195
|
pages = client.list_pages(space, start=start, limit=limit)
|
|
192
|
-
ctx.result = {"pages": [_slim_page(p, base_url=client._config.base_url.rstrip("/")) for p in pages]}
|
|
196
|
+
ctx.result = {"pages": [_slim_page(p, base_url=client._config.base_url.rstrip("/"), is_cloud=client._config.is_cloud) for p in pages]}
|
|
193
197
|
|
|
194
198
|
|
|
195
199
|
@page_app.command("inspect")
|
|
@@ -217,7 +221,7 @@ def page_inspect(
|
|
|
217
221
|
if raw:
|
|
218
222
|
ctx.result = page
|
|
219
223
|
else:
|
|
220
|
-
result = _slim_page(page, base_url=client._config.base_url.rstrip("/"))
|
|
224
|
+
result = _slim_page(page, base_url=client._config.base_url.rstrip("/"), is_cloud=client._config.is_cloud)
|
|
221
225
|
if format == "markdown" and "body_storage" in result:
|
|
222
226
|
from confpub.reverse_converter import convert_storage_to_markdown
|
|
223
227
|
conversion = convert_storage_to_markdown(result["body_storage"])
|
|
@@ -239,6 +243,7 @@ def page_publish(
|
|
|
239
243
|
page_id: Optional[str] = typer.Option(None, "--page-id", help="Confluence page ID (skip lookup, update directly)"),
|
|
240
244
|
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without writing"),
|
|
241
245
|
backup: bool = typer.Option(False, "--backup", help="Backup existing page before overwriting"),
|
|
246
|
+
label: Optional[list[str]] = typer.Option(None, "--label", help="Label to apply (repeatable)"),
|
|
242
247
|
) -> None:
|
|
243
248
|
"""Publish a single Markdown file to Confluence."""
|
|
244
249
|
from confpub.publish import derive_title
|
|
@@ -262,6 +267,7 @@ def page_publish(
|
|
|
262
267
|
dry_run=dry_run,
|
|
263
268
|
backup=backup,
|
|
264
269
|
progress_callback=ctx,
|
|
270
|
+
labels=label or [],
|
|
265
271
|
)
|
|
266
272
|
ctx.result = result
|
|
267
273
|
|
|
@@ -351,6 +357,43 @@ def page_delete(
|
|
|
351
357
|
ctx.result = result
|
|
352
358
|
|
|
353
359
|
|
|
360
|
+
@page_app.command("move")
|
|
361
|
+
def page_move(
|
|
362
|
+
page_id: str = typer.Option(..., "--page-id", help="Confluence page ID to move"),
|
|
363
|
+
target_parent: Optional[str] = typer.Option(None, "--target-parent", help="Title of the new parent page"),
|
|
364
|
+
space: Optional[str] = typer.Option(None, "--space", help="Space key (required with --target-parent)"),
|
|
365
|
+
target_parent_id: Optional[str] = typer.Option(None, "--target-parent-id", help="Page ID of the new parent"),
|
|
366
|
+
) -> None:
|
|
367
|
+
"""Move a page under a new parent."""
|
|
368
|
+
target = {"page_id": page_id}
|
|
369
|
+
with command_context("page.move", target=target) as ctx:
|
|
370
|
+
if not target_parent and not target_parent_id:
|
|
371
|
+
raise ConfpubError(
|
|
372
|
+
"ERR_VALIDATION_REQUIRED",
|
|
373
|
+
"Either --target-parent or --target-parent-id is required",
|
|
374
|
+
)
|
|
375
|
+
if target_parent and not space:
|
|
376
|
+
raise ConfpubError(
|
|
377
|
+
"ERR_VALIDATION_REQUIRED",
|
|
378
|
+
"--space is required when using --target-parent",
|
|
379
|
+
)
|
|
380
|
+
from confpub.confluence import build_client
|
|
381
|
+
client = build_client()
|
|
382
|
+
|
|
383
|
+
if target_parent_id:
|
|
384
|
+
# Use target_id directly — more reliable, no title resolution needed
|
|
385
|
+
parent_page = client.get_page_by_id(target_parent_id)
|
|
386
|
+
if not parent_page or not parent_page.get("id"):
|
|
387
|
+
from confpub.errors import ERR_VALIDATION_NOT_FOUND
|
|
388
|
+
raise ConfpubError(ERR_VALIDATION_NOT_FOUND, f"Target parent page not found: {target_parent_id}")
|
|
389
|
+
resolved_space = parent_page.get("space", {}).get("key", space or "")
|
|
390
|
+
result = client.move_page(resolved_space, page_id, target_id=target_parent_id)
|
|
391
|
+
else:
|
|
392
|
+
result = client.move_page(space, page_id, target_title=target_parent)
|
|
393
|
+
|
|
394
|
+
ctx.result = result
|
|
395
|
+
|
|
396
|
+
|
|
354
397
|
@space_app.command("list")
|
|
355
398
|
def space_list() -> None:
|
|
356
399
|
"""List accessible Confluence spaces."""
|
|
@@ -481,6 +524,111 @@ def config_inspect() -> None:
|
|
|
481
524
|
ctx.result = config.to_display_dict()
|
|
482
525
|
|
|
483
526
|
|
|
527
|
+
# ---------------------------------------------------------------------------
|
|
528
|
+
# Label commands
|
|
529
|
+
# ---------------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@label_app.command("list")
|
|
533
|
+
def label_list(
|
|
534
|
+
page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
|
|
535
|
+
) -> None:
|
|
536
|
+
"""List labels on a Confluence page."""
|
|
537
|
+
with command_context("label.list", target={"page_id": page_id}) as ctx:
|
|
538
|
+
from confpub.confluence import build_client
|
|
539
|
+
client = build_client()
|
|
540
|
+
labels = client.get_labels(page_id)
|
|
541
|
+
ctx.result = {"labels": labels, "count": len(labels)}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@label_app.command("add")
|
|
545
|
+
def label_add(
|
|
546
|
+
page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
|
|
547
|
+
label: list[str] = typer.Option(..., "--label", help="Label name (repeatable)"),
|
|
548
|
+
) -> None:
|
|
549
|
+
"""Add labels to a Confluence page."""
|
|
550
|
+
with command_context("label.add", target={"page_id": page_id}) as ctx:
|
|
551
|
+
from confpub.errors import ERR_VALIDATION_LABEL
|
|
552
|
+
# Validate labels
|
|
553
|
+
for lbl in label:
|
|
554
|
+
if " " in lbl:
|
|
555
|
+
raise ConfpubError(
|
|
556
|
+
ERR_VALIDATION_LABEL,
|
|
557
|
+
f"Label must not contain spaces: '{lbl}'",
|
|
558
|
+
details={"label": lbl},
|
|
559
|
+
)
|
|
560
|
+
if len(lbl) > 255:
|
|
561
|
+
raise ConfpubError(
|
|
562
|
+
ERR_VALIDATION_LABEL,
|
|
563
|
+
f"Label exceeds 255 characters: '{lbl[:50]}...'",
|
|
564
|
+
details={"label": lbl, "length": len(lbl)},
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
from confpub.confluence import build_client
|
|
568
|
+
client = build_client()
|
|
569
|
+
results = client.set_labels(page_id, label)
|
|
570
|
+
ctx.result = {"labels_added": label, "results": results}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@label_app.command("remove")
|
|
574
|
+
def label_remove(
|
|
575
|
+
page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
|
|
576
|
+
label: list[str] = typer.Option(..., "--label", help="Label name to remove (repeatable)"),
|
|
577
|
+
) -> None:
|
|
578
|
+
"""Remove labels from a Confluence page."""
|
|
579
|
+
with command_context("label.remove", target={"page_id": page_id}) as ctx:
|
|
580
|
+
from confpub.confluence import build_client
|
|
581
|
+
client = build_client()
|
|
582
|
+
results = []
|
|
583
|
+
for lbl in label:
|
|
584
|
+
result = client.remove_label(page_id, lbl)
|
|
585
|
+
results.append(result)
|
|
586
|
+
ctx.result = {"labels_removed": label, "results": results}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
# ---------------------------------------------------------------------------
|
|
590
|
+
# Comment commands
|
|
591
|
+
# ---------------------------------------------------------------------------
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@comment_app.command("add")
|
|
595
|
+
def comment_add(
|
|
596
|
+
page_id: str = typer.Option(..., "--page-id", help="Confluence page ID"),
|
|
597
|
+
text: Optional[str] = typer.Option(None, "--text", help="Comment text (Markdown)"),
|
|
598
|
+
file: Optional[str] = typer.Option(None, "--file", help="Path to Markdown file for comment body"),
|
|
599
|
+
) -> None:
|
|
600
|
+
"""Add a comment to a Confluence page."""
|
|
601
|
+
with command_context("comment.add", target={"page_id": page_id}) as ctx:
|
|
602
|
+
if not text and not file:
|
|
603
|
+
raise ConfpubError(
|
|
604
|
+
"ERR_VALIDATION_REQUIRED",
|
|
605
|
+
"Either --text or --file is required",
|
|
606
|
+
)
|
|
607
|
+
if text and file:
|
|
608
|
+
raise ConfpubError(
|
|
609
|
+
"ERR_VALIDATION_REQUIRED",
|
|
610
|
+
"--text and --file are mutually exclusive",
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
if file:
|
|
614
|
+
from pathlib import Path
|
|
615
|
+
p = Path(file)
|
|
616
|
+
if not p.exists():
|
|
617
|
+
from confpub.errors import ERR_IO_FILE_NOT_FOUND
|
|
618
|
+
raise ConfpubError(ERR_IO_FILE_NOT_FOUND, f"File not found: {file}")
|
|
619
|
+
md_text = p.read_text(encoding="utf-8")
|
|
620
|
+
else:
|
|
621
|
+
md_text = text
|
|
622
|
+
|
|
623
|
+
from confpub.converter import convert_markdown
|
|
624
|
+
storage_body = convert_markdown(md_text)
|
|
625
|
+
|
|
626
|
+
from confpub.confluence import build_client
|
|
627
|
+
client = build_client()
|
|
628
|
+
result = client.add_comment(page_id, storage_body)
|
|
629
|
+
ctx.result = result
|
|
630
|
+
|
|
631
|
+
|
|
484
632
|
# ---------------------------------------------------------------------------
|
|
485
633
|
# search command (top-level, not in a subgroup)
|
|
486
634
|
# ---------------------------------------------------------------------------
|
|
@@ -267,7 +267,8 @@ class ConfluenceClient:
|
|
|
267
267
|
raw = result
|
|
268
268
|
else:
|
|
269
269
|
raw = []
|
|
270
|
-
|
|
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]
|
|
271
272
|
except Exception as exc:
|
|
272
273
|
self._handle_error(exc, "list_spaces")
|
|
273
274
|
return []
|
|
@@ -407,7 +408,7 @@ class ConfluenceClient:
|
|
|
407
408
|
api_limit = raw.get("limit", limit) if isinstance(raw, dict) else limit
|
|
408
409
|
|
|
409
410
|
results = [
|
|
410
|
-
_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)
|
|
411
412
|
for r in results_raw
|
|
412
413
|
]
|
|
413
414
|
return {
|
|
@@ -418,6 +419,95 @@ class ConfluenceClient:
|
|
|
418
419
|
"has_more": (api_start + api_limit) < total,
|
|
419
420
|
}
|
|
420
421
|
|
|
422
|
+
# ------------------------------------------------------------------
|
|
423
|
+
# Label operations
|
|
424
|
+
# ------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
def get_labels(self, page_id: str) -> list[dict[str, Any]]:
|
|
427
|
+
"""Get labels on a page."""
|
|
428
|
+
self._call_count += 1
|
|
429
|
+
try:
|
|
430
|
+
result = self._api.get_page_labels(page_id)
|
|
431
|
+
raw = result.get("results", []) if isinstance(result, dict) else (result if isinstance(result, list) else [])
|
|
432
|
+
return [_slim_label(lbl) for lbl in raw]
|
|
433
|
+
except Exception as exc:
|
|
434
|
+
self._handle_error(exc, "get_labels")
|
|
435
|
+
return []
|
|
436
|
+
|
|
437
|
+
def set_labels(self, page_id: str, labels: list[str]) -> list[dict[str, Any]]:
|
|
438
|
+
"""Set labels on a page (additive — does not remove existing labels)."""
|
|
439
|
+
results: list[dict[str, Any]] = []
|
|
440
|
+
for lbl in labels:
|
|
441
|
+
self._call_count += 1
|
|
442
|
+
try:
|
|
443
|
+
result = self._api.set_page_label(page_id, lbl)
|
|
444
|
+
if isinstance(result, dict):
|
|
445
|
+
results.append(_slim_label(result))
|
|
446
|
+
elif isinstance(result, list):
|
|
447
|
+
results.extend(_slim_label(r) for r in result)
|
|
448
|
+
except Exception as exc:
|
|
449
|
+
self._handle_error(exc, "set_labels")
|
|
450
|
+
return results
|
|
451
|
+
|
|
452
|
+
def remove_label(self, page_id: str, label: str) -> dict[str, Any]:
|
|
453
|
+
"""Remove a label from a page."""
|
|
454
|
+
self._call_count += 1
|
|
455
|
+
try:
|
|
456
|
+
self._api.remove_page_label(page_id, label)
|
|
457
|
+
return {"removed": True, "label": label, "page_id": page_id}
|
|
458
|
+
except Exception as exc:
|
|
459
|
+
self._handle_error(exc, "remove_label")
|
|
460
|
+
return {}
|
|
461
|
+
|
|
462
|
+
# ------------------------------------------------------------------
|
|
463
|
+
# Comment operations
|
|
464
|
+
# ------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
def add_comment(self, page_id: str, body: str) -> dict[str, Any]:
|
|
467
|
+
"""Add a comment to a page."""
|
|
468
|
+
self._call_count += 1
|
|
469
|
+
try:
|
|
470
|
+
result = self._api.add_comment(page_id, body)
|
|
471
|
+
return {
|
|
472
|
+
"id": result.get("id") if isinstance(result, dict) else None,
|
|
473
|
+
"page_id": page_id,
|
|
474
|
+
"created": True,
|
|
475
|
+
}
|
|
476
|
+
except Exception as exc:
|
|
477
|
+
self._handle_error(exc, "add_comment")
|
|
478
|
+
return {}
|
|
479
|
+
|
|
480
|
+
# ------------------------------------------------------------------
|
|
481
|
+
# Page move
|
|
482
|
+
# ------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
def move_page(
|
|
485
|
+
self,
|
|
486
|
+
space_key: str,
|
|
487
|
+
page_id: str,
|
|
488
|
+
target_title: str | None = None,
|
|
489
|
+
target_id: str | None = None,
|
|
490
|
+
position: str = "append",
|
|
491
|
+
) -> dict[str, Any]:
|
|
492
|
+
"""Move a page under a new parent."""
|
|
493
|
+
self._call_count += 1
|
|
494
|
+
try:
|
|
495
|
+
result = self._api.move_page(
|
|
496
|
+
space_key, page_id,
|
|
497
|
+
target_title=target_title,
|
|
498
|
+
target_id=target_id,
|
|
499
|
+
position=position,
|
|
500
|
+
)
|
|
501
|
+
return {
|
|
502
|
+
"moved": True,
|
|
503
|
+
"page_id": page_id,
|
|
504
|
+
"target_parent": target_title or target_id,
|
|
505
|
+
"result": result if isinstance(result, dict) else {"raw": str(result)},
|
|
506
|
+
}
|
|
507
|
+
except Exception as exc:
|
|
508
|
+
self._handle_error(exc, "move_page")
|
|
509
|
+
return {}
|
|
510
|
+
|
|
421
511
|
# ------------------------------------------------------------------
|
|
422
512
|
# Fingerprinting
|
|
423
513
|
# ------------------------------------------------------------------
|
|
@@ -433,7 +523,30 @@ class ConfluenceClient:
|
|
|
433
523
|
return None
|
|
434
524
|
|
|
435
525
|
|
|
436
|
-
def
|
|
526
|
+
def _webui_base(base_url: str, is_cloud: bool) -> str:
|
|
527
|
+
"""Return the correct base URL for webui links.
|
|
528
|
+
|
|
529
|
+
Cloud instances need ``/wiki`` in the path; DC/Server do not.
|
|
530
|
+
Handles base_url configured with or without the ``/wiki`` suffix.
|
|
531
|
+
"""
|
|
532
|
+
base = base_url.rstrip("/")
|
|
533
|
+
if is_cloud and not base.endswith("/wiki"):
|
|
534
|
+
base += "/wiki"
|
|
535
|
+
return base
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def build_page_url(
|
|
539
|
+
base_url: str, is_cloud: bool, space: str, page_id: str, title: str = "",
|
|
540
|
+
) -> str:
|
|
541
|
+
"""Construct a full webui URL for a Confluence page."""
|
|
542
|
+
base = _webui_base(base_url, is_cloud)
|
|
543
|
+
if title:
|
|
544
|
+
encoded_title = title.replace(" ", "+")
|
|
545
|
+
return f"{base}/spaces/{space}/pages/{page_id}/{encoded_title}"
|
|
546
|
+
return f"{base}/spaces/{space}/pages/{page_id}"
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _slim_page(page: dict[str, Any], *, base_url: str = "", is_cloud: bool = False) -> dict[str, Any]:
|
|
437
550
|
"""Extract agent-relevant fields from a raw Confluence page object."""
|
|
438
551
|
result: dict[str, Any] = {
|
|
439
552
|
"id": page.get("id"),
|
|
@@ -456,12 +569,12 @@ def _slim_page(page: dict[str, Any], *, base_url: str = "") -> dict[str, Any]:
|
|
|
456
569
|
result["parent_title"] = parent.get("title")
|
|
457
570
|
links = page.get("_links", {})
|
|
458
571
|
if "webui" in links:
|
|
459
|
-
base = base_url
|
|
572
|
+
base = _webui_base(base_url, is_cloud) if base_url else links.get("base", "")
|
|
460
573
|
result["webui"] = base + links["webui"]
|
|
461
574
|
return result
|
|
462
575
|
|
|
463
576
|
|
|
464
|
-
def _slim_space(space: dict[str, Any]) -> dict[str, Any]:
|
|
577
|
+
def _slim_space(space: dict[str, Any], *, base_url: str = "", is_cloud: bool = False) -> dict[str, Any]:
|
|
465
578
|
"""Extract agent-relevant fields from a raw Confluence space object."""
|
|
466
579
|
result: dict[str, Any] = {
|
|
467
580
|
"id": space.get("id"),
|
|
@@ -478,7 +591,12 @@ def _slim_space(space: dict[str, Any]) -> dict[str, Any]:
|
|
|
478
591
|
result["description"] = value
|
|
479
592
|
links = space.get("_links", {})
|
|
480
593
|
if "webui" in links:
|
|
481
|
-
|
|
594
|
+
webui_path = links.get("webui", "")
|
|
595
|
+
if base_url and webui_path:
|
|
596
|
+
base = _webui_base(base_url, is_cloud)
|
|
597
|
+
result["webui"] = base + webui_path
|
|
598
|
+
else:
|
|
599
|
+
result["webui"] = webui_path
|
|
482
600
|
return result
|
|
483
601
|
|
|
484
602
|
|
|
@@ -507,10 +625,20 @@ def _slim_attachment(att: dict[str, Any]) -> dict[str, Any]:
|
|
|
507
625
|
return result
|
|
508
626
|
|
|
509
627
|
|
|
628
|
+
def _slim_label(lbl: dict[str, Any]) -> dict[str, Any]:
|
|
629
|
+
"""Extract agent-relevant fields from a raw Confluence label object."""
|
|
630
|
+
return {
|
|
631
|
+
"name": lbl.get("name", ""),
|
|
632
|
+
"prefix": lbl.get("prefix", "global"),
|
|
633
|
+
"id": lbl.get("id"),
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
|
|
510
637
|
def _slim_search_result(
|
|
511
638
|
item: dict[str, Any],
|
|
512
639
|
*,
|
|
513
640
|
base_url: str = "",
|
|
641
|
+
is_cloud: bool = False,
|
|
514
642
|
excerpt_length: int = 0,
|
|
515
643
|
) -> dict[str, Any]:
|
|
516
644
|
"""Extract agent-relevant fields from a raw Confluence search result.
|
|
@@ -536,7 +664,7 @@ def _slim_search_result(
|
|
|
536
664
|
|
|
537
665
|
webui = content.get("_links", {}).get("webui", "")
|
|
538
666
|
if webui:
|
|
539
|
-
_set("url", base_url + webui)
|
|
667
|
+
_set("url", _webui_base(base_url, is_cloud) + webui if base_url else webui)
|
|
540
668
|
|
|
541
669
|
_set("space_key", content.get("space", {}).get("key"))
|
|
542
670
|
|
|
@@ -15,6 +15,7 @@ ERR_VALIDATION_MARKDOWN = "ERR_VALIDATION_MARKDOWN"
|
|
|
15
15
|
ERR_VALIDATION_ASSET_MISSING = "ERR_VALIDATION_ASSET_MISSING"
|
|
16
16
|
ERR_VALIDATION_SPACE_MISMATCH = "ERR_VALIDATION_SPACE_MISMATCH"
|
|
17
17
|
ERR_VALIDATION_NOT_FOUND = "ERR_VALIDATION_NOT_FOUND"
|
|
18
|
+
ERR_VALIDATION_LABEL = "ERR_VALIDATION_LABEL"
|
|
18
19
|
|
|
19
20
|
# Auth (exit 20)
|
|
20
21
|
ERR_AUTH_REQUIRED = "ERR_AUTH_REQUIRED"
|
|
@@ -24,6 +24,7 @@ from confpub.errors import (
|
|
|
24
24
|
ERR_IO_FILE_NOT_FOUND,
|
|
25
25
|
ERR_IO_TIMEOUT,
|
|
26
26
|
ERR_VALIDATION_ASSET_MISSING,
|
|
27
|
+
ERR_VALIDATION_LABEL,
|
|
27
28
|
ERR_VALIDATION_MANIFEST,
|
|
28
29
|
ERR_VALIDATION_MARKDOWN,
|
|
29
30
|
ERR_VALIDATION_NOT_FOUND,
|
|
@@ -121,11 +122,23 @@ def build_guide() -> dict[str, Any]:
|
|
|
121
122
|
"group": "write",
|
|
122
123
|
"mutates": True,
|
|
123
124
|
"description": "Publish a single Markdown file to Confluence",
|
|
124
|
-
"flags": ["--space", "--parent", "--title", "--page-id", "--dry-run", "--backup"],
|
|
125
|
+
"flags": ["--space", "--parent", "--title", "--page-id", "--dry-run", "--backup", "--label"],
|
|
125
126
|
"agent_hint": (
|
|
126
127
|
"When --title is omitted, the title is inferred from the filename: "
|
|
127
128
|
"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
|
+
"and the result is title-cased. E.g. 'my-cool-page.md' → 'My Cool Page'. "
|
|
130
|
+
"Use --label to apply labels (repeatable): --label api --label docs."
|
|
131
|
+
),
|
|
132
|
+
},
|
|
133
|
+
"page.move": {
|
|
134
|
+
"group": "write",
|
|
135
|
+
"mutates": True,
|
|
136
|
+
"description": "Move a page under a new parent",
|
|
137
|
+
"flags": ["--page-id", "--target-parent", "--space", "--target-parent-id"],
|
|
138
|
+
"agent_hint": (
|
|
139
|
+
"Use --target-parent + --space for title-based targeting, "
|
|
140
|
+
"or --target-parent-id for ID-based targeting. "
|
|
141
|
+
"The page ID does not change after a move."
|
|
129
142
|
),
|
|
130
143
|
},
|
|
131
144
|
"page.pull": {
|
|
@@ -177,6 +190,32 @@ def build_guide() -> dict[str, Any]:
|
|
|
177
190
|
"description": "Upload an attachment to a Confluence page",
|
|
178
191
|
"flags": ["--page-id"],
|
|
179
192
|
},
|
|
193
|
+
"label.list": {
|
|
194
|
+
"group": "read",
|
|
195
|
+
"mutates": False,
|
|
196
|
+
"description": "List labels on a Confluence page",
|
|
197
|
+
"flags": ["--page-id"],
|
|
198
|
+
},
|
|
199
|
+
"label.add": {
|
|
200
|
+
"group": "write",
|
|
201
|
+
"mutates": True,
|
|
202
|
+
"description": "Add labels to a Confluence page",
|
|
203
|
+
"flags": ["--page-id", "--label"],
|
|
204
|
+
"agent_hint": "Use --label for each label (repeatable): --label api --label docs. Labels must not contain spaces and max 255 characters.",
|
|
205
|
+
},
|
|
206
|
+
"label.remove": {
|
|
207
|
+
"group": "write",
|
|
208
|
+
"mutates": True,
|
|
209
|
+
"description": "Remove labels from a Confluence page",
|
|
210
|
+
"flags": ["--page-id", "--label"],
|
|
211
|
+
},
|
|
212
|
+
"comment.add": {
|
|
213
|
+
"group": "write",
|
|
214
|
+
"mutates": True,
|
|
215
|
+
"description": "Add a comment to a Confluence page",
|
|
216
|
+
"flags": ["--page-id", "--text", "--file"],
|
|
217
|
+
"agent_hint": "Exactly one of --text or --file is required. The body is converted from Markdown to Confluence storage format.",
|
|
218
|
+
},
|
|
180
219
|
"plan.create": {
|
|
181
220
|
"group": "transactional",
|
|
182
221
|
"mutates": False,
|
|
@@ -254,6 +293,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
254
293
|
ERR_VALIDATION_ASSET_MISSING: _error_code_entry(ERR_VALIDATION_ASSET_MISSING),
|
|
255
294
|
ERR_VALIDATION_NOT_FOUND: _error_code_entry(ERR_VALIDATION_NOT_FOUND),
|
|
256
295
|
ERR_VALIDATION_SPACE_MISMATCH: _error_code_entry(ERR_VALIDATION_SPACE_MISMATCH),
|
|
296
|
+
ERR_VALIDATION_LABEL: _error_code_entry(ERR_VALIDATION_LABEL),
|
|
257
297
|
ERR_AUTH_REQUIRED: _error_code_entry(ERR_AUTH_REQUIRED),
|
|
258
298
|
ERR_AUTH_EXPIRED: _error_code_entry(ERR_AUTH_EXPIRED),
|
|
259
299
|
ERR_AUTH_FORBIDDEN: _error_code_entry(ERR_AUTH_FORBIDDEN),
|
|
@@ -51,6 +51,7 @@ class ManifestPage(BaseModel):
|
|
|
51
51
|
title: str
|
|
52
52
|
file: str
|
|
53
53
|
assets: list[str] = Field(default_factory=list)
|
|
54
|
+
labels: list[str] = Field(default_factory=list)
|
|
54
55
|
children: list[ManifestPage] = Field(default_factory=list)
|
|
55
56
|
|
|
56
57
|
|
|
@@ -93,6 +94,7 @@ class PlanPage(BaseModel):
|
|
|
93
94
|
operation: Literal["create", "update", "noop"]
|
|
94
95
|
parent_title: Optional[str] = None
|
|
95
96
|
attachments: list[PlanAttachment] = Field(default_factory=list)
|
|
97
|
+
labels: list[str] = Field(default_factory=list)
|
|
96
98
|
|
|
97
99
|
|
|
98
100
|
class PlanSummary(BaseModel):
|
|
@@ -102,6 +104,7 @@ class PlanSummary(BaseModel):
|
|
|
102
104
|
update: int = 0
|
|
103
105
|
noop: int = 0
|
|
104
106
|
attachments_to_upload: int = 0
|
|
107
|
+
labels_to_apply: int = 0
|
|
105
108
|
|
|
106
109
|
|
|
107
110
|
class PlanArtifact(BaseModel):
|
|
@@ -126,6 +129,7 @@ class FlatPage(BaseModel):
|
|
|
126
129
|
title: str
|
|
127
130
|
file: str
|
|
128
131
|
assets: list[str] = Field(default_factory=list)
|
|
132
|
+
labels: list[str] = Field(default_factory=list) # merged global + per-page
|
|
129
133
|
parent_title: str # The parent page title (from manifest parent or parent page)
|
|
130
134
|
|
|
131
135
|
|
|
@@ -187,6 +191,9 @@ def generate_manifest_yaml(
|
|
|
187
191
|
result = []
|
|
188
192
|
for p in pages:
|
|
189
193
|
entry: dict[str, Any] = {"title": p["title"], "file": p["file"]}
|
|
194
|
+
labels = p.get("labels", [])
|
|
195
|
+
if labels:
|
|
196
|
+
entry["labels"] = labels
|
|
190
197
|
children = p.get("children", [])
|
|
191
198
|
if children:
|
|
192
199
|
entry["children"] = _build_pages(children)
|
|
@@ -206,16 +213,20 @@ def resolve_page_tree(manifest: Manifest) -> list[FlatPage]:
|
|
|
206
213
|
"""Flatten the recursive page tree into a list with parent references.
|
|
207
214
|
|
|
208
215
|
Pages are returned in tree order (parents before children) to ensure
|
|
209
|
-
correct creation order.
|
|
216
|
+
correct creation order. Labels are merged: global (manifest.labels)
|
|
217
|
+
first, then per-page, deduplicated while preserving order.
|
|
210
218
|
"""
|
|
211
219
|
flat: list[FlatPage] = []
|
|
220
|
+
global_labels = manifest.labels
|
|
212
221
|
|
|
213
222
|
def _walk(pages: list[ManifestPage], parent_title: str) -> None:
|
|
214
223
|
for page in pages:
|
|
224
|
+
merged = list(dict.fromkeys(global_labels + page.labels))
|
|
215
225
|
flat.append(FlatPage(
|
|
216
226
|
title=page.title,
|
|
217
227
|
file=page.file,
|
|
218
228
|
assets=page.assets,
|
|
229
|
+
labels=merged,
|
|
219
230
|
parent_title=parent_title,
|
|
220
231
|
))
|
|
221
232
|
if page.children:
|
|
@@ -58,7 +58,7 @@ def create_plan(
|
|
|
58
58
|
|
|
59
59
|
# Build plan pages
|
|
60
60
|
plan_pages: list[PlanPage] = []
|
|
61
|
-
counts = {"create": 0, "update": 0, "noop": 0, "attachments_to_upload": 0}
|
|
61
|
+
counts = {"create": 0, "update": 0, "noop": 0, "attachments_to_upload": 0, "labels_to_apply": 0}
|
|
62
62
|
|
|
63
63
|
for i, fp in enumerate(flat_pages):
|
|
64
64
|
plan_id = f"plan_{i + 1}"
|
|
@@ -118,6 +118,8 @@ def create_plan(
|
|
|
118
118
|
|
|
119
119
|
counts[operation] += 1
|
|
120
120
|
counts["attachments_to_upload"] += len(plan_attachments)
|
|
121
|
+
if fp.labels:
|
|
122
|
+
counts["labels_to_apply"] += len(fp.labels)
|
|
121
123
|
|
|
122
124
|
plan_pages.append(PlanPage(
|
|
123
125
|
id=plan_id,
|
|
@@ -128,6 +130,7 @@ def create_plan(
|
|
|
128
130
|
operation=operation,
|
|
129
131
|
parent_title=fp.parent_title,
|
|
130
132
|
attachments=plan_attachments,
|
|
133
|
+
labels=fp.labels,
|
|
131
134
|
))
|
|
132
135
|
|
|
133
136
|
# Build plan artifact
|