Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,84 @@ markfluence read 1234567890 --format storage > page.storage.xml
markfluence read "https://org.atlassian.net/wiki/spaces/ENG/pages/1234567890/Title"
```

### `--json` output

The persistent `--json` flag makes any command emit a single machine-readable
JSON document to stdout instead of the human output, for scripting and CI. It
pipes cleanly to `jq`:

```sh
markfluence info 1234567890 --json | jq '.results[0].page_width'
markfluence update docs/*.md --json | jq '.summary'
```

Output is a stable, versioned **envelope**. `results` always holds one object per
target (a single element for `info`/`read`); `summary` carries batch counts:

```json
{
"schema_version": 1,
"markfluence_version": "1.4.0",
"command": "update",
"results": [
{
"ok": true,
"status": "published",
"file": "docs/foo.md",
"page_id": "123",
"title": "Foo",
"space": "ENG",
"url": "https://wiki.example.net/wiki/spaces/ENG/pages/123/Foo",
"version": { "previous": 3, "new": 4 },
"page_width": { "value": "max", "default": false },
"attachments": [ { "action": "updated", "filename": "diagram.png" } ],
"warnings": [],
"broken": [],
"error": null,
"code": null
}
],
"summary": { "total": 1, "succeeded": 1, "failed": 0, "skipped": 0 }
}
```

The full contract is published as a JSON Schema (draft 2020-12) at
[`schema/json-output/v1.json`](schema/json-output/v1.json) — the `results` item
and `summary` shapes are selected by `command`, and the stderr error object is
`#/$defs/errorObject`. A test validates markfluence's actual output against it, so
the schema cannot drift from the implementation.

Notes on the schema:

- **Per-command stable.** Each command always emits the same keys in the same
shapes (empty values are `null` or `[]`); the key *set* differs per command.
`schema_version` is bumped on any breaking change.
- **Status verbs** are per-command: `published`/`skipped` (`update`),
`created`/`not_created` (`create`), `changed`/`consistent` (`fix`), plus
`failed`. `info`/`read` results carry data only (no status verb).
- **Compound values are objects**, never display strings — `version`,
`page_width`, and the `created`/`updated` author stamps on `info`.
- **`create`'s two-phase abort** (a validation failure means nothing is created)
lists every input file — failed ones with an `error`, the rest as
`not_created` — and sets `summary.aborted: true`.
- **Warnings and broken image/link notices** are data (`warnings`/`broken`
arrays on each result), not stderr log lines.

Errors and exit codes:

- **Per-file operational failures** appear in `results` as
`{ "ok": false, "error": "…", "code": "…" }`; the command exits `1` if any
file failed.
- **Fatal/pre-flight failures** (bad flags, credential resolution) print a typed
error object to **stderr** and exit `2`:

```json
{ "schema_version": 1, "command": "update", "error": "…", "code": "CONFIG" }
```

- Error `code` values: `CONFIG`, `AUTH`, `NOT_FOUND`, `VALIDATION`, `CONVERT`,
`IO`, `NETWORK`, `API`.

## Markdown page structure

Each Markdown file is one Confluence page: an optional YAML **frontmatter** block
Expand Down
226 changes: 226 additions & 0 deletions _plans/015_json-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
# Plan: `--json` machine-readable output

Add a global `--json` mode across all five subcommands (`info`, `read`, `update`,
`create`, `fix`) so markfluence output can be consumed by scripts and CI. Closes
issue #12 ("Add --json output for commands").

`--json` suppresses the human `internal/ui` output and writes a single structured
JSON document to stdout. Warnings and broken-image/link notices — currently
`ui.Warn` lines on stderr — become **data** in the payload (issue #12 lists them
as schema fields). Fatal/pre-flight errors stay on stderr, but as a typed JSON
error object when `--json` is set.

Reference: pchuri/confluence-cli's `--json` (global flag, stdout-stays-valid-JSON,
typed error codes on stderr). We diverge deliberately in two places: we wrap
output in an **envelope** (it has no envelope), and we fold warnings/broken into
the **payload** (it logs them to stderr) — because markfluence's warnings are
per-page conversion results, not incidental log chatter.

## Decisions locked (from the interview)

- **Scope:** all five subcommands, including `read`.
- **Flag:** one persistent `--json` bool on `rootCmd`, inherited by every
subcommand (like `--debug`/`--no-color`). Bare `markfluence --json` (help) is
unaffected — `--json` only shapes subcommand output.
- **Envelope:** uniform for every command, including single-target `info`/`read`
(their `results` is a 1-element array).
- **Field presence:** stable schema means **per-command stable** — each command
always emits the same keys with the same shapes (empty → `null`/`[]`); the key
*set* differs between commands.
- **Compound values are nested objects**, never human display strings.
- **Status verbs are per-command** (`published`/`created`/`changed`, etc.).
- **create phase-1 abort:** every input file appears in `results`; summary carries
`aborted:true`.
- **Refactor depth:** full — `processFile`/`createOne` build a typed result
struct; a human renderer and the JSON collector both consume it (single source
of truth).
- **Errors:** per-file operational failures live in `results`; fatal/pre-flight
errors go to stderr as a typed error object. Typed error codes, markfluence-
tailored set.
- **Formatting:** pretty-printed, 2-space indent, newline-terminated.
- **Exit codes:** `0` ok, `1` operational failure, `2` config/usage/pre-flight.

## Architecture

### `internal/jsonout` (new package)

Holds the shared machinery so no command re-implements it:

- `Envelope` — `{schema_version, markfluence_version, command, results, summary}`.
`results` is `[]any`; `summary` is `any` (command-specific).
- `ErrorObject` — `{schema_version, command, error, code}` for the fatal path.
- `Code` constants: `CONFIG`, `AUTH`, `NOT_FOUND`, `VALIDATION`, `CONVERT`, `IO`,
`NETWORK`, `API`.
- `Emit(w io.Writer, env Envelope) error` — marshal indented (2-space), trailing
newline, to stdout.
- `EmitError(w io.Writer, command, msg string, code Code) error` — the stderr
error object.
- A helper to derive a `Code` from a `client.HTTPError` (401/403→`AUTH`,
404→`NOT_FOUND`, other→`API`) with `NETWORK` for transport errors.

`schema_version` is the integer `1` (bump on breaking change).
`markfluence_version` is `buildinfo.Stamp` (aids bug reports).

### `internal/ui`

- `SetJSON(bool)` — when set, the stdout helpers (`Header`/`Success`/`Info`/`Dim`)
become no-ops, a belt-and-suspenders guard so a stray call can't corrupt the
JSON on stdout. `ui.Warn`/`ui.Error` also stop writing in JSON mode (their
content is carried in the payload / error object instead). `ui.Debug` is
unaffected — still stderr, still `--debug`-gated.
- Wired from `rootCmd.PersistentPreRunE` next to `SetDebug`.

### Command refactor (the bulk of the diff)

Each command's per-file worker builds a typed result struct instead of printing
inline and returning `bool`:

- `update.processFile` / `create.createOne` / `fix.processFile` → return a result
struct (and an in-band error/status).
- A **human renderer** re-derives today's `ui.*` lines from that struct, so
human-mode output is byte-identical to now.
- A **JSON collector** appends structs to `Envelope.results` and prints once at
the end.

Human-mode output must not regress; the renderer is exercised by the existing
command behavior.

## Envelope

```json
{
"schema_version": 1,
"markfluence_version": "1.4.0",
"command": "update",
"results": [ /* per-command result objects */ ],
"summary": { "total": 2, "succeeded": 1, "failed": 1 }
}
```

Summary core is `{total, succeeded, failed}`; commands add extras (below).

## Per-command result shapes

### update — `status`: `published` | `skipped` | `failed`

```json
{ "ok": true, "status": "published", "file": "docs/foo.md",
"page_id": "123", "title": "Foo", "space": "ENG",
"url": "https://wiki.example.net/wiki/spaces/ENG/pages/123/Foo",
"version": { "previous": 3, "new": 4 },
"page_width": { "value": "max", "default": false },
"attachments": [ { "action": "updated", "filename": "diagram.png" } ],
"warnings": [], "broken": [], "error": null, "code": null }
```

- `skipped` (mtime unchanged): `ok:true`, `version.new == version.previous`.
- `failed`: `ok:false`, `error` set, `code` set.
- Summary extra: `skipped`.

### create — `status`: `created` | `not_created` | `failed`

```json
{ "ok": true, "status": "created", "file": "docs/foo.md",
"page_id": "456", "title": "Foo", "space": "ENG", "parent": "123",
"url": "...", "page_width": { "value": "max", "default": false },
"persisted": true,
"attachments": [ ... ], "warnings": [], "broken": [],
"error": null, "code": null }
```

- Phase-1 abort: validation-failed files → `{ok:false,status:"failed",code:"VALIDATION",error}`;
the rest → `{ok:false,status:"not_created",error:null}`.
- Summary: `{total, succeeded, failed, aborted}`.

### fix — `status`: `changed` | `consistent` | `failed`

```json
{ "ok": true, "status": "changed", "file": "docs/foo.md", "page_id": "123",
"dry_run": false,
"changes": [ { "field": "space", "old": "OLD", "new": "ENG" } ],
"warnings": [], "error": null, "code": null }
```

- `consistent`: `changes` empty.
- `--dry-run`: `dry_run:true`, status still `changed` when changes exist, no file
written.
- Summary extras: `changed`, `consistent`.

### info — data only (no operational verb)

```json
{ "ok": true, "file": null, "page_id": "123", "title": "Foo",
"page_status": "current", "space": "ENG", "parent": "456",
"version": { "number": 7 },
"page_width": { "value": "max", "default": true },
"created": { "at": "…", "by": { "account_id": "…", "name": "Will" } },
"updated": { "at": "…", "by": { "account_id": "…", "name": "Will" } },
"message": "Updated via markfluence", "url": "…",
"properties": null }
```

- Confluence's `status` field is renamed `page_status` to avoid colliding with the
result-status concept used by the action commands.
- `properties` stays gated on `--properties`: `null` when the flag is absent, an
array of `{key, value}` (sorted by key) when present. No extra API call unless
asked.
- **Page not found** is a `results[0]` entry `{ok:false,code:"NOT_FOUND"}` with
exit `1` — *not* a fatal stderr error (that path is reserved for config/usage).
Keeps "operational failures live in the payload" consistent.

### read — structured fields + body string

```json
{ "ok": true, "page_id": "123", "title": "X", "space": "ENG",
"parent": null, "page_width": { "value": "max", "default": true },
"format": "markdown", "body": "# X\n\nhello" }
```

- `page_width` is the same nested object as `info` (null when the width read
fails). `format` echoes `--format` (`markdown` | `storage`). `parent` is `null`
for top-level, else the parent page id.

## Errors & exit codes

- **Fatal / pre-flight** (bad flags, `client.Resolve`/auth) → JSON error object on
**stderr**, no stdout payload, **exit 2**:

```json
{ "schema_version": 1, "command": "update", "error": "…", "code": "CONFIG" }
```

- **Per-file operational** failures → `{ok:false, error, code}` in `results`;
**exit 1** if any file failed.
- Codes derived from `client.HTTPError` status + failure site (see
`internal/jsonout`).

## Testing

- `internal/jsonout`: unit-test `Emit`/`EmitError` output (golden strings) and the
`HTTPError`→`Code` mapping.
- Each `cmd/*`: golden JSON tests built from hand-constructed `client.Page` /
result-struct values (like `info_test.go` builds `client.Property` values) — no
network mock needed. Cover: a success, a per-file failure, warnings/broken
populated; for create, the phase-1 abort envelope; for fix, `--dry-run`.
- `cmd`: extend `root_test.go` to assert the `--json` persistent flag is
registered.
- `make test && make lint && make vet` before done.

## Docs

- Document `--json` in the README (envelope shape, the per-command result keys, the
error object, exit-code table) and note the schema is versioned via
`schema_version`.

## Published schema (drift-guarded)

The full contract is a JSON Schema (draft 2020-12) at `schema/json-output/v1.json`:
the envelope root, per-command `results`/`summary` selected via `if/then` on
`command`, and the stderr error object at `#/$defs/errorObject`. Every object uses
`additionalProperties: false`, so a new struct field fails validation until the
schema is updated.

`internal/schematest` (a test-only helper using `santhosh-tekuri/jsonschema/v6`)
loads and compiles the schema once; each command's `TestSchemaConformance`
validates the command's *actual* marshaled output against it, so the schema cannot
drift from the Go structs.
Loading