confpub-cli 0.5.0__tar.gz → 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- confpub_cli-0.6.0/FEEDBACK.md +197 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/PKG-INFO +1 -1
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/__init__.py +1 -1
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/applier.py +2 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/cli.py +17 -2
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/confluence.py +5 -5
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/guide.py +17 -1
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/manifest.py +6 -1
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/planner.py +2 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/publish.py +2 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/puller.py +7 -5
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/validator.py +7 -2
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/verifier.py +12 -2
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/test_guide.py +1 -1
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/test_puller.py +3 -1
- confpub_cli-0.5.0/FEEDBACK.md +0 -226
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/.github/workflows/publish.yml +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/.gitignore +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/LICENSE +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/PRD.md +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/README.md +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/assets.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/config.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/converter.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/envelope.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/errors.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/lockfile.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/output.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/py.typed +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub/reverse_converter.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/confpub.lock +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/pyproject.toml +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/__init__.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/conftest.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/test_applier.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/test_assets.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/test_config.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/test_confluence.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/test_converter.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/test_envelope.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/test_errors.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/test_integration.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/test_lockfile.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/test_manifest.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/test_output.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/test_planner.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/test_publish.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/test_reverse_converter.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/test_validator.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/tests/test_verifier.py +0 -0
- {confpub_cli-0.5.0 → confpub_cli-0.6.0}/uv.lock +0 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# confpub-cli v0.5.0 — Blind Test Feedback
|
|
2
|
+
|
|
3
|
+
Tested by: Claude Code (Opus 4.6) on 2026-03-01, Windows 11, via `uvx confpub-cli`.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Summary
|
|
8
|
+
|
|
9
|
+
confpub v0.5.0 is a well-designed, agent-friendly CLI. The structured JSON envelope, clear error taxonomy, and comprehensive `guide` command make it straightforward for an LLM agent to drive programmatically. The full publish/pull/delete lifecycle works correctly, and round-trip Markdown fidelity is excellent. This review covers what works well, what could be improved, and specific bugs encountered.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## What Works Well
|
|
14
|
+
|
|
15
|
+
### Structured JSON Envelope
|
|
16
|
+
Every command returns the same `{ ok, command, target, result, warnings, errors, metrics }` shape. This is the single most important design decision for agent consumption — no output parsing required.
|
|
17
|
+
|
|
18
|
+
### Error Taxonomy
|
|
19
|
+
Typed error codes (`ERR_VALIDATION_*`, `ERR_AUTH_*`, `ERR_IO_*`, `ERR_CONFLICT_*`) with `retryable` and `suggested_action` fields make programmatic error handling trivial. Exit codes are consistent with the documented schema.
|
|
20
|
+
|
|
21
|
+
### `guide` Command
|
|
22
|
+
The machine-readable schema is comprehensive: command metadata, flags, safety annotations, concurrency rules, error codes, and auth precedence. The `--section` flag works for top-level keys (`commands`, `auth`, `error_codes`, `concurrency`).
|
|
23
|
+
|
|
24
|
+
### Markdown Round-Trip Fidelity
|
|
25
|
+
Published a file with headings, bullet lists, tables, blockquotes, and fenced code blocks. Pulled it back — the Markdown was virtually identical (only trivial whitespace differences in table alignment `| --- |` vs `|---------|`). This is a strong result.
|
|
26
|
+
|
|
27
|
+
### Publish Lifecycle
|
|
28
|
+
- `page.publish` with `--dry-run` correctly reports what would happen before writing.
|
|
29
|
+
- Re-publishing an existing page updates it in-place with version increment.
|
|
30
|
+
- `--backup` flag saves the previous HTML to `.confpub-backup-{id}.html`.
|
|
31
|
+
- `--title` override works as expected.
|
|
32
|
+
- `page.delete` cleanly removes the page.
|
|
33
|
+
|
|
34
|
+
### Recursive Pull + Manifest Generation
|
|
35
|
+
`page pull --recursive --manifest` correctly traverses child pages and generates a well-structured `confpub.yaml` manifest. The flat layout manifest correctly represents the page hierarchy with `children:` nesting.
|
|
36
|
+
|
|
37
|
+
### Plan Workflow
|
|
38
|
+
The `plan create` / `plan validate` / `plan apply --dry-run` workflow functions correctly. Plans include fingerprints for stale-state detection, which is a nice safety feature.
|
|
39
|
+
|
|
40
|
+
### Lock File
|
|
41
|
+
`confpub.lock` tracks page IDs and versions, providing local state awareness across commands.
|
|
42
|
+
|
|
43
|
+
### Auth & Config
|
|
44
|
+
`auth inspect` and `config inspect` return clear, useful information. Token masking in `config inspect` is a good security practice.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Bugs
|
|
49
|
+
|
|
50
|
+
### 1. Stderr Message Leaks on Expected "Not Found" Lookups
|
|
51
|
+
**Severity: Medium**
|
|
52
|
+
|
|
53
|
+
When publishing a new page (or doing a dry-run), the CLI prints `Can't find '<title>' page on ...` to stderr before the JSON output. This happens because the tool checks whether the page exists first. For a *new* page, not finding it is the expected path — not an error. The message is confusing, especially during `--dry-run` where the user explicitly wants to preview a creation.
|
|
54
|
+
|
|
55
|
+
`--quiet` suppresses it, but agents shouldn't need `--quiet` for expected behavior.
|
|
56
|
+
|
|
57
|
+
**Suggestion:** Only emit this message at `--verbose` level, or suppress it when the subsequent operation succeeds.
|
|
58
|
+
|
|
59
|
+
### 2. Relative Paths Fail for `plan validate` and `plan apply`
|
|
60
|
+
**Severity: Medium**
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
uvx confpub-cli plan validate --plan plan-test/test-plan.json
|
|
64
|
+
# ERR_IO_FILE_NOT_FOUND: Plan file not found: plan-test/test-plan.json
|
|
65
|
+
|
|
66
|
+
uvx confpub-cli plan validate --plan "C:/Users/.../test-plan.json"
|
|
67
|
+
# Works fine
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Relative paths resolve correctly for `page publish` (the FILE argument) but not for `--plan` in plan commands. This is likely a `Path.resolve()` vs `Path()` issue.
|
|
71
|
+
|
|
72
|
+
### 3. `page inspect` `webui` Field Inconsistent Format
|
|
73
|
+
**Severity: Low**
|
|
74
|
+
|
|
75
|
+
- With `--space SD --title "..."`: returns relative URL `/spaces/SD/pages/327981/Test+Page`
|
|
76
|
+
- With `--page-id 327981`: returns absolute URL `https://...atlassian.net/wiki/spaces/SD/pages/327981/Test+Page`
|
|
77
|
+
|
|
78
|
+
Should be consistent — preferably always absolute, since the agent may not know the base URL.
|
|
79
|
+
|
|
80
|
+
### 4. `ERR_AUTH_FORBIDDEN` for Nonexistent Space
|
|
81
|
+
**Severity: Low**
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
uvx confpub-cli page inspect --space FAKESPACE --title "Test"
|
|
85
|
+
# ERR_AUTH_FORBIDDEN: "Permission denied (get_page)"
|
|
86
|
+
# suggested_action: "escalate"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A nonexistent space key returns a permission error rather than a "space not found" validation error. The `suggested_action` is `"escalate"` but the guide says ERR_AUTH_FORBIDDEN should suggest `"reauth"`. An agent following the guide would incorrectly attempt to re-authenticate.
|
|
90
|
+
|
|
91
|
+
### 5. `guide --section` Does Not List Valid Sections on Error
|
|
92
|
+
**Severity: Low**
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
uvx confpub-cli guide --section search
|
|
96
|
+
# ERR_VALIDATION_REQUIRED: "Unknown guide section: search"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The error doesn't tell you what the valid sections are. Adding `"valid_sections": ["commands", "auth", "error_codes", "concurrency", "compatibility"]` to the error details would save a round-trip.
|
|
100
|
+
|
|
101
|
+
### 6. Nested Layout Manifest Uses Ambiguous `file: index.md` for All Pages
|
|
102
|
+
**Severity: Medium**
|
|
103
|
+
|
|
104
|
+
When using `--layout nested`, every page gets `file: index.md` in the manifest without a directory prefix:
|
|
105
|
+
|
|
106
|
+
```yaml
|
|
107
|
+
pages:
|
|
108
|
+
- title: confpub v0.3.0 Blind Test Report
|
|
109
|
+
file: index.md
|
|
110
|
+
children:
|
|
111
|
+
- title: What Works Well
|
|
112
|
+
file: index.md
|
|
113
|
+
- title: Bugs and Issues
|
|
114
|
+
file: index.md
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
These are all `index.md` — a plan created from this manifest would have no way to distinguish them. The file paths should include the relative directory (e.g., `confpub-v030-blind-test-report/index.md`).
|
|
118
|
+
|
|
119
|
+
### 7. Nested Layout Doesn't Actually Nest Directories
|
|
120
|
+
**Severity: Low**
|
|
121
|
+
|
|
122
|
+
The `--layout nested` option creates a flat set of directories rather than truly nesting children inside parents:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
pulled-nested/
|
|
126
|
+
confpub-v030-blind-test-report/index.md # parent
|
|
127
|
+
what-works-well/index.md # child (not nested inside parent dir)
|
|
128
|
+
bugs-and-issues/index.md # child
|
|
129
|
+
full-test-matrix/index.md # child
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Expected behavior for "nested" layout would place children inside the parent directory.
|
|
133
|
+
|
|
134
|
+
### 8. `--layout nested` Generates Manifest Without `--manifest` Flag
|
|
135
|
+
**Severity: Low**
|
|
136
|
+
|
|
137
|
+
Using `page pull --layout nested` generates a `confpub.yaml` even without the `--manifest` flag. The flat layout correctly requires `--manifest` to generate one.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Suggestions (Not Bugs)
|
|
142
|
+
|
|
143
|
+
### Add `page.inspect --format markdown`
|
|
144
|
+
Currently `page inspect` returns raw Confluence storage XML. An option to return the reverse-converted Markdown would be useful for quick content review without pulling to a file.
|
|
145
|
+
|
|
146
|
+
### Add `search --type page` Default for Agent Usage
|
|
147
|
+
Agents almost always want pages, not attachments or space entities. Consider a default or a `--pages-only` shorthand.
|
|
148
|
+
|
|
149
|
+
### Document `confpub.lock` in `guide`
|
|
150
|
+
The lock file is created implicitly but isn't documented in the guide schema. Agents should know it exists and what it tracks.
|
|
151
|
+
|
|
152
|
+
### `ERR_IO_FILE_NOT_FOUND` Suggestion for Missing Source
|
|
153
|
+
When the source file doesn't exist, `retryable: true` with `suggested_action: "retry"` is misleading — a file that doesn't exist won't appear on retry. Consider `retryable: false` with `suggested_action: "fix_input"` for local file-not-found errors (vs. transient network errors).
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Test Matrix
|
|
158
|
+
|
|
159
|
+
| Command | Flags Tested | Result | Notes |
|
|
160
|
+
|---|---|---|---|
|
|
161
|
+
| `guide` | (none), `--section commands`, `--section auth`, `--section error_codes` | Pass | `--section` works for top-level keys |
|
|
162
|
+
| `guide --section` | invalid section name | Pass (with note) | Error lacks valid section list |
|
|
163
|
+
| `auth inspect` | (none) | Pass | |
|
|
164
|
+
| `config inspect` | (none) | Pass | Token correctly masked |
|
|
165
|
+
| `space list` | (none) | Pass | |
|
|
166
|
+
| `page list` | `--space` | Pass | |
|
|
167
|
+
| `page inspect` | `--space --title`, `--page-id` | Pass | webui format inconsistency |
|
|
168
|
+
| `page publish` | `--dry-run`, `--backup`, `--title` | Pass | stderr leak on new page |
|
|
169
|
+
| `page publish` (update) | `--backup` | Pass | Version incremented correctly |
|
|
170
|
+
| `page pull` | `--output`, `--force`, `--manifest` | Pass | |
|
|
171
|
+
| `page pull` | `--recursive`, `--layout flat` | Pass | |
|
|
172
|
+
| `page pull` | `--recursive`, `--layout nested` | Pass (with notes) | Manifest ambiguity, not truly nested |
|
|
173
|
+
| `page delete` | `--space --title` | Pass | |
|
|
174
|
+
| `search` | `--space`, `--limit`, `--cql` | Pass | |
|
|
175
|
+
| `plan create` | `--manifest`, `--output` | Pass | |
|
|
176
|
+
| `plan validate` | `--plan` (absolute path) | Pass | Relative path fails |
|
|
177
|
+
| `plan apply` | `--plan --dry-run` | Pass | |
|
|
178
|
+
| `attachment list` | `--page-id` | Pass | |
|
|
179
|
+
| `--quiet` | global flag | Pass | Suppresses stderr messages |
|
|
180
|
+
| `--verbose` | global flag | Pass | Adds diagnostics to metrics |
|
|
181
|
+
| Error: missing file | `page publish nonexistent.md` | Pass | Clear error |
|
|
182
|
+
| Error: missing page | `page inspect --title "..."` | Pass | |
|
|
183
|
+
| Error: missing args | `page publish` (no file) | Pass | |
|
|
184
|
+
| Error: missing space | `--space FAKESPACE` | Fail | Misleading ERR_AUTH_FORBIDDEN |
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Overall Assessment
|
|
189
|
+
|
|
190
|
+
**confpub v0.5.0 is production-ready for the core publish/pull/delete workflow.** The agent-first JSON design, error taxonomy, and guide command set a high bar for CLI ergonomics. The main areas for improvement are:
|
|
191
|
+
|
|
192
|
+
1. Fix relative path resolution for plan commands (blocks scripted workflows)
|
|
193
|
+
2. Fix the nested layout manifest ambiguity (blocks round-trip with nested trees)
|
|
194
|
+
3. Suppress the "Can't find" stderr noise on expected new-page creation
|
|
195
|
+
4. Normalize the `webui` field format
|
|
196
|
+
|
|
197
|
+
None of these are blockers for single-page publishing, which is the most common use case.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: confpub-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -177,7 +177,7 @@ def page_list(
|
|
|
177
177
|
from confpub.confluence import build_client, _slim_page
|
|
178
178
|
client = build_client()
|
|
179
179
|
pages = client.list_pages(space)
|
|
180
|
-
ctx.result = {"pages": [_slim_page(p) for p in pages]}
|
|
180
|
+
ctx.result = {"pages": [_slim_page(p, base_url=client._config.base_url.rstrip("/")) for p in pages]}
|
|
181
181
|
|
|
182
182
|
|
|
183
183
|
@page_app.command("inspect")
|
|
@@ -186,6 +186,7 @@ def page_inspect(
|
|
|
186
186
|
title: str = typer.Option(None, "--title", help="Page title"),
|
|
187
187
|
page_id: str = typer.Option(None, "--page-id", help="Confluence page ID"),
|
|
188
188
|
raw: bool = typer.Option(False, "--raw", help="Return full raw API response"),
|
|
189
|
+
format: str = typer.Option("storage", "--format", help="Output format: storage (raw HTML) or markdown"),
|
|
189
190
|
) -> None:
|
|
190
191
|
"""Inspect a Confluence page."""
|
|
191
192
|
with command_context("page.inspect", target={"space": space, "title": title, "page_id": page_id}) as ctx:
|
|
@@ -201,7 +202,20 @@ def page_inspect(
|
|
|
201
202
|
if not page:
|
|
202
203
|
from confpub.errors import ERR_VALIDATION_NOT_FOUND
|
|
203
204
|
raise ConfpubError(ERR_VALIDATION_NOT_FOUND, f"Page not found")
|
|
204
|
-
|
|
205
|
+
if raw:
|
|
206
|
+
ctx.result = page
|
|
207
|
+
else:
|
|
208
|
+
result = _slim_page(page, base_url=client._config.base_url.rstrip("/"))
|
|
209
|
+
if format == "markdown" and "body_storage" in result:
|
|
210
|
+
from confpub.reverse_converter import convert_storage_to_markdown
|
|
211
|
+
conversion = convert_storage_to_markdown(result["body_storage"])
|
|
212
|
+
result["body_markdown"] = conversion.markdown
|
|
213
|
+
del result["body_storage"]
|
|
214
|
+
if conversion.warnings:
|
|
215
|
+
result["conversion_warnings"] = conversion.warnings
|
|
216
|
+
if conversion.unknown_macros:
|
|
217
|
+
result["unknown_macros"] = conversion.unknown_macros
|
|
218
|
+
ctx.result = result
|
|
205
219
|
|
|
206
220
|
|
|
207
221
|
@page_app.command("publish")
|
|
@@ -484,6 +498,7 @@ def guide(
|
|
|
484
498
|
raise validation_error(
|
|
485
499
|
ERR_VALIDATION_REQUIRED,
|
|
486
500
|
f"Unknown guide section: {section}",
|
|
501
|
+
valid_sections=list(full_guide.keys()),
|
|
487
502
|
)
|
|
488
503
|
ctx.result = result
|
|
489
504
|
else:
|
|
@@ -32,10 +32,10 @@ class ConfluenceClient:
|
|
|
32
32
|
self._call_count = 0
|
|
33
33
|
|
|
34
34
|
# Suppress noisy atlassian-python-api logging (e.g. "Can't find 'X' page")
|
|
35
|
-
from confpub.output import
|
|
35
|
+
from confpub.output import is_verbose
|
|
36
36
|
|
|
37
37
|
atlassian_logger = logging.getLogger("atlassian")
|
|
38
|
-
atlassian_logger.setLevel(logging.
|
|
38
|
+
atlassian_logger.setLevel(logging.WARNING if is_verbose() else logging.CRITICAL)
|
|
39
39
|
|
|
40
40
|
@staticmethod
|
|
41
41
|
def _build_api(config: ResolvedConfig) -> Any:
|
|
@@ -74,7 +74,7 @@ class ConfluenceClient:
|
|
|
74
74
|
raise ConfpubError(
|
|
75
75
|
ERR_AUTH_FORBIDDEN,
|
|
76
76
|
f"Permission denied ({context}): {msg}",
|
|
77
|
-
|
|
77
|
+
details={"note": "This may indicate a nonexistent resource; Confluence returns 403 for both."},
|
|
78
78
|
) from exc
|
|
79
79
|
# Not found (404 or explicit "not found")
|
|
80
80
|
if "404" in msg or "not found" in msg.lower():
|
|
@@ -403,7 +403,7 @@ class ConfluenceClient:
|
|
|
403
403
|
return None
|
|
404
404
|
|
|
405
405
|
|
|
406
|
-
def _slim_page(page: dict[str, Any]) -> dict[str, Any]:
|
|
406
|
+
def _slim_page(page: dict[str, Any], *, base_url: str = "") -> dict[str, Any]:
|
|
407
407
|
"""Extract agent-relevant fields from a raw Confluence page object."""
|
|
408
408
|
result: dict[str, Any] = {
|
|
409
409
|
"id": page.get("id"),
|
|
@@ -421,7 +421,7 @@ def _slim_page(page: dict[str, Any]) -> dict[str, Any]:
|
|
|
421
421
|
result["body_storage"] = body
|
|
422
422
|
links = page.get("_links", {})
|
|
423
423
|
if "webui" in links:
|
|
424
|
-
base = links.get("base", "")
|
|
424
|
+
base = links.get("base", "") or base_url
|
|
425
425
|
result["webui"] = base + links["webui"]
|
|
426
426
|
return result
|
|
427
427
|
|
|
@@ -66,6 +66,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
66
66
|
"mutates": False,
|
|
67
67
|
"description": "Search Confluence content using CQL",
|
|
68
68
|
"flags": ["--cql", "--space", "--type", "--limit", "--start", "--include-archived", "--excerpt-length"],
|
|
69
|
+
"agent_hint": "Most agent workflows should include --type page to exclude attachments and space entities from results.",
|
|
69
70
|
"result_schema": {
|
|
70
71
|
"cql_query": "string — effective CQL sent to the API",
|
|
71
72
|
"results": "list of {id, type, title, excerpt, url, space_key, entity_type, status, last_modified, container_title}",
|
|
@@ -90,7 +91,7 @@ def build_guide() -> dict[str, Any]:
|
|
|
90
91
|
"group": "read",
|
|
91
92
|
"mutates": False,
|
|
92
93
|
"description": "Inspect a Confluence page",
|
|
93
|
-
"flags": ["--space", "--title", "--page-id"],
|
|
94
|
+
"flags": ["--space", "--title", "--page-id", "--format"],
|
|
94
95
|
},
|
|
95
96
|
"page.publish": {
|
|
96
97
|
"group": "write",
|
|
@@ -232,6 +233,21 @@ def build_guide() -> dict[str, Any]:
|
|
|
232
233
|
"to the same workspace return ERR_CONFLICT_LOCK"
|
|
233
234
|
),
|
|
234
235
|
},
|
|
236
|
+
"lockfile": {
|
|
237
|
+
"description": "Local state file tracking page IDs and versions from publish/pull operations.",
|
|
238
|
+
"file": "confpub.lock",
|
|
239
|
+
"schema": {
|
|
240
|
+
"schema_version": "Lockfile format version (currently '1.0')",
|
|
241
|
+
"last_updated": "ISO 8601 timestamp of last write",
|
|
242
|
+
"pages": "Map of page title to { page_id, version }",
|
|
243
|
+
},
|
|
244
|
+
"behavior": [
|
|
245
|
+
"Created/updated automatically by page.publish, page.pull, and plan.apply",
|
|
246
|
+
"Written atomically (temp file + rename) for crash safety",
|
|
247
|
+
"Used by plan.create to detect existing pages and versions",
|
|
248
|
+
"Does not prevent concurrent operations — purely local state tracking",
|
|
249
|
+
],
|
|
250
|
+
},
|
|
235
251
|
"auth": {
|
|
236
252
|
"precedence": [
|
|
237
253
|
"--token + --user",
|
|
@@ -139,7 +139,12 @@ def load_manifest(path: str) -> Manifest:
|
|
|
139
139
|
p = Path(path)
|
|
140
140
|
if not p.exists():
|
|
141
141
|
from confpub.errors import ERR_IO_FILE_NOT_FOUND
|
|
142
|
-
raise ConfpubError(
|
|
142
|
+
raise ConfpubError(
|
|
143
|
+
ERR_IO_FILE_NOT_FOUND,
|
|
144
|
+
f"Manifest not found: {path}",
|
|
145
|
+
retryable=False,
|
|
146
|
+
suggested_action="fix_input",
|
|
147
|
+
)
|
|
143
148
|
try:
|
|
144
149
|
data = yaml.safe_load(p.read_text(encoding="utf-8"))
|
|
145
150
|
if not isinstance(data, dict):
|
|
@@ -107,8 +107,9 @@ def _compute_file_paths(
|
|
|
107
107
|
while current and current != root_page_id:
|
|
108
108
|
chain.append(id_to_slug.get(current, current))
|
|
109
109
|
current = id_to_parent.get(current) # type: ignore[assignment]
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
# Always include root as the outermost directory
|
|
111
|
+
if root_slug:
|
|
112
|
+
chain.append(root_slug)
|
|
112
113
|
chain.reverse()
|
|
113
114
|
rel_path = os.path.join(*chain, "index.md") if len(chain) > 0 else f"{slug}/index.md"
|
|
114
115
|
paths[pid] = os.path.join(output_dir, rel_path)
|
|
@@ -185,6 +186,7 @@ def _build_page_tree(
|
|
|
185
186
|
pages: list[dict[str, Any]],
|
|
186
187
|
file_paths: dict[str, str],
|
|
187
188
|
root_page_id: str,
|
|
189
|
+
output_dir: str = ".",
|
|
188
190
|
) -> list[dict[str, Any]]:
|
|
189
191
|
"""Build a hierarchical page tree for manifest generation."""
|
|
190
192
|
id_to_entry: dict[str, dict[str, Any]] = {}
|
|
@@ -198,7 +200,7 @@ def _build_page_tree(
|
|
|
198
200
|
|
|
199
201
|
id_to_entry[pid] = {
|
|
200
202
|
"title": page.get("title", ""),
|
|
201
|
-
"file": os.path.
|
|
203
|
+
"file": os.path.relpath(file_path, output_dir) if file_path else "",
|
|
202
204
|
"children": [],
|
|
203
205
|
}
|
|
204
206
|
children_map.setdefault(parent_id, []).append(pid)
|
|
@@ -308,9 +310,9 @@ def pull_pages(
|
|
|
308
310
|
|
|
309
311
|
# Generate manifest if requested or recursive with multiple pages
|
|
310
312
|
manifest_file: str | None = None
|
|
311
|
-
if generate_manifest
|
|
313
|
+
if generate_manifest:
|
|
312
314
|
root_title = root_page.get("title", "")
|
|
313
|
-
page_tree = _build_page_tree(all_pages, file_paths, root_id)
|
|
315
|
+
page_tree = _build_page_tree(all_pages, file_paths, root_id, output_dir)
|
|
314
316
|
manifest_yaml = generate_manifest_yaml(root_space, root_title, page_tree)
|
|
315
317
|
manifest_path = os.path.join(output_dir, "confpub.yaml")
|
|
316
318
|
Path(manifest_path).write_text(manifest_yaml, encoding="utf-8")
|
|
@@ -19,9 +19,14 @@ from confpub.manifest import PlanArtifact, PlanPage
|
|
|
19
19
|
|
|
20
20
|
def _load_plan(plan_path: str) -> PlanArtifact:
|
|
21
21
|
"""Load and parse a plan artifact JSON file."""
|
|
22
|
-
p = Path(plan_path)
|
|
22
|
+
p = Path(plan_path).resolve()
|
|
23
23
|
if not p.exists():
|
|
24
|
-
raise ConfpubError(
|
|
24
|
+
raise ConfpubError(
|
|
25
|
+
ERR_IO_FILE_NOT_FOUND,
|
|
26
|
+
f"Plan file not found: {plan_path}",
|
|
27
|
+
retryable=False,
|
|
28
|
+
suggested_action="fix_input",
|
|
29
|
+
)
|
|
25
30
|
try:
|
|
26
31
|
data = json.loads(p.read_text(encoding="utf-8"))
|
|
27
32
|
return PlanArtifact(**data)
|
|
@@ -20,7 +20,12 @@ def _load_assertions(assertions_path: str | None, plan_path: str | None) -> list
|
|
|
20
20
|
if assertions_path:
|
|
21
21
|
p = Path(assertions_path)
|
|
22
22
|
if not p.exists():
|
|
23
|
-
raise ConfpubError(
|
|
23
|
+
raise ConfpubError(
|
|
24
|
+
ERR_IO_FILE_NOT_FOUND,
|
|
25
|
+
f"Assertions file not found: {assertions_path}",
|
|
26
|
+
retryable=False,
|
|
27
|
+
suggested_action="fix_input",
|
|
28
|
+
)
|
|
24
29
|
try:
|
|
25
30
|
data = json.loads(p.read_text(encoding="utf-8"))
|
|
26
31
|
if isinstance(data, list):
|
|
@@ -33,7 +38,12 @@ def _load_assertions(assertions_path: str | None, plan_path: str | None) -> list
|
|
|
33
38
|
# Try to load assertions from the plan's source manifest
|
|
34
39
|
p = Path(plan_path)
|
|
35
40
|
if not p.exists():
|
|
36
|
-
raise ConfpubError(
|
|
41
|
+
raise ConfpubError(
|
|
42
|
+
ERR_IO_FILE_NOT_FOUND,
|
|
43
|
+
f"Plan file not found: {plan_path}",
|
|
44
|
+
retryable=False,
|
|
45
|
+
suggested_action="fix_input",
|
|
46
|
+
)
|
|
37
47
|
plan_data = json.loads(p.read_text(encoding="utf-8"))
|
|
38
48
|
# Plan doesn't contain assertions directly — return empty
|
|
39
49
|
return []
|
|
@@ -6,7 +6,7 @@ from confpub.guide import build_guide
|
|
|
6
6
|
class TestBuildGuide:
|
|
7
7
|
def test_has_required_top_level_keys(self):
|
|
8
8
|
guide = build_guide()
|
|
9
|
-
for key in ("schema_version", "commands", "error_codes", "auth", "concurrency"):
|
|
9
|
+
for key in ("schema_version", "commands", "error_codes", "auth", "concurrency", "lockfile"):
|
|
10
10
|
assert key in guide, f"Missing top-level key: {key}"
|
|
11
11
|
|
|
12
12
|
def test_all_commands_present(self):
|
|
@@ -176,6 +176,7 @@ class TestRecursivePull:
|
|
|
176
176
|
page_id="1",
|
|
177
177
|
output_dir=str(tmp_path),
|
|
178
178
|
recursive=True,
|
|
179
|
+
generate_manifest=True,
|
|
179
180
|
)
|
|
180
181
|
|
|
181
182
|
assert result["summary"]["manifest_generated"] is True
|
|
@@ -265,7 +266,7 @@ class TestLayoutModes:
|
|
|
265
266
|
|
|
266
267
|
assert result["layout"] == "nested"
|
|
267
268
|
assert (tmp_path / "root" / "index.md").exists()
|
|
268
|
-
assert (tmp_path / "child" / "index.md").exists()
|
|
269
|
+
assert (tmp_path / "root" / "child" / "index.md").exists()
|
|
269
270
|
|
|
270
271
|
|
|
271
272
|
# ---------------------------------------------------------------------------
|
|
@@ -405,6 +406,7 @@ class TestDataCenterCompat:
|
|
|
405
406
|
result = pull_pages(
|
|
406
407
|
space="PROJ", title="Root",
|
|
407
408
|
output_dir=str(tmp_path), recursive=True,
|
|
409
|
+
generate_manifest=True,
|
|
408
410
|
)
|
|
409
411
|
|
|
410
412
|
assert result["summary"]["pages_pulled"] == 2
|
confpub_cli-0.5.0/FEEDBACK.md
DELETED
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
# confpub-cli v0.2.3 — Blind Test Feedback
|
|
2
|
-
|
|
3
|
-
**Tester**: Claude Opus 4.6 (LLM agent)
|
|
4
|
-
**Date**: 2026-03-01
|
|
5
|
-
**Environment**: Windows 11, bash shell, `uvx confpub-cli`
|
|
6
|
-
**Confluence**: Cloud instance (thomasklokrohde.atlassian.net)
|
|
7
|
-
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
## Overall Impression
|
|
11
|
-
|
|
12
|
-
confpub-cli is a remarkably well-designed agent-first CLI. As an LLM agent driving it
|
|
13
|
-
zero-shot, I was productive within seconds. The `guide` command gave me everything I
|
|
14
|
-
needed to understand the full command surface, error taxonomy, and concurrency rules.
|
|
15
|
-
The consistent JSON envelope made it trivial to parse every response. This is one of
|
|
16
|
-
the best-designed CLIs I've encountered for agent consumption.
|
|
17
|
-
|
|
18
|
-
**Rating: 4/5** — Excellent foundation with a handful of rough edges to polish.
|
|
19
|
-
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
## What Works Well
|
|
23
|
-
|
|
24
|
-
### Structured JSON Envelope
|
|
25
|
-
Every command returns the same `{ ok, command, target, result, warnings, errors, metrics }`
|
|
26
|
-
shape. Parsing is zero-effort. The `request_id` and `metrics.duration_ms` fields are a
|
|
27
|
-
nice touch for tracing and diagnostics.
|
|
28
|
-
|
|
29
|
-
### `guide` Command
|
|
30
|
-
Brilliant bootstrapping mechanism. One call gives an agent: every command with its flags,
|
|
31
|
-
mutability annotations, error codes with exit codes and retry hints, auth precedence,
|
|
32
|
-
and concurrency rules. The `--section` flag is useful for targeted queries. This alone
|
|
33
|
-
puts confpub ahead of most CLI tools for agent integration.
|
|
34
|
-
|
|
35
|
-
### Transactional Plan Workflow
|
|
36
|
-
The plan → validate → apply → verify pipeline is well thought out:
|
|
37
|
-
- `plan create` generates a readable artifact with clear operation types
|
|
38
|
-
- `plan validate` checks for drift before apply
|
|
39
|
-
- `plan apply` supports `--dry-run` for preview
|
|
40
|
-
- The lockfile (`confpub.lock`) enables idempotent re-publishing
|
|
41
|
-
|
|
42
|
-
I tested the full cycle and it worked flawlessly: manifest → plan → validate → dry-run → apply → verify.
|
|
43
|
-
|
|
44
|
-
### Markdown Conversion
|
|
45
|
-
Tested headings, bold/italic, inline code, fenced code blocks (with language), tables,
|
|
46
|
-
admonitions (`[!NOTE]`, `[!WARNING]`, `[!TIP]`), strikethrough, and horizontal rules.
|
|
47
|
-
All converted correctly to Confluence Storage Format. Particularly impressed that
|
|
48
|
-
admonitions map to the proper Confluence Info/Warning/Tip macros.
|
|
49
|
-
|
|
50
|
-
### Error Taxonomy
|
|
51
|
-
Stable error codes (`ERR_*`) with structured details, exit codes, and `suggested_action`
|
|
52
|
-
hints (`fix_input`, `retry`, `reauth`, `escalate`). An agent can branch on these without
|
|
53
|
-
parsing human-readable messages. The `retryable` flag and `retry_after_ms` for I/O errors
|
|
54
|
-
are agent-friendly.
|
|
55
|
-
|
|
56
|
-
### Safety Design
|
|
57
|
-
- Write commands are clearly annotated as `mutates: true` in the guide
|
|
58
|
-
- `--dry-run` is available on both `page publish` and `plan apply`
|
|
59
|
-
- `--cascade` is a separate opt-in for cascading deletes
|
|
60
|
-
- `safety_flags` section in the guide calls out dangerous flags
|
|
61
|
-
|
|
62
|
-
### Auth Resolution
|
|
63
|
-
Clean precedence chain (flags → env vars → config file → keychain) with auto-detection
|
|
64
|
-
of Cloud vs Server from the URL. `auth inspect` gives a quick status check.
|
|
65
|
-
|
|
66
|
-
---
|
|
67
|
-
|
|
68
|
-
## Bugs Found
|
|
69
|
-
|
|
70
|
-
### BUG-1: `--space` flag ignored during page update (Severity: High)
|
|
71
|
-
|
|
72
|
-
When I published a page to space `SD`, then re-published with `--space NONEXIST`, the
|
|
73
|
-
command succeeded (exit 0) and updated the existing page in the `SD` space. The `--space`
|
|
74
|
-
flag was silently ignored on the update path.
|
|
75
|
-
|
|
76
|
-
**Repro:**
|
|
77
|
-
```bash
|
|
78
|
-
confpub page publish test.md --space SD --parent "Software Development"
|
|
79
|
-
# Creates page in SD, version 1
|
|
80
|
-
|
|
81
|
-
confpub page publish test.md --space NONEXIST --parent "Software Development"
|
|
82
|
-
# Expected: error (space not found or page not found in that space)
|
|
83
|
-
# Actual: ok=true, updated page in SD to version 3
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
**Impact**: An agent could accidentally update a page in the wrong space without any
|
|
87
|
-
warning. The title-based lookup appears to match globally (or against the lockfile)
|
|
88
|
-
rather than scoping to the specified space.
|
|
89
|
-
|
|
90
|
-
### BUG-2: Nonexistent page returns `ok: true` with `result: null` (Severity: Medium)
|
|
91
|
-
|
|
92
|
-
```bash
|
|
93
|
-
confpub page inspect --space SD --title "nonexistent-page"
|
|
94
|
-
# Returns: ok=true, result=null, errors=[]
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
This should return `ok: false` with an appropriate error (e.g., a new `ERR_NOT_FOUND`
|
|
98
|
-
code). An agent checking `ok` to determine success would incorrectly think the call
|
|
99
|
-
succeeded. Currently the only way to detect "not found" is to check if `result` is null,
|
|
100
|
-
which is an undocumented convention.
|
|
101
|
-
|
|
102
|
-
A `"Can't find ... page"` message also leaks to stderr, suggesting the underlying library
|
|
103
|
-
knows it's not found — the CLI just doesn't surface it as a structured error.
|
|
104
|
-
|
|
105
|
-
### BUG-3: Missing required options return exit code 2, not JSON envelope (Severity: Medium)
|
|
106
|
-
|
|
107
|
-
```bash
|
|
108
|
-
confpub page list
|
|
109
|
-
# Returns Typer's error format: "Missing option '--space'." with exit code 2
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
This breaks the documented invariant: "stdout is exclusively JSON — one object, no
|
|
113
|
-
preamble, no epilogue." An agent expecting to always parse JSON on stdout will crash.
|
|
114
|
-
Exit code 2 is also undocumented (the error code table only covers 0/10/20/40/50/90).
|
|
115
|
-
|
|
116
|
-
**Suggestion**: Catch Typer's `MissingParameter` and convert it to an
|
|
117
|
-
`ERR_VALIDATION_REQUIRED` envelope with exit code 10.
|
|
118
|
-
|
|
119
|
-
### BUG-4: `--backup` flag produces no observable output (Severity: Low)
|
|
120
|
-
|
|
121
|
-
```bash
|
|
122
|
-
confpub page publish test.md --space SD --parent "Software Development" --backup
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
The command succeeded but the result JSON contains no mention of a backup being created —
|
|
126
|
-
no backup file path, no `backup: true` field, nothing. Either the backup didn't happen,
|
|
127
|
-
or it happened silently. An agent has no way to confirm.
|
|
128
|
-
|
|
129
|
-
### BUG-5: Nonexistent parent accepted in dry-run (Severity: Low)
|
|
130
|
-
|
|
131
|
-
```bash
|
|
132
|
-
confpub page publish test.md --space SD --parent "Nonexistent Parent" --dry-run
|
|
133
|
-
# Returns: ok=true, type=page.update
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
Dry-run should ideally validate that the parent page exists, or at least emit a warning.
|
|
137
|
-
Currently it plans an update to a nonexistent parent, which would fail on real apply.
|
|
138
|
-
|
|
139
|
-
---
|
|
140
|
-
|
|
141
|
-
## Improvement Suggestions
|
|
142
|
-
|
|
143
|
-
### 1. Normalize attachment command output
|
|
144
|
-
|
|
145
|
-
`attachment.list` and `attachment.upload` return raw Confluence API responses with
|
|
146
|
-
internal fields (`_expandable`, ARIs, `base64EncodedAri`, full user profiles). Every
|
|
147
|
-
other command returns a curated result. These should be normalized to the same level
|
|
148
|
-
of curation.
|
|
149
|
-
|
|
150
|
-
**Suggested `attachment.list` shape:**
|
|
151
|
-
```json
|
|
152
|
-
{
|
|
153
|
-
"attachments": [
|
|
154
|
-
{
|
|
155
|
-
"id": "att262400",
|
|
156
|
-
"title": "test-attachment.txt",
|
|
157
|
-
"media_type": "application/binary",
|
|
158
|
-
"file_size": 27,
|
|
159
|
-
"download_url": "/download/attachments/360459/test-attachment.txt?..."
|
|
160
|
-
}
|
|
161
|
-
]
|
|
162
|
-
}
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
### 2. Add `--format` flag or page count to `page.list`
|
|
166
|
-
|
|
167
|
-
For spaces with many pages, `page.list` returns everything. Consider:
|
|
168
|
-
- A `--limit` / `--offset` for pagination
|
|
169
|
-
- A `--format compact` option (just titles + IDs)
|
|
170
|
-
- Include total count in the result
|
|
171
|
-
|
|
172
|
-
### 3. Suppress `"Can't find ... page"` stderr noise
|
|
173
|
-
|
|
174
|
-
Multiple commands emit `"Can't find 'X' page on ..."` to stderr. This comes from the
|
|
175
|
-
underlying `atlassian-python-api` library but leaks through even with `--quiet`. Since
|
|
176
|
-
not finding a page is a normal flow (e.g., first publish), this shouldn't appear by
|
|
177
|
-
default — maybe only with `--verbose`.
|
|
178
|
-
|
|
179
|
-
### 4. Add stdin support
|
|
180
|
-
|
|
181
|
-
`echo "# Hello" | confpub page publish - --space SD --parent "Docs" --title "Hello"`
|
|
182
|
-
would be useful for piped workflows and agent-generated content. Currently `-` is treated
|
|
183
|
-
as a literal filename.
|
|
184
|
-
|
|
185
|
-
### 5. `plan verify` with no assertions is a no-op
|
|
186
|
-
|
|
187
|
-
```bash
|
|
188
|
-
confpub plan verify --plan confpub-plan.json
|
|
189
|
-
# Returns: all_passed=true, results=[]
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
Without `--assertions`, this always passes vacuously. Consider:
|
|
193
|
-
- Auto-generating basic assertions from the plan (pages exist, correct parent, version incremented)
|
|
194
|
-
- Emitting a warning when no assertions are provided
|
|
195
|
-
|
|
196
|
-
### 6. Consider `--json` flag for Typer-level errors
|
|
197
|
-
|
|
198
|
-
As a bridge until BUG-3 is fully fixed, a `--json` flag could force JSON output even
|
|
199
|
-
for framework-level errors (missing options, unknown commands).
|
|
200
|
-
|
|
201
|
-
### 7. Lockfile includes pages from `page publish` and `plan apply`
|
|
202
|
-
|
|
203
|
-
The lockfile accumulated entries from both `page publish` (single-file mode) and
|
|
204
|
-
`plan apply`. This might be intentional for idempotency, but it could surprise users
|
|
205
|
-
who expected the lockfile to only track manifest-managed pages. Consider documenting
|
|
206
|
-
this behavior or separating the two.
|
|
207
|
-
|
|
208
|
-
---
|
|
209
|
-
|
|
210
|
-
## Summary
|
|
211
|
-
|
|
212
|
-
| Area | Score |
|
|
213
|
-
|------|-------|
|
|
214
|
-
| Agent discoverability (`guide`) | 5/5 |
|
|
215
|
-
| JSON envelope consistency | 4/5 (BUG-3 breaks it for Typer errors) |
|
|
216
|
-
| Error handling | 4/5 (BUG-2 masks not-found) |
|
|
217
|
-
| Markdown conversion | 5/5 |
|
|
218
|
-
| Plan workflow | 5/5 |
|
|
219
|
-
| Write correctness | 3/5 (BUG-1 is a real data integrity risk) |
|
|
220
|
-
| Output curation | 3/5 (attachments leak raw API) |
|
|
221
|
-
| Documentation (README) | 5/5 |
|
|
222
|
-
|
|
223
|
-
confpub is production-ready for the core read + plan + publish workflows. The main
|
|
224
|
-
blocker is BUG-1 (space flag ignored on updates), which could cause silent cross-space
|
|
225
|
-
writes. Fixing that plus normalizing the attachment output would bring this to a
|
|
226
|
-
strong 5/5.
|
|
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
|