confpub-cli 1.3.0__tar.gz → 1.4.1__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.3.0 → confpub_cli-1.4.1}/PKG-INFO +1 -1
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/__init__.py +1 -1
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/cli.py +41 -9
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/confluence.py +38 -9
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/converter.py +21 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/guide.py +15 -1
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/output.py +11 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/publish.py +11 -2
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/puller.py +3 -2
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/test_confluence.py +91 -2
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/test_converter.py +29 -1
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/test_integration.py +127 -1
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/test_output.py +16 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/test_publish.py +18 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/test_puller.py +50 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/.github/workflows/publish.yml +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/.gitignore +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/CLAUDE.md +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/LICENSE +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/PRD.md +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/README.md +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/applier.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/assets.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/config.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/envelope.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/errors.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/lockfile.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/manifest.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/planner.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/py.typed +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/reverse_converter.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/validator.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub/verifier.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/confpub.lock +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/pyproject.toml +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/__init__.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/conftest.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/test_applier.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/test_assets.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/test_config.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/test_envelope.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/test_errors.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/test_guide.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/test_lockfile.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/test_manifest.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/test_planner.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/test_reverse_converter.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/test_validator.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/tests/test_verifier.py +0 -0
- {confpub_cli-1.3.0 → confpub_cli-1.4.1}/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.4.1
|
|
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
|
|
@@ -16,7 +16,7 @@ import typer
|
|
|
16
16
|
from confpub import __version__
|
|
17
17
|
from confpub.envelope import Envelope
|
|
18
18
|
from confpub.errors import ConfpubError, exit_code_for, ERR_INTERNAL_SDK
|
|
19
|
-
from confpub.output import emit_stderr, emit_stdout, is_verbose, set_quiet, set_verbose
|
|
19
|
+
from confpub.output import emit_stderr, emit_stdout, is_compact, is_verbose, set_compact, set_quiet, set_verbose
|
|
20
20
|
|
|
21
21
|
# ---------------------------------------------------------------------------
|
|
22
22
|
# Subcommand group apps
|
|
@@ -26,10 +26,12 @@ from confpub.output import emit_stderr, emit_stdout, is_verbose, set_quiet, set_
|
|
|
26
26
|
def _group_callback(
|
|
27
27
|
quiet: bool = typer.Option(False, "--quiet", help="Suppress progress output on stderr"),
|
|
28
28
|
verbose: bool = typer.Option(False, "--verbose", help="Include diagnostics in result"),
|
|
29
|
+
compact: bool = typer.Option(False, "--compact", help="Output single-line JSON (no indentation)"),
|
|
29
30
|
) -> None:
|
|
30
|
-
"""Allow --quiet/--verbose between the group name and the subcommand."""
|
|
31
|
+
"""Allow --quiet/--verbose/--compact between the group name and the subcommand."""
|
|
31
32
|
set_quiet(quiet)
|
|
32
33
|
set_verbose(verbose)
|
|
34
|
+
set_compact(compact)
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
page_app = typer.Typer(help="Page operations", callback=_group_callback)
|
|
@@ -71,6 +73,7 @@ def _version_callback(value: bool) -> None:
|
|
|
71
73
|
def main_callback(
|
|
72
74
|
quiet: bool = typer.Option(False, "--quiet", help="Suppress progress output on stderr"),
|
|
73
75
|
verbose: bool = typer.Option(False, "--verbose", help="Include diagnostics in result"),
|
|
76
|
+
compact: bool = typer.Option(False, "--compact", help="Output single-line JSON (no indentation)"),
|
|
74
77
|
version: bool = typer.Option(
|
|
75
78
|
False, "--version", help="Show version and exit",
|
|
76
79
|
callback=_version_callback, is_eager=True,
|
|
@@ -79,6 +82,7 @@ def main_callback(
|
|
|
79
82
|
"""confpub — publish Markdown to Confluence."""
|
|
80
83
|
set_quiet(quiet)
|
|
81
84
|
set_verbose(verbose)
|
|
85
|
+
set_compact(compact)
|
|
82
86
|
|
|
83
87
|
|
|
84
88
|
# ---------------------------------------------------------------------------
|
|
@@ -94,6 +98,7 @@ class CommandResult:
|
|
|
94
98
|
self.target: dict[str, Any] | None = None
|
|
95
99
|
self.warnings: list[str] = []
|
|
96
100
|
self.metrics: dict[str, Any] = {}
|
|
101
|
+
self.client: Any = None
|
|
97
102
|
|
|
98
103
|
|
|
99
104
|
@contextmanager
|
|
@@ -117,7 +122,10 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
|
|
|
117
122
|
ctx.metrics["duration_ms"] = duration_ms
|
|
118
123
|
if is_verbose():
|
|
119
124
|
import traceback as tb
|
|
120
|
-
|
|
125
|
+
err_diag: dict[str, Any] = {"traceback": tb.format_exc()}
|
|
126
|
+
if ctx.client and hasattr(ctx.client, "_call_count"):
|
|
127
|
+
err_diag["api_call_count"] = ctx.client._call_count
|
|
128
|
+
ctx.metrics["diagnostics"] = err_diag
|
|
121
129
|
envelope = Envelope.failure(
|
|
122
130
|
command_name,
|
|
123
131
|
[e],
|
|
@@ -125,7 +133,7 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
|
|
|
125
133
|
warnings=ctx.warnings,
|
|
126
134
|
metrics=ctx.metrics,
|
|
127
135
|
)
|
|
128
|
-
emit_stdout(envelope.to_json_bytes())
|
|
136
|
+
emit_stdout(envelope.to_json_bytes(indent=not is_compact()))
|
|
129
137
|
raise typer.Exit(code=exit_code_for(e.code))
|
|
130
138
|
except typer.Exit:
|
|
131
139
|
raise
|
|
@@ -145,7 +153,7 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
|
|
|
145
153
|
warnings=ctx.warnings,
|
|
146
154
|
metrics=ctx.metrics,
|
|
147
155
|
)
|
|
148
|
-
emit_stdout(envelope.to_json_bytes())
|
|
156
|
+
emit_stdout(envelope.to_json_bytes(indent=not is_compact()))
|
|
149
157
|
raise typer.Exit(code=90)
|
|
150
158
|
else:
|
|
151
159
|
duration_ms = int((time.monotonic() - start) * 1000)
|
|
@@ -155,15 +163,20 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
|
|
|
155
163
|
from confpub.config import load_config as _load_verbose_config
|
|
156
164
|
|
|
157
165
|
diag: dict[str, Any] = {
|
|
166
|
+
"duration_ms": duration_ms,
|
|
158
167
|
"command": command_name,
|
|
159
168
|
"target": ctx.target,
|
|
160
169
|
"warning_count": len(ctx.warnings),
|
|
161
170
|
"python_version": sys.version,
|
|
162
171
|
"confpub_version": __version__,
|
|
163
172
|
}
|
|
173
|
+
if ctx.client and hasattr(ctx.client, "_call_count"):
|
|
174
|
+
diag["api_call_count"] = ctx.client._call_count
|
|
164
175
|
try:
|
|
165
176
|
_vcfg = _load_verbose_config()
|
|
177
|
+
diag["config_source"] = _vcfg.token_source
|
|
166
178
|
diag["confluence_url"] = _vcfg.base_url
|
|
179
|
+
diag["is_cloud"] = _vcfg.is_cloud
|
|
167
180
|
except Exception:
|
|
168
181
|
pass
|
|
169
182
|
ctx.metrics["diagnostics"] = diag
|
|
@@ -174,7 +187,7 @@ def command_context(command_name: str, target: dict[str, Any] | None = None) ->
|
|
|
174
187
|
warnings=ctx.warnings,
|
|
175
188
|
metrics=ctx.metrics,
|
|
176
189
|
)
|
|
177
|
-
emit_stdout(envelope.to_json_bytes())
|
|
190
|
+
emit_stdout(envelope.to_json_bytes(indent=not is_compact()))
|
|
178
191
|
|
|
179
192
|
|
|
180
193
|
# ---------------------------------------------------------------------------
|
|
@@ -192,8 +205,15 @@ def page_list(
|
|
|
192
205
|
with command_context("page.list", target={"space": space}) as ctx:
|
|
193
206
|
from confpub.confluence import build_client, _slim_page
|
|
194
207
|
client = build_client()
|
|
195
|
-
|
|
196
|
-
|
|
208
|
+
ctx.client = client
|
|
209
|
+
page_result = client.list_pages(space, start=start, limit=limit)
|
|
210
|
+
ctx.result = {
|
|
211
|
+
"pages": [_slim_page(p, base_url=client._config.base_url.rstrip("/"), is_cloud=client._config.is_cloud) for p in page_result["pages"]],
|
|
212
|
+
"start": page_result["start"],
|
|
213
|
+
"limit": page_result["limit"],
|
|
214
|
+
"size": page_result["size"],
|
|
215
|
+
"has_more": page_result["has_more"],
|
|
216
|
+
}
|
|
197
217
|
|
|
198
218
|
|
|
199
219
|
@page_app.command("inspect")
|
|
@@ -208,6 +228,7 @@ def page_inspect(
|
|
|
208
228
|
with command_context("page.inspect", target={"space": space, "title": title, "page_id": page_id}) as ctx:
|
|
209
229
|
from confpub.confluence import build_client, _slim_page
|
|
210
230
|
client = build_client()
|
|
231
|
+
ctx.client = client
|
|
211
232
|
if page_id:
|
|
212
233
|
page = client.get_page_by_id(page_id)
|
|
213
234
|
else:
|
|
@@ -240,6 +261,7 @@ def page_publish(
|
|
|
240
261
|
space: str = typer.Option(..., "--space", help="Confluence space key"),
|
|
241
262
|
parent: Optional[str] = typer.Option(None, "--parent", help="Parent page title"),
|
|
242
263
|
title: Optional[str] = typer.Option(None, "--title", help="Page title (defaults to filename stem, hyphen/underscore→spaces, title-cased)"),
|
|
264
|
+
title_from_h1: bool = typer.Option(False, "--title-from-h1", help="Derive title from first H1 heading in the Markdown file"),
|
|
243
265
|
page_id: Optional[str] = typer.Option(None, "--page-id", help="Confluence page ID (skip lookup, update directly)"),
|
|
244
266
|
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without writing"),
|
|
245
267
|
backup: bool = typer.Option(False, "--backup", help="Backup existing page before overwriting"),
|
|
@@ -247,7 +269,7 @@ def page_publish(
|
|
|
247
269
|
) -> None:
|
|
248
270
|
"""Publish a single Markdown file to Confluence."""
|
|
249
271
|
from confpub.publish import derive_title
|
|
250
|
-
resolved_title = derive_title(file, title)
|
|
272
|
+
resolved_title = derive_title(file, title, title_from_h1=title_from_h1)
|
|
251
273
|
target = {"space": space, "title": resolved_title, "file": file}
|
|
252
274
|
if page_id:
|
|
253
275
|
target["page_id"] = page_id
|
|
@@ -325,6 +347,7 @@ def page_delete(
|
|
|
325
347
|
)
|
|
326
348
|
from confpub.confluence import build_client
|
|
327
349
|
client = build_client()
|
|
350
|
+
ctx.client = client
|
|
328
351
|
|
|
329
352
|
# Collect descendant IDs before deleting (for lockfile cleanup)
|
|
330
353
|
deleted_ids: set[str] = set()
|
|
@@ -379,6 +402,7 @@ def page_move(
|
|
|
379
402
|
)
|
|
380
403
|
from confpub.confluence import build_client
|
|
381
404
|
client = build_client()
|
|
405
|
+
ctx.client = client
|
|
382
406
|
|
|
383
407
|
if target_parent_id:
|
|
384
408
|
# Use target_id directly — more reliable, no title resolution needed
|
|
@@ -400,6 +424,7 @@ def space_list() -> None:
|
|
|
400
424
|
with command_context("space.list") as ctx:
|
|
401
425
|
from confpub.confluence import build_client
|
|
402
426
|
client = build_client()
|
|
427
|
+
ctx.client = client
|
|
403
428
|
spaces = client.list_spaces()
|
|
404
429
|
ctx.result = {"spaces": spaces}
|
|
405
430
|
|
|
@@ -412,6 +437,7 @@ def attachment_list(
|
|
|
412
437
|
with command_context("attachment.list", target={"page_id": page_id}) as ctx:
|
|
413
438
|
from confpub.confluence import build_client, _slim_attachment
|
|
414
439
|
client = build_client()
|
|
440
|
+
ctx.client = client
|
|
415
441
|
attachments = client.get_attachments(page_id)
|
|
416
442
|
ctx.result = {"attachments": [_slim_attachment(a) for a in attachments]}
|
|
417
443
|
|
|
@@ -425,6 +451,7 @@ def attachment_upload(
|
|
|
425
451
|
with command_context("attachment.upload", target={"page_id": page_id, "file": file}) as ctx:
|
|
426
452
|
from confpub.confluence import build_client
|
|
427
453
|
client = build_client()
|
|
454
|
+
ctx.client = client
|
|
428
455
|
result = client.upload_attachment(page_id, file)
|
|
429
456
|
ctx.result = result
|
|
430
457
|
|
|
@@ -537,6 +564,7 @@ def label_list(
|
|
|
537
564
|
with command_context("label.list", target={"page_id": page_id}) as ctx:
|
|
538
565
|
from confpub.confluence import build_client
|
|
539
566
|
client = build_client()
|
|
567
|
+
ctx.client = client
|
|
540
568
|
labels = client.get_labels(page_id)
|
|
541
569
|
ctx.result = {"labels": labels, "count": len(labels)}
|
|
542
570
|
|
|
@@ -566,6 +594,7 @@ def label_add(
|
|
|
566
594
|
|
|
567
595
|
from confpub.confluence import build_client
|
|
568
596
|
client = build_client()
|
|
597
|
+
ctx.client = client
|
|
569
598
|
results = client.set_labels(page_id, label)
|
|
570
599
|
ctx.result = {"labels_added": label, "results": results}
|
|
571
600
|
|
|
@@ -579,6 +608,7 @@ def label_remove(
|
|
|
579
608
|
with command_context("label.remove", target={"page_id": page_id}) as ctx:
|
|
580
609
|
from confpub.confluence import build_client
|
|
581
610
|
client = build_client()
|
|
611
|
+
ctx.client = client
|
|
582
612
|
results = []
|
|
583
613
|
for lbl in label:
|
|
584
614
|
result = client.remove_label(page_id, lbl)
|
|
@@ -625,6 +655,7 @@ def comment_add(
|
|
|
625
655
|
|
|
626
656
|
from confpub.confluence import build_client
|
|
627
657
|
client = build_client()
|
|
658
|
+
ctx.client = client
|
|
628
659
|
result = client.add_comment(page_id, storage_body)
|
|
629
660
|
ctx.result = result
|
|
630
661
|
|
|
@@ -669,6 +700,7 @@ def search(
|
|
|
669
700
|
|
|
670
701
|
from confpub.confluence import build_client
|
|
671
702
|
client = build_client()
|
|
703
|
+
ctx.client = client
|
|
672
704
|
result = client.search(
|
|
673
705
|
effective_cql,
|
|
674
706
|
start=start,
|
|
@@ -63,7 +63,7 @@ class ConfluenceClient:
|
|
|
63
63
|
raise ConfpubError(
|
|
64
64
|
ERR_AUTH_FORBIDDEN,
|
|
65
65
|
f"Permission denied: {msg}",
|
|
66
|
-
suggested_action="
|
|
66
|
+
suggested_action="check_input",
|
|
67
67
|
details={"note": "Confluence returns HTTP 403 for both forbidden and nonexistent resources. Verify the resource exists."},
|
|
68
68
|
) from exc
|
|
69
69
|
if "timeout" in msg.lower() or "Timeout" in msg:
|
|
@@ -75,6 +75,7 @@ class ConfluenceClient:
|
|
|
75
75
|
raise ConfpubError(
|
|
76
76
|
ERR_AUTH_FORBIDDEN,
|
|
77
77
|
f"Permission denied ({context}): {msg}",
|
|
78
|
+
suggested_action="check_input",
|
|
78
79
|
details={"note": "This may indicate a nonexistent resource; Confluence returns 403 for both."},
|
|
79
80
|
) from exc
|
|
80
81
|
# Not found (404 or explicit "not found")
|
|
@@ -273,17 +274,27 @@ class ConfluenceClient:
|
|
|
273
274
|
self._handle_error(exc, "list_spaces")
|
|
274
275
|
return []
|
|
275
276
|
|
|
276
|
-
def list_pages(self, space: str, *, start: int = 0, limit: int = 25) ->
|
|
277
|
-
"""List pages in a space.
|
|
277
|
+
def list_pages(self, space: str, *, start: int = 0, limit: int = 25) -> dict[str, Any]:
|
|
278
|
+
"""List pages in a space.
|
|
279
|
+
|
|
280
|
+
Returns a dict with keys: pages, start, limit, size, has_more.
|
|
281
|
+
"""
|
|
278
282
|
self._call_count += 1
|
|
279
283
|
try:
|
|
280
284
|
result = self._api.get_all_pages_from_space(
|
|
281
285
|
space, start=start, limit=limit, expand="version",
|
|
282
286
|
)
|
|
283
|
-
|
|
287
|
+
pages = result if isinstance(result, list) else []
|
|
288
|
+
return {
|
|
289
|
+
"pages": pages,
|
|
290
|
+
"start": start,
|
|
291
|
+
"limit": limit,
|
|
292
|
+
"size": len(pages),
|
|
293
|
+
"has_more": len(pages) >= limit,
|
|
294
|
+
}
|
|
284
295
|
except Exception as exc:
|
|
285
296
|
self._handle_error(exc, "list_pages")
|
|
286
|
-
return []
|
|
297
|
+
return {"pages": [], "start": start, "limit": limit, "size": 0, "has_more": False}
|
|
287
298
|
|
|
288
299
|
# ------------------------------------------------------------------
|
|
289
300
|
# Attachment operations
|
|
@@ -351,7 +362,12 @@ class ConfluenceClient:
|
|
|
351
362
|
"""Upload an attachment to a page."""
|
|
352
363
|
self._call_count += 1
|
|
353
364
|
try:
|
|
354
|
-
|
|
365
|
+
import mimetypes
|
|
366
|
+
content_type, _ = mimetypes.guess_type(filepath)
|
|
367
|
+
kwargs: dict[str, Any] = {"page_id": page_id}
|
|
368
|
+
if content_type:
|
|
369
|
+
kwargs["content_type"] = content_type
|
|
370
|
+
result = self._api.attach_file(filepath, **kwargs)
|
|
355
371
|
if isinstance(result, dict):
|
|
356
372
|
# API returns {"results": [...]} wrapper — extract the attachment
|
|
357
373
|
if "results" in result and isinstance(result["results"], list) and result["results"]:
|
|
@@ -441,10 +457,16 @@ class ConfluenceClient:
|
|
|
441
457
|
self._call_count += 1
|
|
442
458
|
try:
|
|
443
459
|
result = self._api.set_page_label(page_id, lbl)
|
|
444
|
-
if isinstance(result, dict):
|
|
460
|
+
if isinstance(result, dict) and result.get("name"):
|
|
445
461
|
results.append(_slim_label(result))
|
|
446
462
|
elif isinstance(result, list):
|
|
447
|
-
results.extend(
|
|
463
|
+
results.extend(
|
|
464
|
+
_slim_label(r) if isinstance(r, dict) and r.get("name")
|
|
465
|
+
else {"name": lbl, "prefix": "global", "id": None}
|
|
466
|
+
for r in result
|
|
467
|
+
)
|
|
468
|
+
else:
|
|
469
|
+
results.append({"name": lbl, "prefix": "global", "id": None})
|
|
448
470
|
except Exception as exc:
|
|
449
471
|
self._handle_error(exc, "set_labels")
|
|
450
472
|
return results
|
|
@@ -498,11 +520,18 @@ class ConfluenceClient:
|
|
|
498
520
|
target_id=target_id,
|
|
499
521
|
position=position,
|
|
500
522
|
)
|
|
523
|
+
base_url = self._config.base_url.rstrip("/") if self._config.base_url else ""
|
|
524
|
+
page_data: dict[str, Any] | None = None
|
|
525
|
+
if isinstance(result, dict):
|
|
526
|
+
# The API may return the page directly or nested under a 'page' key
|
|
527
|
+
raw_page = result.get("page", result) if "page" in result else result
|
|
528
|
+
if raw_page.get("id"):
|
|
529
|
+
page_data = _slim_page(raw_page, base_url=base_url, is_cloud=self._config.is_cloud)
|
|
501
530
|
return {
|
|
502
531
|
"moved": True,
|
|
503
532
|
"page_id": page_id,
|
|
504
533
|
"target_parent": target_title or target_id,
|
|
505
|
-
"
|
|
534
|
+
"page": page_data,
|
|
506
535
|
}
|
|
507
536
|
except Exception as exc:
|
|
508
537
|
self._handle_error(exc, "move_page")
|
|
@@ -366,6 +366,27 @@ def convert_markdown(md_text: str) -> str:
|
|
|
366
366
|
return renderer.render(tokens, {}, {})
|
|
367
367
|
|
|
368
368
|
|
|
369
|
+
def extract_h1_title(md_text: str) -> str | None:
|
|
370
|
+
"""Extract the text of the first H1 heading from Markdown source.
|
|
371
|
+
|
|
372
|
+
Returns the title string, or None if no H1 is found.
|
|
373
|
+
"""
|
|
374
|
+
parser = _create_parser()
|
|
375
|
+
tokens = parser.parse(md_text)
|
|
376
|
+
for i, token in enumerate(tokens):
|
|
377
|
+
if token.type == "heading_open" and token.tag == "h1":
|
|
378
|
+
# The next token should be an inline token with the heading content
|
|
379
|
+
if i + 1 < len(tokens) and tokens[i + 1].type == "inline":
|
|
380
|
+
inline = tokens[i + 1]
|
|
381
|
+
if inline.children:
|
|
382
|
+
parts: list[str] = []
|
|
383
|
+
for child in inline.children:
|
|
384
|
+
if child.type in ("text", "code_inline"):
|
|
385
|
+
parts.append(child.content)
|
|
386
|
+
return "".join(parts) if parts else None
|
|
387
|
+
return None
|
|
388
|
+
|
|
389
|
+
|
|
369
390
|
def fingerprint_content(content: str) -> str:
|
|
370
391
|
"""Return SHA-256 hex digest of content."""
|
|
371
392
|
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
@@ -93,6 +93,17 @@ def build_guide() -> dict[str, Any]:
|
|
|
93
93
|
"mutates": False,
|
|
94
94
|
"description": "List pages in a Confluence space",
|
|
95
95
|
"flags": ["--space", "--limit", "--start"],
|
|
96
|
+
"result_schema": {
|
|
97
|
+
"pages": "list of slim page objects",
|
|
98
|
+
"start": "int — current offset",
|
|
99
|
+
"limit": "int — page size",
|
|
100
|
+
"size": "int — number of pages returned in this batch",
|
|
101
|
+
"has_more": "bool — true if more pages may be available (heuristic: size >= limit)",
|
|
102
|
+
},
|
|
103
|
+
"agent_hint": (
|
|
104
|
+
"Use --start and --limit for pagination: first call with --start 0 --limit 25, "
|
|
105
|
+
"then if has_more is true, call again with --start 25 --limit 25, and so on."
|
|
106
|
+
),
|
|
96
107
|
},
|
|
97
108
|
"page.inspect": {
|
|
98
109
|
"group": "read",
|
|
@@ -122,11 +133,13 @@ def build_guide() -> dict[str, Any]:
|
|
|
122
133
|
"group": "write",
|
|
123
134
|
"mutates": True,
|
|
124
135
|
"description": "Publish a single Markdown file to Confluence",
|
|
125
|
-
"flags": ["--space", "--parent", "--title", "--page-id", "--dry-run", "--backup", "--label"],
|
|
136
|
+
"flags": ["--space", "--parent", "--title", "--title-from-h1", "--page-id", "--dry-run", "--backup", "--label"],
|
|
126
137
|
"agent_hint": (
|
|
138
|
+
"Title precedence: explicit --title > --title-from-h1 (first H1 heading) > filename inference. "
|
|
127
139
|
"When --title is omitted, the title is inferred from the filename: "
|
|
128
140
|
"the stem is extracted, hyphens and underscores are replaced with spaces, "
|
|
129
141
|
"and the result is title-cased. E.g. 'my-cool-page.md' → 'My Cool Page'. "
|
|
142
|
+
"Use --title-from-h1 to extract the title from the first # heading in the file. "
|
|
130
143
|
"Use --label to apply labels (repeatable): --label api --label docs."
|
|
131
144
|
),
|
|
132
145
|
},
|
|
@@ -315,6 +328,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
315
328
|
"flags": {
|
|
316
329
|
"--quiet": "Suppress progress output on stderr",
|
|
317
330
|
"--verbose": "Include diagnostics in result",
|
|
331
|
+
"--compact": "Output single-line JSON (no indentation)",
|
|
318
332
|
"--version": "Show version and exit (top-level only)",
|
|
319
333
|
},
|
|
320
334
|
"placement": [
|
|
@@ -16,6 +16,7 @@ import orjson
|
|
|
16
16
|
# Module-level overrides set by the CLI layer
|
|
17
17
|
_quiet: bool | None = None
|
|
18
18
|
_verbose: bool | None = None
|
|
19
|
+
_compact: bool = False
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
def set_quiet(value: bool) -> None:
|
|
@@ -28,6 +29,16 @@ def set_verbose(value: bool) -> None:
|
|
|
28
29
|
_verbose = value
|
|
29
30
|
|
|
30
31
|
|
|
32
|
+
def set_compact(value: bool) -> None:
|
|
33
|
+
global _compact
|
|
34
|
+
_compact = value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_compact() -> bool:
|
|
38
|
+
"""True when JSON output should be single-line (no indentation)."""
|
|
39
|
+
return _compact
|
|
40
|
+
|
|
41
|
+
|
|
31
42
|
def is_llm_mode() -> bool:
|
|
32
43
|
"""True when LLM=true is set in the environment."""
|
|
33
44
|
return os.environ.get("LLM", "").lower() == "true"
|
|
@@ -23,10 +23,19 @@ from confpub.errors import (
|
|
|
23
23
|
from confpub.lockfile import Lockfile, load_lockfile, save_lockfile, update_lockfile
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def derive_title(file: str, title: str | None = None) -> str:
|
|
27
|
-
"""Derive page title from explicit title or filename.
|
|
26
|
+
def derive_title(file: str, title: str | None = None, *, title_from_h1: bool = False) -> str:
|
|
27
|
+
"""Derive page title from explicit title, H1 heading, or filename.
|
|
28
|
+
|
|
29
|
+
Precedence: explicit --title > --title-from-h1 > filename inference.
|
|
30
|
+
"""
|
|
28
31
|
if title:
|
|
29
32
|
return title
|
|
33
|
+
if title_from_h1:
|
|
34
|
+
from confpub.converter import extract_h1_title
|
|
35
|
+
md_text = Path(file).read_text(encoding="utf-8")
|
|
36
|
+
h1 = extract_h1_title(md_text)
|
|
37
|
+
if h1:
|
|
38
|
+
return h1
|
|
30
39
|
return Path(file).stem.replace("-", " ").replace("_", " ").title()
|
|
31
40
|
|
|
32
41
|
|
|
@@ -200,9 +200,10 @@ def _build_page_tree(
|
|
|
200
200
|
parent_id = str(entry["parent_id"]) if entry["parent_id"] else None
|
|
201
201
|
file_path = file_paths.get(pid, "")
|
|
202
202
|
|
|
203
|
+
rel = os.path.relpath(file_path, output_dir).replace("\\", "/") if file_path else ""
|
|
203
204
|
node: dict[str, Any] = {
|
|
204
205
|
"title": page.get("title", ""),
|
|
205
|
-
"file":
|
|
206
|
+
"file": rel,
|
|
206
207
|
"children": [],
|
|
207
208
|
}
|
|
208
209
|
if labels_map.get(pid):
|
|
@@ -317,7 +318,7 @@ def pull_pages(
|
|
|
317
318
|
"labels": page_labels,
|
|
318
319
|
})
|
|
319
320
|
|
|
320
|
-
# Generate manifest if requested
|
|
321
|
+
# Generate manifest if explicitly requested
|
|
321
322
|
manifest_file: str | None = None
|
|
322
323
|
if generate_manifest:
|
|
323
324
|
root_title = root_page.get("title", "")
|
|
@@ -135,8 +135,37 @@ class TestListPages:
|
|
|
135
135
|
{"id": "1", "title": "Page A"},
|
|
136
136
|
{"id": "2", "title": "Page B"},
|
|
137
137
|
]
|
|
138
|
-
|
|
139
|
-
assert len(pages) == 2
|
|
138
|
+
result = client.list_pages("DEV")
|
|
139
|
+
assert len(result["pages"]) == 2
|
|
140
|
+
assert result["size"] == 2
|
|
141
|
+
assert result["start"] == 0
|
|
142
|
+
assert result["limit"] == 25
|
|
143
|
+
assert result["has_more"] is False
|
|
144
|
+
|
|
145
|
+
def test_has_more_when_full_batch(self, client):
|
|
146
|
+
"""has_more should be True when batch size equals limit."""
|
|
147
|
+
client._mock_api.get_all_pages_from_space.return_value = [
|
|
148
|
+
{"id": str(i), "title": f"Page {i}"} for i in range(5)
|
|
149
|
+
]
|
|
150
|
+
result = client.list_pages("DEV", limit=5)
|
|
151
|
+
assert result["has_more"] is True
|
|
152
|
+
assert result["size"] == 5
|
|
153
|
+
|
|
154
|
+
def test_has_more_false_when_partial_batch(self, client):
|
|
155
|
+
"""has_more should be False when fewer results than limit."""
|
|
156
|
+
client._mock_api.get_all_pages_from_space.return_value = [
|
|
157
|
+
{"id": "1", "title": "Only Page"},
|
|
158
|
+
]
|
|
159
|
+
result = client.list_pages("DEV", limit=25)
|
|
160
|
+
assert result["has_more"] is False
|
|
161
|
+
assert result["size"] == 1
|
|
162
|
+
|
|
163
|
+
def test_pagination_params_passed_through(self, client):
|
|
164
|
+
"""start and limit should be passed through to the result."""
|
|
165
|
+
client._mock_api.get_all_pages_from_space.return_value = []
|
|
166
|
+
result = client.list_pages("DEV", start=50, limit=10)
|
|
167
|
+
assert result["start"] == 50
|
|
168
|
+
assert result["limit"] == 10
|
|
140
169
|
|
|
141
170
|
|
|
142
171
|
class TestAttachments:
|
|
@@ -152,6 +181,20 @@ class TestAttachments:
|
|
|
152
181
|
result = client.upload_attachment("123", "/tmp/file.png")
|
|
153
182
|
assert result["title"] == "file.png"
|
|
154
183
|
|
|
184
|
+
def test_upload_attachment_passes_content_type(self, client):
|
|
185
|
+
"""Suggestion 3: upload_attachment should detect and pass content_type for known types."""
|
|
186
|
+
client._mock_api.attach_file.return_value = {"title": "readme.txt"}
|
|
187
|
+
client.upload_attachment("123", "/tmp/readme.txt")
|
|
188
|
+
call_kwargs = client._mock_api.attach_file.call_args
|
|
189
|
+
assert call_kwargs[1].get("content_type") == "text/plain"
|
|
190
|
+
|
|
191
|
+
def test_upload_attachment_png_content_type(self, client):
|
|
192
|
+
"""MIME type detection for .png files."""
|
|
193
|
+
client._mock_api.attach_file.return_value = {"title": "image.png"}
|
|
194
|
+
client.upload_attachment("123", "/tmp/image.png")
|
|
195
|
+
call_kwargs = client._mock_api.attach_file.call_args
|
|
196
|
+
assert call_kwargs[1].get("content_type") == "image/png"
|
|
197
|
+
|
|
155
198
|
|
|
156
199
|
class TestDownloadAttachment:
|
|
157
200
|
def test_relative_url_uses_api_url(self, client, tmp_path):
|
|
@@ -256,6 +299,22 @@ class TestLabels:
|
|
|
256
299
|
assert client._mock_api.set_page_label.call_count == 2
|
|
257
300
|
assert len(results) == 2
|
|
258
301
|
|
|
302
|
+
def test_set_labels_empty_response_falls_back(self, client):
|
|
303
|
+
"""When API returns empty/malformed dict, fall back to input label name."""
|
|
304
|
+
client._mock_api.set_page_label.return_value = {}
|
|
305
|
+
results = client.set_labels("123", ["my-label"])
|
|
306
|
+
assert len(results) == 1
|
|
307
|
+
assert results[0]["name"] == "my-label"
|
|
308
|
+
assert results[0]["prefix"] == "global"
|
|
309
|
+
assert results[0]["id"] is None
|
|
310
|
+
|
|
311
|
+
def test_set_labels_list_response_with_empty_entries(self, client):
|
|
312
|
+
"""When API returns a list with empty entries, fall back to input label name."""
|
|
313
|
+
client._mock_api.set_page_label.return_value = [{}]
|
|
314
|
+
results = client.set_labels("123", ["tag1"])
|
|
315
|
+
assert len(results) == 1
|
|
316
|
+
assert results[0]["name"] == "tag1"
|
|
317
|
+
|
|
259
318
|
def test_set_labels_call_count(self, client):
|
|
260
319
|
"""Each label should increment _call_count."""
|
|
261
320
|
initial = client._call_count
|
|
@@ -319,6 +378,18 @@ class TestMovePage:
|
|
|
319
378
|
"DEV", "123", target_title=None, target_id="456", position="append",
|
|
320
379
|
)
|
|
321
380
|
|
|
381
|
+
def test_move_page_returns_normalized_page(self, client):
|
|
382
|
+
"""Bug 3: move_page should return a 'page' key with slim page data, not raw 'result'."""
|
|
383
|
+
client._mock_api.move_page.return_value = {
|
|
384
|
+
"page": {"id": "123", "title": "My Page", "version": {"number": 3, "when": "2026-01-01"}}
|
|
385
|
+
}
|
|
386
|
+
result = client.move_page("DEV", "123", target_title="New Parent")
|
|
387
|
+
assert "result" not in result
|
|
388
|
+
assert "page" in result
|
|
389
|
+
assert result["page"]["id"] == "123"
|
|
390
|
+
assert result["page"]["title"] == "My Page"
|
|
391
|
+
assert result["moved"] is True
|
|
392
|
+
|
|
322
393
|
def test_move_page_not_found(self, client):
|
|
323
394
|
client._mock_api.move_page.side_effect = Exception("404 Not Found")
|
|
324
395
|
with pytest.raises(ConfpubError) as exc_info:
|
|
@@ -348,6 +419,24 @@ class TestErrorTranslation:
|
|
|
348
419
|
client.list_spaces()
|
|
349
420
|
assert exc_info.value.code == ERR_IO_CONNECTION
|
|
350
421
|
|
|
422
|
+
def test_403_has_check_input_action(self, client):
|
|
423
|
+
"""Bug 5: 403 errors should have suggested_action='check_input'."""
|
|
424
|
+
client._mock_api.get_all_spaces.side_effect = Exception("403 Forbidden")
|
|
425
|
+
with pytest.raises(ConfpubError) as exc_info:
|
|
426
|
+
client.list_spaces()
|
|
427
|
+
assert exc_info.value.code == ERR_AUTH_FORBIDDEN
|
|
428
|
+
assert exc_info.value.suggested_action == "check_input"
|
|
429
|
+
|
|
430
|
+
def test_permission_denied_has_check_input_action(self, client):
|
|
431
|
+
"""Bug 5: permission-denied errors should have suggested_action='check_input'."""
|
|
432
|
+
client._mock_api.get_all_spaces.side_effect = Exception(
|
|
433
|
+
"User does not have permission to view the content"
|
|
434
|
+
)
|
|
435
|
+
with pytest.raises(ConfpubError) as exc_info:
|
|
436
|
+
client.list_spaces()
|
|
437
|
+
assert exc_info.value.code == ERR_AUTH_FORBIDDEN
|
|
438
|
+
assert exc_info.value.suggested_action == "check_input"
|
|
439
|
+
|
|
351
440
|
def test_permission_error(self, client):
|
|
352
441
|
client._mock_api.get_all_spaces.side_effect = Exception(
|
|
353
442
|
"User does not have permission to view the content"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Tests for confpub.converter module."""
|
|
2
2
|
|
|
3
|
-
from confpub.converter import convert_markdown, fingerprint_content
|
|
3
|
+
from confpub.converter import convert_markdown, extract_h1_title, fingerprint_content
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class TestHeadings:
|
|
@@ -220,3 +220,31 @@ def hello():
|
|
|
220
220
|
assert "<blockquote>" in result
|
|
221
221
|
assert "<hr />" in result
|
|
222
222
|
assert '<a href="https://example.com">' in result
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class TestExtractH1Title:
|
|
226
|
+
"""Suggestion 2: extract_h1_title should extract text from first H1 heading."""
|
|
227
|
+
|
|
228
|
+
def test_simple_h1(self):
|
|
229
|
+
assert extract_h1_title("# My Page Title") == "My Page Title"
|
|
230
|
+
|
|
231
|
+
def test_h1_with_body(self):
|
|
232
|
+
md = "# Getting Started\n\nSome content here."
|
|
233
|
+
assert extract_h1_title(md) == "Getting Started"
|
|
234
|
+
|
|
235
|
+
def test_no_h1_returns_none(self):
|
|
236
|
+
assert extract_h1_title("## Only H2\n\nNo H1 here.") is None
|
|
237
|
+
|
|
238
|
+
def test_empty_returns_none(self):
|
|
239
|
+
assert extract_h1_title("") is None
|
|
240
|
+
|
|
241
|
+
def test_h1_with_inline_code(self):
|
|
242
|
+
assert extract_h1_title("# Using `confpub` CLI") == "Using confpub CLI"
|
|
243
|
+
|
|
244
|
+
def test_first_h1_wins(self):
|
|
245
|
+
md = "# First Title\n\n## Sub\n\n# Second Title"
|
|
246
|
+
assert extract_h1_title(md) == "First Title"
|
|
247
|
+
|
|
248
|
+
def test_h2_before_h1(self):
|
|
249
|
+
md = "## Intro\n\n# Main Title"
|
|
250
|
+
assert extract_h1_title(md) == "Main Title"
|
|
@@ -112,7 +112,7 @@ class TestPersonalSpaceKeyCLI:
|
|
|
112
112
|
|
|
113
113
|
def fake_list_pages(self, space, **kwargs):
|
|
114
114
|
captured["space"] = space
|
|
115
|
-
return []
|
|
115
|
+
return {"pages": [], "start": 0, "limit": 25, "size": 0, "has_more": False}
|
|
116
116
|
|
|
117
117
|
from confpub.confluence import ConfluenceClient
|
|
118
118
|
monkeypatch.setattr(ConfluenceClient, "list_pages", fake_list_pages)
|
|
@@ -309,6 +309,132 @@ class TestPageMoveValidation:
|
|
|
309
309
|
assert "space" in data["errors"][0]["message"].lower()
|
|
310
310
|
|
|
311
311
|
|
|
312
|
+
class TestCompactFlag:
|
|
313
|
+
"""Suggestion 4: --compact should produce single-line JSON output."""
|
|
314
|
+
|
|
315
|
+
def test_compact_produces_single_line(self):
|
|
316
|
+
result = runner.invoke(app, ["--compact", "guide"])
|
|
317
|
+
assert result.exit_code == 0
|
|
318
|
+
# Compact output should be a single line (no newlines within the JSON)
|
|
319
|
+
lines = result.output.strip().split("\n")
|
|
320
|
+
assert len(lines) == 1
|
|
321
|
+
data = json.loads(lines[0])
|
|
322
|
+
assert data["ok"] is True
|
|
323
|
+
|
|
324
|
+
def test_compact_between_group_and_command(self, monkeypatch):
|
|
325
|
+
"""--compact should work between group name and subcommand."""
|
|
326
|
+
def fake_list_pages(self, space, **kwargs):
|
|
327
|
+
return {"pages": [], "start": 0, "limit": 25, "size": 0, "has_more": False}
|
|
328
|
+
|
|
329
|
+
from confpub.confluence import ConfluenceClient
|
|
330
|
+
monkeypatch.setattr(ConfluenceClient, "list_pages", fake_list_pages)
|
|
331
|
+
monkeypatch.setattr("confpub.confluence.build_client", lambda: ConfluenceClient.__new__(ConfluenceClient))
|
|
332
|
+
|
|
333
|
+
result = runner.invoke(app, ["page", "--compact", "list", "--space", "DEV"])
|
|
334
|
+
assert result.exit_code == 0
|
|
335
|
+
lines = result.output.strip().split("\n")
|
|
336
|
+
assert len(lines) == 1
|
|
337
|
+
|
|
338
|
+
def test_without_compact_is_multiline(self):
|
|
339
|
+
result = runner.invoke(app, ["guide"])
|
|
340
|
+
assert result.exit_code == 0
|
|
341
|
+
lines = result.output.strip().split("\n")
|
|
342
|
+
assert len(lines) > 1
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class TestVerboseDiagnostics:
|
|
346
|
+
"""Bug 6: --verbose should include rich diagnostics."""
|
|
347
|
+
|
|
348
|
+
def test_verbose_includes_diagnostics(self):
|
|
349
|
+
result = runner.invoke(app, ["--verbose", "guide"])
|
|
350
|
+
assert result.exit_code == 0
|
|
351
|
+
data = json.loads(result.output)
|
|
352
|
+
assert "diagnostics" in data["metrics"]
|
|
353
|
+
diag = data["metrics"]["diagnostics"]
|
|
354
|
+
assert "duration_ms" in diag
|
|
355
|
+
assert "confpub_version" in diag
|
|
356
|
+
assert "command" in diag
|
|
357
|
+
|
|
358
|
+
def test_verbose_with_client_includes_api_call_count(self, monkeypatch):
|
|
359
|
+
"""When a client is used, diagnostics should include api_call_count."""
|
|
360
|
+
def fake_list_pages(self, space, **kwargs):
|
|
361
|
+
self._call_count += 1
|
|
362
|
+
return {"pages": [], "start": 0, "limit": 25, "size": 0, "has_more": False}
|
|
363
|
+
|
|
364
|
+
from confpub.confluence import ConfluenceClient
|
|
365
|
+
from confpub.config import ResolvedConfig
|
|
366
|
+
from unittest.mock import patch, MagicMock
|
|
367
|
+
|
|
368
|
+
mock_config = ResolvedConfig(
|
|
369
|
+
base_url="https://test.atlassian.net/wiki",
|
|
370
|
+
user="user@test.com",
|
|
371
|
+
token="test_token",
|
|
372
|
+
token_source="env_var",
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
with patch("confpub.confluence.ConfluenceClient._build_api") as mock_build:
|
|
376
|
+
mock_build.return_value = MagicMock()
|
|
377
|
+
real_client = ConfluenceClient(mock_config)
|
|
378
|
+
|
|
379
|
+
monkeypatch.setattr(ConfluenceClient, "list_pages", fake_list_pages)
|
|
380
|
+
monkeypatch.setattr("confpub.confluence.build_client", lambda: real_client)
|
|
381
|
+
|
|
382
|
+
result = runner.invoke(app, ["page", "--verbose", "list", "--space", "DEV"])
|
|
383
|
+
assert result.exit_code == 0
|
|
384
|
+
data = json.loads(result.output)
|
|
385
|
+
assert "diagnostics" in data["metrics"]
|
|
386
|
+
diag = data["metrics"]["diagnostics"]
|
|
387
|
+
assert "api_call_count" in diag
|
|
388
|
+
assert diag["api_call_count"] >= 1
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class TestPageListPagination:
|
|
392
|
+
"""Suggestion 1: page.list should return pagination metadata."""
|
|
393
|
+
|
|
394
|
+
def test_page_list_has_pagination_fields(self, monkeypatch):
|
|
395
|
+
def fake_list_pages(self, space, **kwargs):
|
|
396
|
+
return {
|
|
397
|
+
"pages": [{"id": "1", "title": "P1"}],
|
|
398
|
+
"start": 0, "limit": 25, "size": 1, "has_more": False,
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
from confpub.confluence import ConfluenceClient
|
|
402
|
+
from confpub.config import ResolvedConfig
|
|
403
|
+
from unittest.mock import patch, MagicMock
|
|
404
|
+
|
|
405
|
+
mock_config = ResolvedConfig(
|
|
406
|
+
base_url="https://test.atlassian.net/wiki",
|
|
407
|
+
user="user@test.com",
|
|
408
|
+
token="test_token",
|
|
409
|
+
token_source="env_var",
|
|
410
|
+
)
|
|
411
|
+
with patch("confpub.confluence.ConfluenceClient._build_api") as mock_build:
|
|
412
|
+
mock_build.return_value = MagicMock()
|
|
413
|
+
real_client = ConfluenceClient(mock_config)
|
|
414
|
+
|
|
415
|
+
monkeypatch.setattr(ConfluenceClient, "list_pages", fake_list_pages)
|
|
416
|
+
monkeypatch.setattr("confpub.confluence.build_client", lambda: real_client)
|
|
417
|
+
|
|
418
|
+
result = runner.invoke(app, ["page", "list", "--space", "DEV"])
|
|
419
|
+
assert result.exit_code == 0
|
|
420
|
+
data = json.loads(result.output)
|
|
421
|
+
r = data["result"]
|
|
422
|
+
assert "pages" in r
|
|
423
|
+
assert "start" in r
|
|
424
|
+
assert "limit" in r
|
|
425
|
+
assert "size" in r
|
|
426
|
+
assert "has_more" in r
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class TestTitleFromH1Flag:
|
|
430
|
+
"""Suggestion 2: --title-from-h1 flag should be available on page publish."""
|
|
431
|
+
|
|
432
|
+
def test_title_from_h1_in_help(self):
|
|
433
|
+
result = runner.invoke(app, ["page", "publish", "--help"])
|
|
434
|
+
assert result.exit_code == 0
|
|
435
|
+
assert "--title-from-h1" in result.output
|
|
436
|
+
|
|
437
|
+
|
|
312
438
|
class TestEnvelopeContract:
|
|
313
439
|
def test_guide_returns_full_envelope(self):
|
|
314
440
|
result = runner.invoke(app, ["guide"])
|
|
@@ -8,9 +8,11 @@ from confpub.output import (
|
|
|
8
8
|
emit_stderr,
|
|
9
9
|
emit_stdout,
|
|
10
10
|
is_ci,
|
|
11
|
+
is_compact,
|
|
11
12
|
is_llm_mode,
|
|
12
13
|
is_quiet,
|
|
13
14
|
is_verbose,
|
|
15
|
+
set_compact,
|
|
14
16
|
set_quiet,
|
|
15
17
|
set_verbose,
|
|
16
18
|
)
|
|
@@ -97,3 +99,17 @@ class TestEmitStderr:
|
|
|
97
99
|
captured = capsys.readouterr()
|
|
98
100
|
assert captured.err == ""
|
|
99
101
|
set_quiet(None) # type: ignore[arg-type]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestCompactMode:
|
|
105
|
+
"""Suggestion 4: compact mode plumbing."""
|
|
106
|
+
|
|
107
|
+
def test_set_compact(self):
|
|
108
|
+
set_compact(True)
|
|
109
|
+
assert is_compact() is True
|
|
110
|
+
set_compact(False)
|
|
111
|
+
assert is_compact() is False
|
|
112
|
+
|
|
113
|
+
def test_default_is_not_compact(self):
|
|
114
|
+
set_compact(False)
|
|
115
|
+
assert is_compact() is False
|
|
@@ -46,6 +46,24 @@ class TestDeriveTitle:
|
|
|
46
46
|
def test_path_uses_stem_only(self):
|
|
47
47
|
assert derive_title("docs/subfolder/overview.md") == "Overview"
|
|
48
48
|
|
|
49
|
+
def test_title_from_h1(self, tmp_path):
|
|
50
|
+
"""Suggestion 2: derive_title with title_from_h1=True extracts H1."""
|
|
51
|
+
md_file = tmp_path / "test.md"
|
|
52
|
+
md_file.write_text("# My Custom Title\n\nContent here.")
|
|
53
|
+
assert derive_title(str(md_file), title_from_h1=True) == "My Custom Title"
|
|
54
|
+
|
|
55
|
+
def test_title_from_h1_falls_back_to_filename(self, tmp_path):
|
|
56
|
+
"""When no H1 is found, fall back to filename inference."""
|
|
57
|
+
md_file = tmp_path / "api-docs.md"
|
|
58
|
+
md_file.write_text("## Only H2\n\nNo H1 here.")
|
|
59
|
+
assert derive_title(str(md_file), title_from_h1=True) == "Api Docs"
|
|
60
|
+
|
|
61
|
+
def test_explicit_title_beats_h1(self, tmp_path):
|
|
62
|
+
"""Explicit --title should win over --title-from-h1."""
|
|
63
|
+
md_file = tmp_path / "test.md"
|
|
64
|
+
md_file.write_text("# H1 Title\n\nContent here.")
|
|
65
|
+
assert derive_title(str(md_file), "Explicit Title", title_from_h1=True) == "Explicit Title"
|
|
66
|
+
|
|
49
67
|
|
|
50
68
|
class TestPublishDryRun:
|
|
51
69
|
@patch("confpub.publish.load_config")
|
|
@@ -511,6 +511,56 @@ class TestMultiLevelRecursivePull:
|
|
|
511
511
|
# ---------------------------------------------------------------------------
|
|
512
512
|
|
|
513
513
|
|
|
514
|
+
class TestManifestPathSeparators:
|
|
515
|
+
"""Bug 1: Manifest file paths must use forward slashes on all platforms."""
|
|
516
|
+
|
|
517
|
+
def test_manifest_paths_no_backslashes(self, tmp_path):
|
|
518
|
+
root = _make_page("1", "Root")
|
|
519
|
+
child = _make_page("2", "Child")
|
|
520
|
+
pages = {"1": root, "2": child}
|
|
521
|
+
children = {"1": [child]}
|
|
522
|
+
client = _mock_client(pages, children)
|
|
523
|
+
|
|
524
|
+
with patch("confpub.puller.build_client", return_value=client):
|
|
525
|
+
result = pull_pages(
|
|
526
|
+
page_id="1",
|
|
527
|
+
output_dir=str(tmp_path),
|
|
528
|
+
recursive=True,
|
|
529
|
+
layout="nested",
|
|
530
|
+
generate_manifest=True,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
manifest_content = Path(result["manifest_file"]).read_text()
|
|
534
|
+
# No backslashes should appear in any file paths in the manifest
|
|
535
|
+
for line in manifest_content.split("\n"):
|
|
536
|
+
if "file:" in line:
|
|
537
|
+
assert "\\" not in line, f"Backslash found in manifest path: {line}"
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
class TestNestedLayoutNoAutoManifest:
|
|
541
|
+
"""Bug 4: --layout nested without --manifest should NOT create confpub.yaml."""
|
|
542
|
+
|
|
543
|
+
def test_nested_without_manifest_flag(self, tmp_path):
|
|
544
|
+
root = _make_page("1", "Root")
|
|
545
|
+
child = _make_page("2", "Child")
|
|
546
|
+
pages = {"1": root, "2": child}
|
|
547
|
+
children = {"1": [child]}
|
|
548
|
+
client = _mock_client(pages, children)
|
|
549
|
+
|
|
550
|
+
with patch("confpub.puller.build_client", return_value=client):
|
|
551
|
+
result = pull_pages(
|
|
552
|
+
page_id="1",
|
|
553
|
+
output_dir=str(tmp_path),
|
|
554
|
+
recursive=True,
|
|
555
|
+
layout="nested",
|
|
556
|
+
generate_manifest=False,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
assert result["summary"]["manifest_generated"] is False
|
|
560
|
+
assert result["manifest_file"] is None
|
|
561
|
+
assert not (tmp_path / "confpub.yaml").exists()
|
|
562
|
+
|
|
563
|
+
|
|
514
564
|
class TestManifestFlag:
|
|
515
565
|
def test_single_page_with_manifest_flag(self, tmp_path):
|
|
516
566
|
"""--manifest generates confpub.yaml even for a single page."""
|
|
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
|