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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,10 @@
CONFLUENCE_URL=https://your-org.atlassian.net
CONFLUENCE_USERNAME=you@example.com
CONFLUENCE_TOKEN=your-api-token

# Optional. Set this only for a *scoped* API token, such as one issued to a
# service account: those must go through Atlassian's api.atlassian.com gateway
# rather than your site domain. Leave it out for a normal personal token.
# Find yours (it isn't a secret):
# curl -s https://your-org.atlassian.net/_edge/tenant_info
#CONFLUENCE_CLOUD_ID=
11 changes: 8 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,18 @@ Run `make test && make lint && make vet` before considering work done.

## Configuration

The CLI needs a base URL, a username, and an API token. Each resolves with the precedence **flag > environment variable > `.env` file**:
The CLI needs a site URL, a username, and an API token, plus an optional cloud ID. Each resolves with the precedence **flag > environment variable > `.env` file**:

| Setting | Flag | Env / `.env` |
|---|---|---|
| base URL | `--url` | `CONFLUENCE_URL` |
| site URL | `--url` | `CONFLUENCE_URL` |
| username | `--username` | `CONFLUENCE_USERNAME` |
| API token | *(none)* | `CONFLUENCE_TOKEN` |
| cloud ID (optional) | `--cloud-id` | `CONFLUENCE_CLOUD_ID` |

A cloud ID routes requests through `https://api.atlassian.com/ex/confluence/{cloudId}` instead of the site domain, which is mandatory for a **scoped** API token (what an Atlassian service account gets — a scoped token 401s against the site domain). Basic auth is unchanged; only the base URL moves. Without a cloud ID, behavior is identical to before it existed, which is what an unscoped personal token and any Data Center site need. The cloud ID is not a secret (`https://SITE/_edge/tenant_info` returns it unauthenticated), which is why it may be a flag while the token may not.

Scopes markfluence needs (it never deletes): `read:confluence-content.all`, `write:confluence-content`, `read:confluence-space.summary`, `read:confluence-props`, `write:confluence-props`, `write:confluence-file`, `read:confluence-user`.

markfluence reads a `.env` from the working directory itself (a minimal built-in parser — no shell expansion), or an explicit path via the persistent `--env-file` flag (a missing explicit path is an error; a missing default `./.env` is not); `.env.example` is the template. The API token is deliberately never a command-line flag. `internal/client.Resolve` is the single place this is read and validated.

Expand All @@ -43,7 +48,7 @@ Module `github.com/mozilla/markfluence` (`go 1.25`). `main.go` is a shim to `cmd

- `cmd/root.go` — the cobra root: `--url`/`--username`/`--debug`/`--no-color` persistent flags, version from `internal/buildinfo`, and registration of the four subcommands. `Execute()` prints cobra-generated errors (bad args/flags) but not `ui.ErrSilent`, which marks a failure a command already reported.
- `cmd/{update,create,fix,info}/` — one package per command (each exports `Cmd`), orchestrating the `internal` packages and `internal/ui` output. `create` is two-phase and transactional (validate all, then create parents-first in topological order); `fix` is read-only on the server.
- `internal/client` — `ConfluenceClient` over `net/http` with basic auth. Pages are Confluence **v2**; attachment writes and the user lookup are **v1** (`/wiki/rest/api/...`). Typed `HTTPError`, per-attempt context timeouts, centralized retry/backoff in `send` (429 for any method honoring `Retry-After`, plus 502/503/504 and network errors for idempotent methods only; exponential backoff capped), `SetContentProperty` retry-once on top (recovers a lost create-POST response), `SyncAttachments` (SHA-256-in-comment skip/update), `_links.next` pagination. `config.go` holds `Resolve` and the `.env` reader.
- `internal/client` — `ConfluenceClient` over `net/http` with basic auth. Built from a `Config` (site URL, cloud ID, username, token) via `New`; it carries **two bases**: `BaseURL()` is where requests go (the gateway when a cloud ID is set) and `SiteURL()` is always the site. Anything a reader sees uses `SiteURL()` — printed page URLs and, critically, the `baseURL` handed to `convert.MdToConfluence`, since rewritten links are published *into* the page. Pages are Confluence **v2**; attachment writes and the user lookup are **v1** (`/wiki/rest/api/...`). Typed `HTTPError`, per-attempt context timeouts, centralized retry/backoff in `send` (429 for any method honoring `Retry-After`, plus 502/503/504 and network errors for idempotent methods only; exponential backoff capped), `SetContentProperty` retry-once on top (recovers a lost create-POST response), `SyncAttachments` (SHA-256-in-comment skip/update), `_links.next` pagination. `config.go` holds `Resolve` and the `.env` reader.
- `internal/convert` — the converter (the crux). `MdToConfluence(md *frontmatter.MarkdownFile, baseURL, spaceKey, version string) (*ConfluencePage, error)`. It parses with goldmark (GFM) and renders through a custom `storageRenderer` registered at priority 100 (below the default HTML=1000 and table=500 renderers) that emits Confluence storage format. `shield.go` renames raw `ac:`/`ri:` tags to colon-free sentinels around the goldmark step so pasted storage passes through; `callouts.go` is an AST transformer + blockquote renderer for GitHub alerts; `images.go`, `links.go` (sibling-file scans, GitHub/Confluence slugs, doc-link + anchor rewriting), `tables.go` (the `<table>` tag only, stamped with Confluence's `data-layout="align-start"` so tables auto-size to their content and left-align; rows and cells still fall through to the GFM renderer), and `renderer.go` (code macros, text soft-break→space, images, links) do the rest. The `<!-- confluence-toc -->` and `<!-- markfluence-version -->` token substitutions happen **inside** `MdToConfluence`.
- `internal/frontmatter` — flat YAML frontmatter parse/quote/`UpdateField`, and the `MarkdownFile` type (`Parse`/`ParseFile`, exported `Filename`/`Content`/`Frontmatter`/`Body`, and `Title`/`PageID`/`Space`/`Parent` accessors that normalize missing/blank/`"null"`).
- `internal/pagewidth` — the `page_width` `Width` enum (`narrow`/`wide`/`max`, default `max`), `Declared`, the vocab↔content-property maps, `WidthFromProperties`, and `Apply`/`Read` against the client.
Expand Down
49 changes: 47 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ TBD — published to a tap on the first release.

## Configure

markfluence needs a base URL, a username, and an API token. Each is resolved
markfluence needs a site URL, a username, and an API token. Each is resolved
with the precedence **flag > environment variable > `.env` file**:

| Setting | Flag | Environment / `.env` |
| --- | --- | --- |
| Base URL | `--url` | `CONFLUENCE_URL` |
| Site URL | `--url` | `CONFLUENCE_URL` |
| Username | `--username` | `CONFLUENCE_USERNAME` |
| API token | *(none — never a flag)* | `CONFLUENCE_TOKEN` |
| Cloud ID *(optional)* | `--cloud-id` | `CONFLUENCE_CLOUD_ID` |

markfluence reads a `.env` file from the current directory automatically (no need
to `source` it), or from an explicit path via `--env-file PATH` (a persistent flag
Expand All @@ -48,6 +49,40 @@ from the environment or `.env`.

(Optional): `alias mf=markfluence`

### Scoped tokens and service accounts

For a normal personal API token, you can leave `CONFLUENCE_CLOUD_ID` unset.

For a **scoped** API token for an Atlassian [service account][svcacct], you
need to set `CONFLUENCE_CLOUD_ID`. You would use this to publish from CI as a
service account rather than as a person. Scoped tokens are rejected with a
**401** against your site domain; they must go through Atlassian's
`api.atlassian.com` gateway, and the cloud ID is what addresses your site
there. `CONFLUENCE_URL` still holds the site URL: markfluence needs it to write
correct links into the pages it publishes.

Find your cloud ID — it is **not** a secret:

```console
$ curl -s https://your-org.atlassian.net/_edge/tenant_info
{"cloudId":"d8febd08-5555-5555-5555-db37c2369ce5"}
```

The scopes markfluence needs:

| Used for | Classic scope |
| --- | --- |
| Reading, creating, and updating pages | `read:confluence-content.all`, `write:confluence-content` |
| Resolving space keys | `read:confluence-space.summary` |
| Page width (content properties) | `read:confluence-props`, `write:confluence-props` |
| Image attachments | `write:confluence-file` |
| Author names in `info` | `read:confluence-user` |

Currently, markfluence doesn't support deleting anything, so it doesn't need
delete scopes. This might change in the future.

[svcacct]: https://support.atlassian.com/user-management/docs/understand-service-accounts/

## Usage

```sh
Expand Down Expand Up @@ -301,6 +336,13 @@ markfluence reads them straight from the environment — no `.env` in CI.
- `CONFLUENCE_URL`
- `CONFLUENCE_USERNAME`

Prefer a [service account][svcacct] over a personal token here, so published pages
aren't authored by an individual and publishing doesn't break when that person
rotates their token or moves on. That means a **scoped** token, which also needs
`CONFLUENCE_CLOUD_ID` (see [Scoped tokens and service
accounts](#scoped-tokens-and-service-accounts)). The cloud ID is not sensitive, so
make it a repository **variable** rather than a secret.

[secrets]: https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions

### Workflow
Expand Down Expand Up @@ -338,6 +380,9 @@ jobs:
CONFLUENCE_URL: ${{ secrets.CONFLUENCE_URL }}
CONFLUENCE_USERNAME: ${{ secrets.CONFLUENCE_USERNAME }}
CONFLUENCE_TOKEN: ${{ secrets.CONFLUENCE_TOKEN }}
# A variable, not a secret: the cloud ID is public. Omit it if you're
# using an unscoped personal token.
CONFLUENCE_CLOUD_ID: ${{ vars.CONFLUENCE_CLOUD_ID }}
run:
markfluence update --page-id=12345 --force docs/some_doc.md
```
Expand Down
Loading