confpub-cli 1.2.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.
Files changed (50) hide show
  1. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/PKG-INFO +1 -1
  2. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/__init__.py +1 -1
  3. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/applier.py +21 -1
  4. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/cli.py +148 -0
  5. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/confluence.py +98 -0
  6. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/errors.py +1 -0
  7. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/guide.py +42 -2
  8. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/manifest.py +12 -1
  9. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/planner.py +4 -1
  10. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/publish.py +20 -6
  11. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/puller.py +15 -2
  12. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_applier.py +85 -0
  13. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_confluence.py +99 -0
  14. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_guide.py +45 -3
  15. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_integration.py +101 -0
  16. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_manifest.py +83 -0
  17. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_publish.py +76 -0
  18. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/.github/workflows/publish.yml +0 -0
  19. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/.gitignore +0 -0
  20. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/CLAUDE.md +0 -0
  21. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/LICENSE +0 -0
  22. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/PRD.md +0 -0
  23. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/README.md +0 -0
  24. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/assets.py +0 -0
  25. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/config.py +0 -0
  26. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/converter.py +0 -0
  27. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/envelope.py +0 -0
  28. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/lockfile.py +0 -0
  29. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/output.py +0 -0
  30. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/py.typed +0 -0
  31. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/reverse_converter.py +0 -0
  32. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/validator.py +0 -0
  33. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub/verifier.py +0 -0
  34. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/confpub.lock +0 -0
  35. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/pyproject.toml +0 -0
  36. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/__init__.py +0 -0
  37. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/conftest.py +0 -0
  38. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_assets.py +0 -0
  39. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_config.py +0 -0
  40. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_converter.py +0 -0
  41. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_envelope.py +0 -0
  42. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_errors.py +0 -0
  43. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_lockfile.py +0 -0
  44. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_output.py +0 -0
  45. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_planner.py +0 -0
  46. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_puller.py +0 -0
  47. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_reverse_converter.py +0 -0
  48. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_validator.py +0 -0
  49. {confpub_cli-1.2.0 → confpub_cli-1.3.0}/tests/test_verifier.py +0 -0
  50. {confpub_cli-1.2.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.2.0
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
@@ -1,3 +1,3 @@
1
1
  """confpub — Agent-first CLI to publish Markdown to Confluence."""
2
2
 
3
- __version__ = "1.2.0"
3
+ __version__ = "1.3.0"
@@ -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
@@ -128,9 +128,19 @@ def apply_plan(
128
128
  change["attachments_added"] = [a.source_path for a in assets]
129
129
  counts["attachments_upload"] += len(assets)
130
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
+
131
137
  # Update lockfile and parent tracking
132
138
  update_lockfile(lockfile, page.title, new_id, new_version if isinstance(new_version, int) else 1, content_fingerprint=fingerprint_content(storage))
133
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
134
144
 
135
145
  counts["create"] += 1
136
146
  changes.append(change)
@@ -184,12 +194,22 @@ def apply_plan(
184
194
  change["attachments_added"] = [a.source_path for a in assets]
185
195
  counts["attachments_upload"] += len(assets)
186
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
+
187
203
  update_lockfile(
188
204
  lockfile, page.title, page.confluence_page_id,
189
205
  new_version if isinstance(new_version, int) else 1,
190
206
  content_fingerprint=fingerprint_content(storage),
191
207
  )
192
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
193
213
 
194
214
  counts["update"] += 1
195
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:
@@ -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
  # ---------------------------------------------------------------------------
@@ -419,6 +419,95 @@ class ConfluenceClient:
419
419
  "has_more": (api_start + api_limit) < total,
420
420
  }
421
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
+
422
511
  # ------------------------------------------------------------------
423
512
  # Fingerprinting
424
513
  # ------------------------------------------------------------------
@@ -536,6 +625,15 @@ def _slim_attachment(att: dict[str, Any]) -> dict[str, Any]:
536
625
  return result
537
626
 
538
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
+
539
637
  def _slim_search_result(
540
638
  item: dict[str, Any],
541
639
  *,
@@ -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
@@ -39,6 +39,7 @@ def publish_page(
39
39
  dry_run: bool = False,
40
40
  backup: bool = False,
41
41
  progress_callback: Any = None,
42
+ labels: list[str] | None = None,
42
43
  ) -> dict[str, Any]:
43
44
  """Publish a single Markdown file to Confluence.
44
45
 
@@ -129,6 +130,8 @@ def publish_page(
129
130
  }
130
131
  if assets:
131
132
  change["attachments_to_upload"] = [a.source_path for a in assets]
133
+ if labels:
134
+ change["labels_to_apply"] = labels
132
135
 
133
136
  warnings: list[str] = []
134
137
  parent_page = client.get_page(space, parent)
@@ -146,14 +149,19 @@ def publish_page(
146
149
 
147
150
  # Real publish
148
151
  if operation == "noop":
152
+ noop_change: dict[str, Any] = {
153
+ "type": "page.noop",
154
+ "title": page_title,
155
+ "confluence_page_id": existing_page_id,
156
+ "before": {"version": current_version} if current_version else None,
157
+ }
158
+ # Labels may still need applying even when content is unchanged
159
+ if labels and existing_page_id:
160
+ client.set_labels(existing_page_id, labels)
161
+ noop_change["labels_added"] = labels
149
162
  return {
150
163
  "dry_run": False,
151
- "changes": [{
152
- "type": "page.noop",
153
- "title": page_title,
154
- "confluence_page_id": existing_page_id,
155
- "before": {"version": current_version} if current_version else None,
156
- }],
164
+ "changes": [noop_change],
157
165
  "summary": {"noop": 1},
158
166
  }
159
167
 
@@ -192,6 +200,10 @@ def publish_page(
192
200
  client.update_page(page_id, page_title, storage)
193
201
  uploaded_attachments = [a.source_path for a in assets]
194
202
 
203
+ # Apply labels
204
+ if labels:
205
+ client.set_labels(page_id, labels)
206
+
195
207
  # Update lockfile
196
208
  new_version_int = new_version if isinstance(new_version, int) else 1
197
209
  update_lockfile(lockfile, page_title, page_id, new_version_int, content_fingerprint=local_fingerprint)
@@ -215,6 +227,8 @@ def publish_page(
215
227
  change["backup_path"] = backup_file_path
216
228
  if uploaded_attachments:
217
229
  change["attachments_added"] = uploaded_attachments
230
+ if labels:
231
+ change["labels_added"] = labels
218
232
 
219
233
  return {
220
234
  "dry_run": False,
@@ -187,10 +187,12 @@ def _build_page_tree(
187
187
  file_paths: dict[str, str],
188
188
  root_page_id: str,
189
189
  output_dir: str = ".",
190
+ page_labels: dict[str, list[str]] | None = None,
190
191
  ) -> list[dict[str, Any]]:
191
192
  """Build a hierarchical page tree for manifest generation."""
192
193
  id_to_entry: dict[str, dict[str, Any]] = {}
193
194
  children_map: dict[str | None, list[str]] = {}
195
+ labels_map = page_labels or {}
194
196
 
195
197
  for entry in pages:
196
198
  page = entry["page"]
@@ -198,11 +200,14 @@ def _build_page_tree(
198
200
  parent_id = str(entry["parent_id"]) if entry["parent_id"] else None
199
201
  file_path = file_paths.get(pid, "")
200
202
 
201
- id_to_entry[pid] = {
203
+ node: dict[str, Any] = {
202
204
  "title": page.get("title", ""),
203
205
  "file": os.path.relpath(file_path, output_dir) if file_path else "",
204
206
  "children": [],
205
207
  }
208
+ if labels_map.get(pid):
209
+ node["labels"] = labels_map[pid]
210
+ id_to_entry[pid] = node
206
211
  children_map.setdefault(parent_id, []).append(pid)
207
212
 
208
213
  def _attach_children(parent_id: str) -> list[dict[str, Any]]:
@@ -300,12 +305,16 @@ def pull_pages(
300
305
  os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
301
306
  Path(out_path).write_text(result.markdown, encoding="utf-8")
302
307
 
308
+ # Fetch labels
309
+ page_labels = [lbl["name"] for lbl in client.get_labels(pid)]
310
+
303
311
  files_result.append({
304
312
  "page_id": pid,
305
313
  "title": page_title,
306
314
  "file": out_path,
307
315
  "version": version_num,
308
316
  "attachments_downloaded": attachments_downloaded,
317
+ "labels": page_labels,
309
318
  })
310
319
 
311
320
  # Generate manifest if requested or recursive with multiple pages
@@ -315,7 +324,11 @@ def pull_pages(
315
324
  # Determine the actual parent of the root page
316
325
  ancestors = client.get_page_ancestors(root_id)
317
326
  manifest_parent = ancestors[-1].get("title", root_title) if ancestors else root_title
318
- page_tree = _build_page_tree(all_pages, file_paths, root_id, output_dir)
327
+ # Collect labels by page ID for manifest generation
328
+ pulled_labels: dict[str, list[str]] = {
329
+ f["page_id"]: f.get("labels", []) for f in files_result
330
+ }
331
+ page_tree = _build_page_tree(all_pages, file_paths, root_id, output_dir, page_labels=pulled_labels)
319
332
  manifest_yaml = generate_manifest_yaml(root_space, manifest_parent, page_tree)
320
333
  manifest_path = os.path.join(output_dir, "confpub.yaml")
321
334
  Path(manifest_path).write_text(manifest_yaml, encoding="utf-8")