01 / apply it
yardr pack apply ./confluenceFrom a clone of yardr-packs: the pack's directory. yardr pack check ./confluence shows what it would change first. It goes into the workspace itself, and into no repo · depot.
yardr pack apply https://yardr.dev/packs/confluence/From this site, as the files below.
02 / the files
Every file, as it ships.
# Confluence Cloud as a pack of kind source: the integration, imported
# into the yard and into no depot. What Confluence is here: the declared
# source confluence, which lists spaces, finds content by CQL, reads a
# page, lists the children of a page, folder, whiteboard, database or
# embed, lists a page's comments, the replies to a comment, its labels and
# its attached files, and names an author from an account id, all through
# the REST API as one Atlassian account. Read only. Every agent granted a
# call reads what that account may read.
#
# yardr pack apply /Users/mckean/projects/repos/yardr-packs/confluence
#
# The source is named confluence: a pack is named after the service it
# talks to. Confluence is per site, so the declaration ships a placeholder
# address that SETUP.md has the operator replace in the yard's copy. The
# login is the Atlassian account the jira pack uses, given to this source in
# its own env file. The pack is written for pack API 3, because children's
# kind has a default and is in a path, which API 3 added. A yardr that
# reads less is told to upgrade and is not given an error about the path.
name: confluence
api: 3
kind: source
setup: SETUP.md
sources:
confluence:
declaration: confluence.yaml
# Suggested grants: named sets of the source's calls, printed numbered by
# pack show. Suggesting grants nothing: the operator grants them, and a
# terminal pack apply ignores them.
presets:
read:
about: List spaces, find content by CQL, read a page, list the children of a page, folder, whiteboard, database or embed, list a page's comments, replies, labels and attached files, and name an author from an account id; change nothing
grants:
confluence: [read]
#!/bin/sh
# mint: the credential Confluence takes, from the account's email and an API
# token. stdin {"inputs": {"CONFLUENCE_EMAIL": …, "CONFLUENCE_API_TOKEN": …},
# "scope": …}, stdout {"token": …, "expires_at": …}, as a source's
# mint_command is run.
#
# Atlassian's Basic scheme is the base64 of "email:token", and a declared
# call's header takes one value (Authorization: Basic {credential}). So the
# pair is made into that value here, once, for every call. The value does
# not expire; the hour is only how long the yard keeps it before it asks
# again, and a new token in the env file is a new key, minted at once. The
# same as jira/mint of the jira pack, which reads its own two names.
#
# Nothing but a fixed line reaches stderr: jq's own errors can quote the
# value they failed on, and the value is the token. Needs jq.
jq -c '
.inputs as $i
| if ($i.CONFLUENCE_EMAIL | type) != "string" or ($i.CONFLUENCE_API_TOKEN | type) != "string"
or $i.CONFLUENCE_EMAIL == "" or $i.CONFLUENCE_API_TOKEN == ""
then error("incomplete") else . end
| { token: ("\($i.CONFLUENCE_EMAIL):\($i.CONFLUENCE_API_TOKEN)" | @base64),
expires_at: (now + 3600 | floor | todate) }
' 2>/dev/null || { echo "confluence/mint: want CONFLUENCE_EMAIL and CONFLUENCE_API_TOKEN among the inputs" >&2; exit 1; }
# Confluence Cloud as a declared source: spaces listed, content found by
# CQL, one page read, the children of a page, folder, whiteboard,
# database or embed listed, a page's comments, replies, labels and
# attachments listed, and an author named from an account id. Reads only.
# Jira's declaration (the jira pack, beside this one) is the pattern for
# the login and the site's address, and yardr's google-docs for a read
# call whose answer is bounded; Confluence differs where noted.
#
# Checked against Atlassian's OpenAPI descriptions of the Confluence Cloud
# REST API (v2 and v1) and its developer and support pages on 2 November
# 2026, and by GET requests without a login to a public Confluence Cloud
# site, which gave the shapes of the answers and of the cursors. Not
# checked with a credential against a site of our own: a person has to make
# one (README.md says what that leaves open).
# spaces: GET /wiki/api/v2/spaces takes read:space:confluence and
# answers {results, _links}. _links.next is there only when another
# page is, and is the path of the next page with its cursor in it. v2
# also gives that address in a Link header, which a declared call
# cannot read, so the cursor is a parameter and the body's _links stays.
# page: GET /wiki/api/v2/pages/{id} takes read:page:confluence.
# body-format=storage puts the body at body.storage.value. Confluence
# storage format is XHTML with Confluence's own tags and macros
# (ac:structured-macro, ri:page), and it is what comes back: turning
# it into text is the reader's.
# children: GET /wiki/api/v2/{kind}/{id}/direct-children takes
# read:hierarchical-content:confluence. Atlassian marks the older
# /pages/{id}/children deprecated, the one deprecated operation in v2.
# The newer one answers a page's pages and also its folders,
# whiteboards, databases and embeds, each with a type. The same
# operation is also at /folders/{id}/direct-children,
# /whiteboards/{id}/direct-children, /databases/{id}/direct-children
# and /embeds/{id}/direct-children, with the same scope, parameters
# and answer, so one call walks all five. Its kind is a child's type
# and an s. It is an enum
# because it becomes a segment of the path, and the enum is the only
# check on it. Left out it is pages, so a call that names no kind
# asks the page endpoint. A default in a path is what pack.yaml's
# api: 3 is for.
# search: GET /wiki/rest/api/content/search takes search:confluence.
# CQL search is v1 only: Atlassian has no v2 equivalent, and has said
# it would deprecate this one only when there is. A hit is
# {id, type, status, title, _links} and the rest of its keys are
# empty objects and the paths of what expand would add; _links.webui
# names the space (/spaces/ENG/pages/123/Title), so nothing is
# expanded. The cursor is the one in _links.next, percent-decoded:
# that address writes it percent-encoded, and the call encodes it
# again. Its start is echoed and ignored, so none is declared.
#
# The comments, labels and attachments of a page were checked the same
# way on 4 November 2026: the OpenAPI description of v2, and GET requests
# without a login to a public Confluence Cloud site. The site had no
# inline comment to show, so the inline shapes are the description's.
# comments: GET /wiki/api/v2/pages/{id}/footer-comments and
# /wiki/api/v2/pages/{id}/inline-comments take read:comment:confluence
# and answer {results, _links}, paged as spaces is. Both list the root
# comments of the page and no reply: the description says root, and on
# a page whose comment has a reply the list gave the comment alone. A
# comment has no body unless body-format is named, so it is fixed to
# storage as page's is, and the body is at body.storage.value, markup
# and not text. A footer comment belongs to the page. An inline comment
# is on a passage of its text, and adds properties.inlineOriginalSelection,
# the passage. Either may carry resolutionStatus (open, reopened,
# resolved or dangling): the footer comments of the public site did,
# though the description lists it for inline comments only. The two are
# one call because only a segment of the path differs, and that segment
# is kind, an enum, the only check on it. It has no default: with footer
# for one, a call that left kind out would read the footer comments and
# look complete. A page id that does not exist answered 400 here, though
# the description says 404; labels and attachments answered 404.
# replies: GET /wiki/api/v2/footer-comments/{id}/children and
# /wiki/api/v2/inline-comments/{id}/children take the same scope and
# answer the replies to one comment, each with parentCommentId. A reply
# may have replies of its own, so a thread is walked down by id, with
# the kind of the comment it started from. The other kind is no error:
# a footer comment's id asked as inline answered 200 and an empty list.
# labels: GET /wiki/api/v2/pages/{id}/labels takes read:page:confluence,
# which page needs already, and answers {results, _links}. A label is
# {id, name, prefix}.
# attachments: GET /wiki/api/v2/pages/{id}/attachments takes
# read:attachment:confluence and answers the files attached to a page
# as metadata: id, title (the file's name), mediaType, fileSize,
# status, createdAt, version, comment, downloadLink and webuiLink. Both
# links are paths on the site, which _links.base completes. Nothing
# here follows one: what a file holds is yardr-racg's to settle. A
# file's entry is about a kilobyte, so 250 may pass the bound of 256 KiB.
#
# The author of a comment or a file was checked on 4 November 2026 the same
# way: the OpenAPI description of v1, and GET requests without a login to
# the public site.
# user: GET /wiki/rest/api/user takes read:confluence-user (the
# description lists read:content-details:confluence beside it as beta,
# as it does for search) and answers one account for the accountId in
# its query: 404 for an id that is not there, 403 for a caller who may
# not view users. That id is version.authorId in the answers of
# comments, replies and attachments, and is seen in two forms, 24 hex
# digits and a number, a colon and a UUID. The call is v1 because v2
# has no GET for a user (its likes/users answer ids alone), and its one
# lookup by id is POST /users-bulk, a list of ids in a body. A declared
# call takes no list, so no bulk is built: v1's /user/bulk takes a list
# of accountId that the description does not say how to write, and a
# query here holds one value to a key. One call names one account. Of a
# User's keys three are kept: accountId, displayName and accountType
# (atlassian, app, or empty). email, profilePicture, publicName and
# timeZone are among the rest, which every agent granted read would see
# and naming an author does not need. The public site answered 403 to a
# caller without a login, for the two ids tried, so the 404 is the
# description's.
#
# The credential. Atlassian takes an API token as HTTP Basic, the base64 of
# "email:token". A declared call's header takes one {credential}, so the
# email and the token are two mint inputs and confluence/mint makes the one
# value every call takes, CONFLUENCE_AUTH. The email is no secret, so it is
# asked in the clear. They live in the source's own env file; the
# jira pack's source has one of its own and is given the same two values
# again. Atlassian's form for a token with scopes has you choose the app
# first, so Jira's token is not made for Confluence, where a token made
# without scopes is the account's and serves both (SETUP.md, step 1).
#
# The host. Confluence is per site, so this is a placeholder that SETUP.md
# has the operator replace in the yard's copy of this file: the credential
# goes to the hosts a file names and to no others. A token with scopes is
# called at https://api.atlassian.com/ex/confluence/<cloud id>/wiki and one
# without at https://<site>.atlassian.net/wiki (Atlassian's support
# article on scoped tokens). Both take Basic, so only the base differs, and
# the /wiki is the base's, so every path below starts at /api or /rest. A
# pack applied again replaces the yard's copy and puts this one back
# (SETUP.md, step 6).
name: confluence
about: Confluence Cloud pages read through its REST API; spaces listed, content found by CQL, one page read, the children of a page, folder, whiteboard, database or embed listed, a page's comments, replies, labels and attached files listed, and an author named from an account id.
base: https://example.atlassian.net/wiki
credential:
env: CONFLUENCE_AUTH
header: "Authorization: Basic {credential}"
mint:
- name: CONFLUENCE_EMAIL
secret: false # an address, asked in the clear
- CONFLUENCE_API_TOKEN
mint_command: [confluence/mint] # the program beside this file; the source's own mint_command wins over it
check: spaces
calls:
spaces:
kind: read
about: List the spaces the account may see, 25 at a time. Answers each space's id, key, name, type and homepageId, the page children starts from. keys narrows it to spaces by key, as an address shows it (/spaces/ENG/...). When more than limit follow, _links.next is in the answer, and its cursor= value is the next call's cursor.
params:
keys: { type: string, max: 500, optional: true, log: true } # space keys, comma-separated; left out, every space the account may see
limit: { type: int, default: 25 } # spaces in the page, 250 at most
cursor: { type: string, max: 1000, optional: true } # the cursor= of the last answer's _links.next
request:
method: GET
path: /api/v2/spaces
query: { keys: "{keys}", limit: "{limit}", cursor: "{cursor}" }
answer: { select: [results, _links], max: 262144 } # 250 spaces are about 100 KB
page:
kind: read
about: Read one page by its id, which is in its address (/spaces/ENG/pages/123456/Title). Answers its title, status, spaceId, parentId, version and links, and its body in Confluence storage format, in body.storage.value, which is XHTML with Confluence's own tags and macros, markup and not text.
params:
id: { type: string, pattern: "^[0-9]+$", max: 20, log: true }
request:
method: GET
path: /api/v2/pages/{id}
query: { body-format: storage }
answer: { select: [id, status, title, spaceId, parentId, parentType, version, body, _links], max: 262144 } # a page's body is bounded as google-docs bounds a document's
children:
kind: read
about: List the direct children of a page, folder, whiteboard, database or embed, 25 at a time, in the order of the tree. Answers each child's id, title, status and type (page, folder, whiteboard, database or embed). To go down a level, give the child's id, and give its type with an s as kind. A child whose type is folder takes kind=folders. kind is one of pages (the default), folders, whiteboards, databases and embeds. When more than limit follow, _links.next is in the answer, and its cursor= value is the next call's cursor, with the same id and kind.
params:
id: { type: string, pattern: "^[0-9]+$", max: 20, log: true }
kind: { type: enum, values: [pages, folders, whiteboards, databases, embeds], default: pages, log: true } # the sort of node id is, written as a child's type and an s
limit: { type: int, default: 25 } # children in the page, 250 at most
cursor: { type: string, max: 1000, optional: true } # the cursor= of the last answer's _links.next
request:
method: GET
path: /api/v2/{kind}/{id}/direct-children
query: { limit: "{limit}", cursor: "{cursor}" }
answer: { select: [results, _links] }
search:
kind: read
about: Find content by CQL, Confluence's query language, 25 at a time, as in type = page AND space = "ENG" AND title ~ "runbook". Answers each hit's id, type, status, title and _links.webui, which names its space. When more than limit follow, _links.next is in the answer, and its cursor= value, percent-decoded, is the next call's cursor.
params:
cql: { type: string, max: 2000 } # free text, CQL: its value is in no event
limit: { type: int, default: 25 } # hits in the page; Confluence may hold it lower
cursor: { type: string, max: 1000, optional: true } # the cursor= of the last answer's _links.next, percent-decoded
request:
method: GET
path: /rest/api/content/search
query: { cql: "{cql}", limit: "{limit}", cursor: "{cursor}" }
answer: { select: [results, size, limit, _links], max: 262144 } # a hit is about 800 bytes, most of it empty keys
comments:
kind: read
about: List the comments on a page, given by its id, 25 at a time, of one kind. kind=footer is the discussion under the page and kind=inline is the comments on a passage of its text, so a page is read with both. Only the first comment of each thread is listed here; its replies are the call replies, by the comment's id. Answers each comment's id, status, pageId, version (number, authorId, createdAt) and its body, in Confluence storage format, in body.storage.value, markup and not text. A comment may have resolutionStatus (open, reopened, resolved or dangling), and an inline comment has properties.inlineOriginalSelection, the passage it is on. When more than limit follow, _links.next is in the answer, and its cursor= value is the next call's cursor.
params:
id: { type: string, pattern: "^[0-9]+$", max: 20, log: true } # the page's id, from its address (/spaces/ENG/pages/123456/Title)
kind: { type: enum, values: [footer, inline], log: true } # no default: a call that leaves it out is refused, not taken for all the comments
limit: { type: int, default: 25 } # comments in the page, 250 at most
cursor: { type: string, max: 1000, optional: true } # the cursor= of the last answer's _links.next
request:
method: GET
path: /api/v2/pages/{id}/{kind}-comments
query: { body-format: storage, limit: "{limit}", cursor: "{cursor}" }
answer: { select: [results, _links], max: 262144 } # the bodies are bounded as page's is
replies:
kind: read
about: List the replies to one comment, 25 at a time. Give the comment's id and its kind, footer or inline, as comments lists it. A kind that is not the comment's is no error and answers an empty list. Answers each reply as a comment is answered, with parentCommentId, the id of the comment it answers. A reply may have replies of its own, and its id with the same kind goes down a level. When more than limit follow, _links.next is in the answer, and its cursor= value is the next call's cursor.
params:
id: { type: string, pattern: "^[0-9]+$", max: 20, log: true } # the comment's id
kind: { type: enum, values: [footer, inline], log: true } # the comment's kind, as comments takes it
limit: { type: int, default: 25 } # replies in the page, 250 at most
cursor: { type: string, max: 1000, optional: true } # the cursor= of the last answer's _links.next
request:
method: GET
path: /api/v2/{kind}-comments/{id}/children
query: { body-format: storage, limit: "{limit}", cursor: "{cursor}" }
answer: { select: [results, _links], max: 262144 } # the bodies are bounded as page's is
labels:
kind: read
about: List the labels on a page, given by its id, 25 at a time. Answers each label's id, name and prefix. When more than limit follow, _links.next is in the answer, and its cursor= value is the next call's cursor.
params:
id: { type: string, pattern: "^[0-9]+$", max: 20, log: true } # the page's id
limit: { type: int, default: 25 } # labels in the page, 250 at most
cursor: { type: string, max: 1000, optional: true } # the cursor= of the last answer's _links.next
request:
method: GET
path: /api/v2/pages/{id}/labels
query: { limit: "{limit}", cursor: "{cursor}" }
answer: { select: [results, _links] } # a label is about 60 bytes, so 250 fit the default bound
attachments:
kind: read
about: List the files attached to a page, given by its id, 25 at a time. Answers the metadata of each file and not the file. That is its id, title (the file's name), mediaType, fileSize in bytes, status, createdAt, version, comment, and downloadLink and webuiLink, which are paths on the site that go after _links.base. Nothing here fetches a file. When more than limit follow, _links.next is in the answer, and its cursor= value is the next call's cursor.
params:
id: { type: string, pattern: "^[0-9]+$", max: 20, log: true } # the page's id
limit: { type: int, default: 25 } # attachments in the page, 250 at most
cursor: { type: string, max: 1000, optional: true } # the cursor= of the last answer's _links.next
request:
method: GET
path: /api/v2/pages/{id}/attachments
query: { limit: "{limit}", cursor: "{cursor}" }
answer: { select: [results, _links], max: 262144 } # an attachment is about 1 KB, so 250 may pass this bound, and a smaller limit then fits
user:
kind: read
about: Name the author of a comment or a file, given an Atlassian account id, which is version.authorId in the answers of comments, replies and attachments. Answers that account's accountId, displayName and accountType (atlassian for an Atlassian account, app for the bot user of an app, or empty) and nothing else of it. Names one account a call, so several authors are several calls. A 404 is an id that is not there, and a 403 is an account that may not view users. The name may be limited by the user's profile visibility.
params:
accountId: { type: string, pattern: "^[A-Za-z0-9:._-]+$", max: 128, log: true } # 24 hex digits, or a number, a colon and a UUID; the pattern blocks /, ?, & and a space
request:
method: GET
path: /rest/api/user
query: { accountId: "{accountId}" }
answer: { select: [accountId, displayName, accountType] } # three keys of a user's many, about 150 bytes, so the default bound holds
# Confluence
Confluence Cloud pages through its REST API, read as one Atlassian account. As
the declared source `confluence`, an agent lists a site's spaces, finds pages by
CQL, reads one page, lists the children of a page, folder, whiteboard, database
or embed, lists a page's comments, the replies to a comment, its labels and its
attached files, and names an author from an account id. Calls are read-only. A
page and a comment come back in Confluence's storage format, and the pack turns
nothing into text. An attached file comes back as its metadata with a download
link, and the pack fetches no file.
Nothing here is compiled into yardr; apply it with:
```sh
yardr pack apply /path/to/yardr-packs/confluence
```
SETUP.md is the operator's steps. The `jira` pack, beside this one, is the
pattern for the login and for the site's address, and this file says where
Confluence differs.
## One identity for the yard
The yard reads as an Atlassian account with no person signed in. Whatever that
account can read, every agent granted a call of this source reads, and the yard
does not narrow it per agent. SETUP.md says this before its first step, and has
the operator give the account the spaces the yard should read and no others.
## The calls
All of kind read, and one preset, `read`, that suggests them. The scopes are the
ones Atlassian's API descriptions list for each request, read on 2 November 2026
(`spaces`, `page`, `children`, `search`) and 4 November (the other five).
| call | request | parameters | scope |
|---|---|---|---|
| `spaces` | `GET /wiki/api/v2/spaces` | `keys`, `limit` (25), `cursor` | `read:space:confluence` |
| `page` | `GET /wiki/api/v2/pages/{id}?body-format=storage` | `id` | `read:page:confluence` |
| `children` | `GET /wiki/api/v2/{kind}/{id}/direct-children` | `id`, `kind` (`pages`), `limit` (25), `cursor` | `read:hierarchical-content:confluence` |
| `search` | `GET /wiki/rest/api/content/search` | `cql`, `limit` (25), `cursor` | `search:confluence` |
| `comments` | `GET /wiki/api/v2/pages/{id}/{kind}-comments?body-format=storage` | `id`, `kind` (`footer` or `inline`), `limit` (25), `cursor` | `read:comment:confluence` |
| `replies` | `GET /wiki/api/v2/{kind}-comments/{id}/children?body-format=storage` | `id` (a comment's), `kind`, `limit` (25), `cursor` | `read:comment:confluence` |
| `labels` | `GET /wiki/api/v2/pages/{id}/labels` | `id`, `limit` (25), `cursor` | `read:page:confluence` |
| `attachments` | `GET /wiki/api/v2/pages/{id}/attachments` | `id`, `limit` (25), `cursor` | `read:attachment:confluence` |
| `user` | `GET /wiki/rest/api/user?accountId={accountId}` | `accountId` | `read:confluence-user` |
What is behind the table:
- `children` is on `direct-children`. Atlassian marks `GET /pages/{id}/children`
deprecated, the one deprecated operation in v2, and `direct-children` is what
stands beside it. It answers a page's pages and also its folders, whiteboards,
databases and embeds, each with a `type`. The same operation is also at
`/folders/{id}/direct-children`, `/whiteboards/{id}/direct-children`,
`/databases/{id}/direct-children` and `/embeds/{id}/direct-children`, with the
same scope, parameters and answer, so one call walks all five. Its `kind` is
a child's `type` and an `s` (`pages`, `folders`, `whiteboards`, `databases`,
`embeds`), so a child of type `folder` takes `kind=folders`. Left out, `kind` is
`pages`, so a call that names no kind asks the page endpoint. It is an enum
because it becomes a segment of the path. A word outside the five sends no
request, a child's own `type` in the singular (`folder`) among them. One call is
one grant, and `read` covers all five.
- `search` is the v1 endpoint, because CQL search is v1 only: Atlassian has no v2
equivalent, and has said it would deprecate this one only when there is. A hit
is `id`, `type`, `status`, `title` and `_links`, and `_links.webui` names its
space (`/spaces/ENG/pages/98404/Runbooks`), so nothing is expanded. The rest of
a hit is empty objects and the paths of what `expand` would add, and a call's
`select` keeps top-level keys only, so they come along. The answer's `start` is
echoed and ignored by Confluence, so it is left out.
- The body is the one field a reader must not take for text. `page` fixes
`body-format=storage`, so the body is at `body.storage.value`, XHTML with
Confluence's own tags and macros, and a call that names another format is
refused. The call's `about` says it is markup. The answer is bounded at 256 KiB
as `google-docs` bounds a document, and one over it is refused and not cut.
- `comments` lists the root comments of a page and no reply. Atlassian's
description says root, and on a page whose comment has a reply the list gave
the comment alone. A reply is at `replies`, by the id of the comment it
answers, and may have replies of its own, so a thread is walked down by id
with the kind it started from. The kind of a comment is a segment of the path
(`footer-comments`, `inline-comments`), so `kind` is an enum, the only check
on that segment, and it has no default: with one, a call that left it out
would list the footer comments and look complete. A footer comment belongs to
the page. An inline comment is on a passage of its text, and
`properties.inlineOriginalSelection` is the passage. Either may have a
`resolutionStatus` (`open`, `reopened`, `resolved` or `dangling`). A comment
has no body unless a format is named, so both calls fix `body-format=storage`
as `page` does. The body is at `body.storage.value`, markup and not text, and
an answer is bounded at 256 KiB, refused and not cut. A comment names its
author by `version.authorId`, an Atlassian account id, which `user` turns into
a name. A `kind` that is not the comment's is no error on `replies`:
a footer comment's id asked as inline answered 200 and an empty list, so an
agent that mixes the two sees no replies where there are some.
- `labels` takes `read:page:confluence`, the scope `page` has already. A label is
`id`, `name` and `prefix`.
- `attachments` lists the files of a page as metadata and never the file: `id`,
`title` (the file's name), `mediaType`, `fileSize` in bytes, `status`,
`createdAt`, `version`, `comment`, `downloadLink` and `webuiLink`. Both links
are paths on the site, so a person opens `_links.base` followed by the path.
Nothing here follows one. What a file holds is yardr-racg's to settle. An
entry is about a kilobyte, so a page of 250 may pass the 256 KiB bound, and a
smaller `limit` then fits.
- `user` names one account from its id, which is `version.authorId` in a comment,
a reply and an attachment. It is the v1 endpoint, `GET /wiki/rest/api/user`,
because v2 has no GET for a user (its four `likes/users` reads answer ids
alone) and its one lookup by id is `POST /users-bulk`, a list of ids in a
body. Two forms of id are seen, 24 hex digits (`5b10ac8d82e05b22cc7d4ef5`) and
a number, a colon and a UUID (`712020:<uuid>`). The pattern of `accountId`,
letters, digits and `:` `.` `_` `-`, takes both and blocks `/`, `?`, `&` and a
space. The value is in the query, which the call encodes (the colon goes out
as `%3A`), so the pattern is a second guard and not the only one. The answer
keeps `accountId`, `displayName` and `accountType` (`atlassian` for an
Atlassian account, `app` for the bot user of an app, an empty string where
Confluence has none) and nothing else, about 150 bytes, under the default
bound. A user has more keys, `email`, `profilePicture`, `publicName` and
`timeZone` among them. Every agent granted `read` would see them and naming an
author needs none, so the `select` drops them, and the test checks the three
keys beside its golden. A 404 is an id that is not there and a 403 is an
account that may not view users. Atlassian says what comes back may be
restricted by the user's profile visibility, and a `displayName` may be the
same as the `publicName`. One call names one account and counts toward the
hourly limit of 60 (SETUP.md step 4), so an agent that has several authors
calls it for each and keeps the names.
- Paging is by cursor. v2 gives the next page's address in a `Link` header, which
a declared call cannot read, and also as `_links.next` in the body, which stays
in the answer. Its `cursor=` value is `cursor` of the same call again. v1
search writes the value percent-encoded and the call encodes what it is given,
so for `search` it is given decoded. Both were tried on a public site: a v2
cursor goes on from where it was with any `limit`, and v1 search takes its
cursor with the same `cql` and no `start`. Paging on the four calls of 4 November
was seen on a page's footer comments and on its attachments: `_links.next`
holds the cursor unencoded, as v2's others do, and the comments came oldest
first (the cursor names CREATED_DATE, ascending).
- The file names the check call `spaces`, which needs no parameter, so `yardr
source check confluence` reaches the site with the minted credential.
- yardr hands an agent the status of a failed call and not Atlassian's error body.
A 404 on `page` is a page that is not there or one the account may not view,
which Confluence does not tell apart, and a 400 on `search` is a CQL it cannot
parse. On the public site a page id that does not exist answered 404 on `labels`
and `attachments` but 400 on `comments`, and a comment id that does not exist
answered 400 on `replies`, though Atlassian's descriptions say 404 for all of
them. A 400 from `comments` or `replies` can be an id that is not there, as well
as a `limit` over 250.
## The credential and the address
**The login.** Atlassian takes an API token as HTTP Basic, the base64 of
`email:token`. A call's header takes one `{credential}`, so the email and the
token are two mint inputs and `confluence/mint` makes the one value
(`CONFLUENCE_AUTH`). It is `jira/mint` with this pack's names. The email is no
secret and is asked in the clear. The two live in this source's own env file; the
`jira` source has another and is given the same two values again, so the yard has
two sources and one login. A token that is replaced is given to each.
**Which token.** A service account has scoped tokens only, and the form for one
has you choose the app and then its scopes. So where the account's token for
`jira` was made for Jira, Confluence needs a second token for the same account,
with the seven scopes above. A token without scopes, which an ordinary account
can make, serves both apps and can do everything the account can. The pack's
mint takes either, and SETUP.md says which goes where. A scope is fixed when a
token is made and Atlassian answers a missing one with a 401, so a token made
before a call was added is made again with that call's scope before the call
works: `read:comment:confluence` and `read:attachment:confluence` for
`comments`, `replies` and `attachments`, and `read:confluence-user` for `user`
(SETUP.md step 6).
**The address.** Confluence is per site, so the declaration ships the placeholder
`https://example.atlassian.net/wiki` and SETUP.md has the operator write their
own into a copy of the file (`confluence.local.yaml`, ignored by git) and add
it. A token with scopes is called at
`https://api.atlassian.com/ex/confluence/<cloud id>/wiki` and one without at
`https://<site>.atlassian.net/wiki`. Both take Basic, so only the base differs,
and every path in the file starts at `/api` or `/rest`. A pack applied again
replaces the yard's copy and puts the placeholder back; the test pins that, and
SETUP.md step 6 says what to do. Until the copy is added again the source calls
the placeholder with the account's credential. Unlike `jira`, this pack has no
command to refuse to send there, since all its calls are declared. If a real user
trips on this, a core `base_url` setting is its own bead.
## Files
- `pack.yaml`, `confluence.yaml`: the pack, with the `read` preset, and the
declaration.
- `confluence/mint`: sh and jq, the Basic credential from the email and the
token. It prints a fixed line on failure, since jq's own errors quote the value.
- `SETUP.md`: the account, its token, the address, the grant, the first calls.
- `.gitignore`: the operator's copy of the declaration.
- `test`, `testdata/`: the tests. `calls/` holds a `.case` for each scenario
(the call, the stand-in's answer and status) and the `.golden` of what
happened; `standin.py` is the stand-in for Confluence; `declaration.golden`
and the two `*.requests.golden` pin the rest.
## Runtimes
`confluence/mint` needs `sh` and `jq` 1.6 or later. The test needs those, and
`python3` and `yardr`, and fails without them, since a skipped test would leave
the requests unpinned. The pack is written for pack API 3 (`api: 3` in
`pack.yaml`), because `children`'s `kind` has a default and is in a path,
which API 3 added. A yardr that reads less refuses the pack with `upgrade yardr`,
not with a complaint about the path.
## Testing
```sh
./test
```
in this directory, about five seconds, no network and no secret. The gate
(`.yardr/test`) runs `yardr pack check` first.
- `mint`: the value, the expiry, what it says when an input is missing or empty,
and that the token is in no argument of anything it runs.
- the declaration as the yard reads it against `testdata/declaration.golden`, and
that every call is a read by GET.
- the real yardr in a scratch yard with its own home, the pack applied, and a copy
of the declaration aimed at the stand-in on a loopback port, which the credential
reaches as the right Basic value: the check call; a `.case` for each call, with
its first page and its next, its default and its given parameters (`children`
once for each of the five kinds, each on a node and a path segment of its own,
`comments` and `replies` once for each of their two), an answer over the bound
(`page`, `comments`, `replies` and `attachments`), the statuses 400, 401, 403
and 404, and the parameters the call must refuse (an id with a slash on each
call that takes one in its path, an account id that brings a query of its own,
a missing id, a body format, a CQL of 2001 characters, a limit that is no
number, a `kind` in the singular or one from another call, no `kind` on
`comments`), each of which sends no request. A changed path, query, select,
bound, pattern or enum value is a changed golden. The case of `user` answers
with an email, an avatar and a time zone, and the test checks beside its
golden that the call keeps three keys and none of those.
- the calls again through the gateway's address, whose base carries a path.
- a pack applied again over an edited address: the plan names the address it
takes away, the shipped address returns, and the env file and a grant stand.
- the token and the credential made of it are in no file of the run but the env
file: not an output, not the yard's store, events or log.
`UPDATE_GOLDEN=1 ./test` writes the goldens again. Read the diff before you
commit it.
## What was checked, and what was not
Checked on 2 November 2026 against Atlassian's pages: the first four requests, their
parameters and scopes in the OpenAPI descriptions of v2 and v1; that pages,
folders, whiteboards, databases and embeds each have a `direct-children`, none of
them deprecated, with the one scope, the same parameters (`id`, `cursor`, `limit`
from 1 to 250 and 25 by default, `sort`) and the same answer type; the
deprecation of `GET /pages/{id}/children`; the gateway's address for a
token with scopes and that Atlassian's own examples send it as Basic; that a 401
is the answer to a missing scope and to an account without access. Seen on a
public Confluence Cloud site without a login (openmrs.atlassian.net): the shapes
of the answers of the first four requests (`children` on a page's id), the cursors,
that v1 search takes its cursor alone, and `tenant_info`.
Checked on 4 November 2026 for `comments`, `replies`, `labels` and
`attachments`: the requests, their parameters and the scope of each
(`read:comment:confluence` for comments and replies, `read:page:confluence` for
labels, `read:attachment:confluence` for attachments) in the OpenAPI description
of v2. Seen on the public site without a login: a page's footer comments, which
are the root comments and no reply; a reply at its comment's `children`; a
comment with no body when no format is named; a page's labels and attachments;
the cursors of a page's footer comments and of its attachments, followed to the
next page; and the answers for a page or a comment that is not there (above).
Checked on 4 November 2026 for `user`: in the OpenAPI description of v1, the
request, its parameter (`accountId`, required, a string), its answers 200, 401,
403 and 404, its scope (`read:confluence-user`, with
`read:content-details:confluence` beside it as beta) and the keys of the answer,
in the description's `User`, which has the three kept (`accountId`,
`accountType`, `displayName`) among its 18 keys; in the description of v2, that
no GET reads a user (the four `likes/users` answer ids alone) and that its one
lookup by id is `POST /users-bulk`. Seen on the public site without a login: the
endpoint answers 403 to an anonymous caller, for the two ids tried, which were no
account, with `User not permitted to view user profiles`. So the 403 is seen, and
the 404 and the shape of an answer are the description's.
Not checked with a credential against a site of our own, because a person has to
make one (a free Atlassian Cloud site serves this pack and the Jira one). The
live proof is a later bead. It should settle:
- that a token with scopes is accepted as Basic at the gateway, as Atlassian's
support pages say. A community thread says only Bearer works there. If a site
answers 401, the fix is in `confluence/mint` and the header in
`confluence.yaml`;
- that the first four scopes are enough. `search:confluence` is the one the v1 page
lists as current, beside `read:content-details:confluence` as beta; if search
answers 401 under the first alone, the second is added to the token;
- whether a service account can make one token with the scopes of both apps, which
would make the Jira token serve here;
- what the account sees in a space that lets every user of the site view it;
- what `children` answers for a folder, whiteboard, database or embed. Those four
endpoints are read in Atlassian's description and not called, with a login or
without, and the cases' answers are made like the page's. The live proof needs a
node of each kind that has children;
- a very long page, and a space with thousands of children;
- an inline comment. The public site had none, so what the pack says of
`resolutionStatus` and `properties` on one is the description's, and the
fixtures of `comments` and `replies` with `kind=inline` are made from it. The
live proof needs a page with an inline comment and a reply to it;
- that `read:comment:confluence` and `read:attachment:confluence` are enough for
the four new calls, as the first four scopes are for theirs;
- that `read:confluence-user` is enough for `user`. The description lists
`read:content-details:confluence` beside it as beta, as it does for `search`;
if `user` answers 401 under the first alone, the second is added to the token;
- what a real account answers: the 404 for an id that is not there; whether
`displayName` is ever null (the description marks it nullable, and says
`publicName` always has a value); and what an account that limits its profile
visibility, a deactivated one and an app's bot user show. The `app` and the
empty `accountType` are the description's.
## Left out
An attachment's contents (a file's bytes are yardr-racg's to settle);
blogposts, and the comments, labels and attachments of one; the comments, labels
and attachments of anything but a page (a space, an attachment, custom content);
several accounts named in one call (`user` names one, and an agent calls it for
each. v1's `GET /wiki/rest/api/user/bulk` takes `accountId` as a list that its
description does not say how to write; OpenAPI's default for a query is one key
for each value, and a declared call holds one value to a query key. v2's `POST
/wiki/api/v2/users-bulk` takes up to 250 ids in a body, and no parameter is a
list. yardr-packs-fjmg weighs it); what else of an account `user` could give
(its email, picture, public name and time zone, which the `select` drops on
purpose); the `sort`, `status` and `resolution-status` filters of the comment
lists, the `prefix` filter of labels and the `mediaType` and `filename` filters
of attachments; comment likes, versions and properties;
reading a folder, whiteboard, database or embed itself (a walk lists one with
its title, and `page` takes a page's id only); page history and versions; any
write; turning storage format into text; and Confluence Data Center and Server,
whose API and login are others. Each is a later bead.
# Setting up Confluence
The pack wires Confluence Cloud into the yard as the declared source
`confluence`. An agent lists the site's spaces, finds pages by CQL, reads one
page, lists the children of a page, folder, whiteboard, database or embed, lists
a page's comments, the replies to a comment, its labels and its attached files,
and names an author from an account id. It only reads. A page and a comment come
as Confluence's storage format, which is markup and not text: turning it into
text is the reader's. An attached file comes as its metadata, and its contents
are not read.
**The yard reads as one Atlassian account.** Every agent granted a call of this
source reads what that account can read, in every space it can view, and the
yard does not narrow it per agent. The grant is the permission. Make the account
for the yard alone, and give it the spaces the yard should read and no others
(step 1). If the yard also runs the jira pack, this is the same account: one
login, two sources.
You need a Confluence Cloud site and an administrator of it for step 1. The
pack's one program, `confluence/mint`, needs `jq` on the yard's machine. Commands
below call the pack's directory (the `confluence` directory of the yardr-packs
checkout) `<pack dir>`. The steps follow Atlassian's documentation as of 2
November 2026, and none was run against a site of our own when the pack was
written, so a screen may be named otherwise than here.
1. **The account and its token, in Atlassian.** Use the account the jira pack
uses, if there is one: make it as step 1 of the jira pack's SETUP.md says (a
service account in Atlassian Administration, under Directory, Service
accounts, fits best). Otherwise make an Atlassian account that only the yard
uses.
Then give it Confluence, and only the spaces the yard should read. The
account needs access to Confluence on the site. Then, in each such space,
Space settings, Permissions: give the account (or a group that holds it
alone) View, and nothing more. Confluence decides what the account sees by its
space permissions and its groups. A space that lets every user of the site
view it, or a group the account belongs to, shows the account that space as
well, so step 5 has you compare what it sees with what you meant.
Then make its API token. Atlassian recommends a token with scopes, and gives
a service account no other kind. Its form for one has you choose the app and
then that app's scopes, so a token the jira pack was given for Jira is not
made for Confluence, and the same account gets a second token for it:
- A service account: Atlassian Administration, Directory, Service accounts,
the account, Create credential, API token.
- An ordinary account: sign in as it, open
https://id.atlassian.com/manage-profile/security/api-tokens and choose
Create API token with scopes.
Choose Confluence and the seven scopes the calls need, which are the ones
Atlassian's API description lists for their requests: `read:space:confluence`
(spaces), `read:page:confluence` (page and labels),
`read:hierarchical-content:confluence` (children, of any kind),
`search:confluence` (search), `read:comment:confluence` (comments and
replies), `read:attachment:confluence` (attachments) and
`read:confluence-user` (user). Atlassian shows the token once. A token lasts 1
to 365 days, a year unless you choose otherwise, so note the day it ends. Step
6 says what to do then.
A token made without scopes (Create API token, an ordinary account only) is
the account's, serves Jira and Confluence alike, and can do everything the
account can. If the jira pack already has one, it is the same token, given to
this source as well. Step 2 says where each kind goes.
2. **The pack and the site's address.** Apply the pack as the operator:
```sh
yardr pack apply <pack dir>
```
The declaration it installs gives the source a placeholder address,
`https://example.atlassian.net/wiki`. A source's credential goes only to the
hosts its file names, so the yard's copy of the file must name yours, with
its `/wiki`. A token with scopes is called through Atlassian's gateway,
`https://api.atlassian.com/ex/confluence/<cloud id>/wiki`. The site gives
its cloud id without a login:
```sh
curl -s https://<site>.atlassian.net/_edge/tenant_info
```
A token without scopes is called at the site, `https://<site>.atlassian.net/wiki`.
Write the address you need into a copy of the declaration, beside the
original so that the program it names is found, and add the copy to the yard.
The copy is ignored by git. For a token with scopes:
```sh
sed 's|^base: .*|base: https://api.atlassian.com/ex/confluence/<cloud id>/wiki|' <pack dir>/confluence.yaml > <pack dir>/confluence.local.yaml
```
And for a token without:
```sh
sed 's|^base: .*|base: https://<site>.atlassian.net/wiki|' <pack dir>/confluence.yaml > <pack dir>/confluence.local.yaml
```
Then add the copy:
```sh
yardr source add <pack dir>/confluence.local.yaml
```
```sh
yardr source show confluence
```
The first line of `source show` is the address in use. Until it is yours,
`yardr source check confluence` calls the placeholder and fails. Do step 3
after this one and not before, so that the credential is never one the yard
could send to the placeholder.
3. **The account's email and token, in the yard.** The credential is two names:
`CONFLUENCE_EMAIL`, the account's email, and `CONFLUENCE_API_TOKEN`. The
pack's `confluence/mint` makes the Basic credential Atlassian takes from
them. They live in this source's own env file, mode 0600, and never in the
store. The jira pack's source has a file of its own and is given the same two
values again, so a token that is replaced is given again to each source.
`yardr source key` asks for both and shows nothing as they are typed. It
writes the file the source reads (`~/.yardr/sources/confluence.env`):
```sh
yardr source key confluence
```
A crew member who needs it given asks on a card instead, and the operator
answers it at a terminal:
```sh
yardr bead ask <bead> "The email and token of the yard's Confluence account" --secret confluence --context "The yard's Atlassian account: its email, and an API token for Confluence with the scopes of SETUP.md step 1."
```
Then check the source:
```sh
yardr source check confluence
```
The check makes the call `spaces` with its defaults and prints the first
spaces the account sees. A 401 is the credential, the address or the account:
the email and token, a token that ended, a token with scopes sent to the site
and not to the gateway (a token without scopes sent to the gateway fails too),
a cloud id that is another site's, a token made without a scope the call needs
(Atlassian answers that with a 401 as well, and a scope is fixed when the token
is made, so a token without one is made again), or an account that has no
access to Confluence or to the space.
4. **The grant.** A pack of kind source asks none at import, so what a crew
member may call is granted afterwards. `yardr pack show confluence` prints
the pack's suggestion, one preset, `read`. yardr's `--preset` names the presets
of the packs inside its binary and not those of a pack applied from a
directory, so the grant is by name: `read` is every read call of the source,
one a later file adds included. The source reads as one account, so a grant of
`read` lets that crew member read everything the account may. Grant it to those
who need it:
```sh
yardr source grant confluence read --crew yardmaster
```
A group of agents takes `--group <group>` in place of `--crew <name>`. A
grantee who comes later gets one card:
```sh
yardr bead ask <bead> "Let the reviewers read Confluence" --run "source grant confluence read --group reviewers" --context "The reviewers read the team's pages to check a bead's work; the card names each call it covers."
```
One caller may make 60 calls of a source in an hour, and an agent that walks
a page tree, reads the replies of each comment on a page, or names each
author of a thread with `user` (one call for an account), uses them quickly.
The operator raises the limit where the work needs it:
```sh
yardr source set confluence calls_per_hour=300
```
5. **Try it, and look at what the account sees.** As the operator, who may make
any call:
```sh
yardr call confluence spaces
```
Compare the list with the spaces you meant the account to read. A space in it
that you did not mean is a space whose permissions let this account in: remove
the account (or the group that lets it in) there, and look again. The same
call with `keys` finds a space by the key its address shows (`/spaces/ENG/...`):
```sh
yardr call confluence spaces keys=ENG
```
The answer gives each space's `id`, `key`, `name`, `type` and `homepageId`, the
page to start walking from. A page's id is also in its address
(`/spaces/ENG/pages/98404/Runbooks` is page 98404). A page, then, and its
children:
```sh
yardr call confluence page id=98404
```
```sh
yardr call confluence children id=98404
```
`children` answers each child's `id`, `title`, `status` and `type`, which is
`page`, `folder`, `whiteboard`, `database` or `embed`. To go down a level, give
the child's `id` and its `type` with an `s` as `kind`. For a folder:
```sh
yardr call confluence children id=98411 kind=folders
```
The other kinds are `whiteboards`, `databases` and `embeds`. A call with no
`kind` asks for the children of a page, and `page` takes the id of a page only.
Content is found by CQL, Confluence's query language:
```sh
yardr call confluence search cql='type = page AND space = "ENG" AND title ~ "runbook"'
```
A hit gives `id`, `type`, `status`, `title` and `_links.webui`, whose path
names the space; `_links.base` and the `webui` path together are the link a
person opens.
A page has footer comments, under the page, and inline comments, on a passage
of its text. `kind` says which, and has no default, so read both:
```sh
yardr call confluence comments id=98404 kind=footer
```
```sh
yardr call confluence comments id=98404 kind=inline
```
`comments` lists the first comment of each thread and no reply. The replies to
a comment are found by its `id`, with the same `kind`, and a reply may have
replies of its own:
```sh
yardr call confluence replies id=131201 kind=footer
```
A `kind` that is not the comment's is no error: `replies` answers an empty list,
so check the kind before reading it as "no replies". An inline comment gives
`properties.inlineOriginalSelection`, the passage it is on, and a comment of
either kind may give `resolutionStatus`. Its author is `version.authorId`, an
Atlassian account id, which `user` turns into a name (below). Then the labels
of a page, and the files attached to it:
```sh
yardr call confluence labels id=98404
```
```sh
yardr call confluence attachments id=98404
```
`attachments` gives each file's `title` (its name), `mediaType`, `fileSize` in
bytes and `downloadLink`, and never the file. The link is a path on the site:
`_links.base` and the path together are what a person opens. No call here
fetches it.
A comment, a reply and an attachment each name their author by
`version.authorId`, and `user` turns that id into a name:
```sh
yardr call confluence user accountId=5b10ac8d82e05b22cc7d4ef5
```
It answers `accountId`, `displayName` and `accountType`, which is `atlassian`
for an Atlassian account and `app` for the bot user of an app, and nothing
else of the account: its email, picture and time zone are not passed on. It
names one account, so an agent with several authors calls it for each, and
each call counts toward the hourly limit of step 4. A 404 is an id that is not
there. A 403 is an account that may not view users; Atlassian's description
asks for the 'Can use' permission on the site. Atlassian may limit what it
tells of a user by their profile visibility, so a name can be the public name
only.
`spaces`, `children`, `search`, `comments`, `replies`, `labels` and
`attachments` answer one page of 25, and `limit` asks for another size (250 at
most for all but `search`). An answer that has another page keeps it as
`_links.next`, whose `cursor=` value is `cursor` in the same call again (for
`children`, `comments` and `replies`, with the same `id` and `kind`). Search
writes it percent-encoded (`%2B`, `%3D`), and the call encodes what it is
given, so give it decoded:
```sh
yardr call confluence children id=98400 cursor=eyJpZCI6Ijk4NDExIiwiY29udGVudE9yZGVyIjoiY2hpbGQtcG9zaXRpb24iLCJjb250ZW50T3JkZXJWYWx1ZSI6MjAwMH0=
```
`page` answers the body as `body.storage.value`, in storage format: XHTML with
Confluence's own tags (`ac:structured-macro`, `ac:link`, `ri:page`). It is
markup and not text, and the answer is at most 256 KiB, refused and not cut
when a page is longer. A comment is the same markup in the same place, and
`comments`, `replies` and `attachments` answer at most 256 KiB too. A failed
call tells the agent the status alone: a 404 is a page that is not there, or
one the account may not view (Confluence answers both so), and a 400 from
`search` is a CQL it cannot parse. A 400 from `comments` or `replies` can also
be an id that is not there: Atlassian's pages say 404, and the public site
that was tried answered 400.
6. **Updating the pack, and when the token ends.** A pack applied again replaces
the source's declaration whole, and the placeholder address comes back with
it. Your env file and the grants stay. After pulling the checkout, read what
would change:
```sh
yardr pack check <pack dir>
```
If you apply the pack, the plan lists the address as `base: <your address> ->
https://example.atlassian.net/wiki`. That line is how you notice, and it is the
edit being undone. Until you add your copy again, the source calls the
placeholder, a host that is not your site, with the account's credential, and
`yardr source check confluence` fails. The shorter way skips the apply. Make
the copy again from the new `confluence.yaml` with step 2's `sed`, and add it
with `yardr source add`. The source then never points at the placeholder.
An update from a version that had only `spaces`, `page`, `children` and
`search` adds `comments`, `replies`, `labels`, `attachments` and `user`, and
one from a version without `user` adds that one. The plan lists them and names
each crew member who gets them through a grant of `read` already held. Nobody
is asked, and `yardr source revoke confluence read --crew <name>` takes the
grant back. A token with scopes made before a call was added lacks its scope:
`read:comment:confluence` and `read:attachment:confluence` for `comments`,
`replies` and `attachments`, and `read:confluence-user` for `user`. Atlassian
answers those calls with a 401 until a token with all seven scopes is made
(step 1) and given again with `yardr source key confluence`. A token made
without scopes needs nothing.
When the token ends, make a new one as in step 1 and give it again with
`yardr source key confluence`. A token that expired or was revoked shows as a
401 from `yardr source check confluence`.
7. **Ending.** Remove the source, which takes its grants with it:
```sh
yardr source rm confluence
```
Revoke the token at id.atlassian.com, or delete the service account's
credential in Atlassian Administration, and take the account out of the spaces.
What is not there, on purpose: an attachment's contents; blogposts, and the
comments, labels and attachments of anything but a page; several account ids
named in one call, and an account's email, picture or time zone (`user` names
one account); reading a folder, whiteboard, database or embed itself (`children`
lists them, with their titles, and `page` takes a page's id only); any write;
turning storage format into text; and Confluence Data Center and Server, whose
API and login are others.
The yard's source format cannot express what is not listed, and the pack does not
change yardr.
#!/bin/sh
# The confluence pack's tests: what the gate runs beside yardr pack check.
# Needs no network and no secret, done in seconds. It wants jq 1.6 or
# later, python3 and yardr, and fails without them, since a skipped test
# would leave the requests unpinned.
#
# mint confluence/mint makes the Basic credential from the email and
# the token, and says nothing of either when it cannot; the token
# is in no argument of anything it runs.
# core the real yardr in a scratch yard of its own, with the pack
# applied: the declaration as the yard reads it, held to a golden
# and to reading nothing but with GET; then a copy of it aimed at
# a stand-in for Confluence on a loopback port (testdata/
# standin.py), the operator's way of SETUP.md step 2.
# calls each testdata/calls/*.case is one `yardr call` of that source
# against the stand-in. The case's golden holds the requests the
# stand-in saw (method, path and query as sent, whether the
# credential was the right one), the exit status, what the call
# kept of the answer and what it said on stderr. A changed
# request, a changed select and a changed bound are each a
# changed golden, and a parameter the call should refuse shows
# as no request at all.
# host a pack applied again over the operator's edited host, which the
# shipped host replaces, and what stands.
# secrets the token and the credential made of it are in no file the
# run left but the env file, and in no output.
#
# UPDATE_GOLDEN=1 ./test writes the goldens again: read the diff before
# committing it.
set -u
cd "$(dirname "$0")"
here=$(pwd)
fail=0
for need in jq python3; do
command -v "$need" >/dev/null 2>&1 || { echo "test: needs $need on PATH"; exit 1; }
done
YARDR=${YARDR:-$(command -v yardr || echo /Users/mckean/go/bin/yardr)}
[ -x "$YARDR" ] || { echo "test: needs yardr ($YARDR)"; exit 1; }
tmp=$(mktemp -d)
# The scratch yard's socket path is held to 100 bytes, so its directory is
# short, and is not the tmp of a long $TMPDIR.
core=$(mktemp -d /tmp/confluencet.XXXXXX)
cleanup() {
[ -z "${standin_pid:-}" ] || kill "$standin_pid" 2>/dev/null
# The scratch yard's server starts on its first command and would outlive
# the directory it owns: stop it first.
env -i HOME="$core/h" PATH="$PATH" "$YARDR" --home "$core/y" --actor tester server stop >/dev/null 2>&1
rm -rf "${core:?}" "${tmp:?}"
}
trap cleanup EXIT HUP INT TERM
# check <name> <actual file> <golden file>
check() {
if [ "${UPDATE_GOLDEN:-}" = 1 ]; then
if ! cmp -s "$2" "$3"; then cp "$2" "$3"; echo "test: $1 golden written"; fi
return
fi
if [ ! -e "$3" ]; then
echo "test: $1 has no golden $3 (UPDATE_GOLDEN=1 ./test writes it)"
fail=1
elif cmp -s "$2" "$3"; then
echo "test: $1 ok"
else
echo "test: $1 differs from $3:"
diff "$3" "$2" | sed 's/^/ /' | head -30
fail=1
fi
}
# ok <name> <condition's exit status> [detail]
ok() {
if [ "$2" = 0 ]; then echo "test: $1 ok"; else echo "test: $1 FAILED${3:+: $3}"; fail=1; fi
}
# The account the tests use is a fixture, not a secret.
email=ada@example.com
token=tok-FIXTURE-0123456789abcdef
auth=$(printf '{"inputs":{"CONFLUENCE_EMAIL":"%s","CONFLUENCE_API_TOKEN":"%s"},"scope":""}' "$email" "$token" | ./confluence/mint | jq -r .token)
# ---- mint ------------------------------------------------------------------
printf '{"inputs":{"CONFLUENCE_EMAIL":"%s","CONFLUENCE_API_TOKEN":"%s"},"scope":""}' "$email" "$token" | ./confluence/mint >"$tmp/mint.out"
want=$(printf '%s:%s' "$email" "$token" | base64 | tr -d '\n')
[ "$(jq -r .token "$tmp/mint.out")" = "$want" ]
ok "mint makes base64 of email:token" $?
jq -e '(.expires_at | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) and (.expires_at | fromdateiso8601) > now and ((keys | sort) == ["expires_at", "token"])' "$tmp/mint.out" >/dev/null
ok "mint answers a future RFC 3339 expiry and nothing else" $?
# A token may hold a colon, and an email a plus; neither is the mint's to cut.
printf '{"inputs":{"CONFLUENCE_EMAIL":"a+b@example.com","CONFLUENCE_API_TOKEN":"ATATT3x:y=z"}}' | ./confluence/mint | jq -e --arg w "$(printf 'a+b@example.com:ATATT3x:y=z' | base64 | tr -d '\n')" '.token == $w' >/dev/null
ok "mint keeps a colon in the token and a plus in the email" $?
i=0
for bad in '{"inputs":{"CONFLUENCE_EMAIL":"'"$email"'"}}' '{"inputs":{"CONFLUENCE_API_TOKEN":"'"$token"'"}}' '{"inputs":{"CONFLUENCE_EMAIL":"","CONFLUENCE_API_TOKEN":"'"$token"'"}}' '{"inputs":{"CONFLUENCE_EMAIL":"'"$email"'","CONFLUENCE_API_TOKEN":""}}' '{"inputs":"'"$token"'"}' 'not json '"$token" '{"inputs":{"JIRA_EMAIL":"'"$email"'","JIRA_API_TOKEN":"'"$token"'"}}'; do
i=$((i + 1))
printf '%s' "$bad" | ./confluence/mint >"$tmp/m.out" 2>"$tmp/m.err"
rc=$?
[ "$rc" = 1 ] && [ ! -s "$tmp/m.out" ] && [ "$(cat "$tmp/m.err")" = "confluence/mint: want CONFLUENCE_EMAIL and CONFLUENCE_API_TOKEN among the inputs" ]
ok "mint refuses input $i with a fixed line that quotes none of it" $?
done
# What the shell runs is what ps shows anyone on the box: the token is in
# none of it, only the program of jq.
printf '{"inputs":{"CONFLUENCE_EMAIL":"%s","CONFLUENCE_API_TOKEN":"%s"}}' "$email" "$token" | sh -x ./confluence/mint >/dev/null 2>"$tmp/x.err"
! grep -qF -e "$token" -e "$auth" "$tmp/x.err"
ok "mint puts the token in no argument it runs" $?
grep -q '^+ jq ' "$tmp/x.err"
ok "mint's trace shows the jq it runs (so the check above looked at something)" $?
# ---- core: the scratch yard ------------------------------------------------
mkdir -p "$core/h"
yrun() { env -i HOME="$core/h" PATH="$PATH" "$YARDR" --home "$core/y" --actor tester "$@"; }
if ! yrun init --harness claude --operator tester >"$core/init.out" 2>&1; then
echo "test: core FAILED: the scratch yard could not be made: $(tail -c 300 "$core/init.out")"
exit 1
fi
if ! yrun pack apply "$here" --yes >"$core/apply.out" 2>&1; then
echo "test: core FAILED: the pack did not apply: $(tail -c 300 "$core/apply.out")"
exit 1
fi
# The declaration as the yard reads it, with the shipped host: base,
# credential and every call's parameters, request and answer.
yrun source show confluence --json | jq -S .declaration >"$core/declaration.json"
check "declaration as the yard reads it (base, credential, check, calls)" "$core/declaration.json" testdata/declaration.golden
jq -e '.base == "https://example.atlassian.net/wiki" and ([.calls[] | .kind == "read" and .request.method == "GET"] | all) and (.calls | length) == 9' "$core/declaration.json" >/dev/null
ok "the shipped host is the placeholder, and every call is a read by GET" $?
yrun pack show confluence >"$core/packshow.out" 2>&1
grep -q '1\. read: ' "$core/packshow.out" && grep -q 'confluence: read' "$core/packshow.out"
ok "pack show prints the one preset, read, as SETUP.md step 4 says" $?
yrun source show confluence >"$core/show.shipped" 2>&1
grep -qF 'https://example.atlassian.net/wiki' "$core/show.shipped"
ok "source show names the host in use, the placeholder until the operator's copy is added" $?
# The operator's copy: the declaration with its base replaced, and the
# program beside it, as SETUP.md step 2 makes it. Here the site is the
# stand-in.
mkdir -p "$core/mine"
cp -R "$here/confluence" "$core/mine/"
cp testdata/spaces.json "$core/answer.json"
: >"$core/requests"
python3 -I testdata/standin.py "$core/port" "$core/requests" "$core/answer.json" 2>"$core/standin.err" <<EOF &
$auth
EOF
standin_pid=$!
i=0
while [ ! -s "$core/port" ] && [ "$i" -lt 200 ]; do sleep 0.05; i=$((i + 1)); done
if [ ! -s "$core/port" ]; then
echo "test: core FAILED: the stand-in did not listen: $(cat "$core/standin.err")"
exit 1
fi
site=http://127.0.0.1:$(cat "$core/port")
sed "s|^base: .*|base: $site/wiki|" "$here/confluence.yaml" >"$core/mine/confluence.local.yaml"
# The credential is given as SETUP.md step 3 gives it: yardr source key, here
# with the two lines on its standard input, which the server writes into the
# source's env file.
envfile=$core/y/sources/confluence.env
yrun source add "$core/mine/confluence.local.yaml" >"$core/add.out" 2>&1 && printf 'CONFLUENCE_EMAIL=%s\nCONFLUENCE_API_TOKEN=%s\n' "$email" "$token" | yrun source key confluence >>"$core/add.out" 2>&1 && yrun source set confluence calls_per_hour=1000 >>"$core/add.out" 2>&1
ok "the operator's copy is added and its credential given by source key" $? "$(tail -c 300 "$core/add.out")"
[ -f "$envfile" ] && [ "$(ls -l "$envfile" | cut -c1-10)" = "-rw-------" ]
ok "the env file is where SETUP.md says, mode 0600" $?
# The check call: spaces, with the defaults, minted by confluence/mint.
yrun source check confluence >"$core/check.out" 2>&1
rc=$?
[ "$rc" = 0 ] && grep -q 'minted' "$core/check.out" && grep -q 'check call spaces' "$core/check.out" && grep -q 'Engineering' "$core/check.out"
ok "source check reaches the site with a minted credential" $? "exit $rc: $(tail -c 300 "$core/check.out")"
check "the check call's request" "$core/requests" testdata/check.requests.golden
# ---- calls -----------------------------------------------------------------
# A case file is sections, each opened by a line "=== name": args (the call
# and its parameters, one word to a line), answer (the stand-in's body) and
# status (its HTTP status, 200 if left out). @FILL@ in an answer becomes
# 300000 characters, for a body over the bound.
for c in testdata/calls/*.case; do
n=$(basename "$c" .case)
d="$core/case_$n"
mkdir "$d"
awk -v dir="$d" '/^=== /{ name = substr($0, 5); f = dir "/" name; printf "" > f; next } { print >> f }' "$c"
if [ -e "$d/answer" ]; then
awk 'BEGIN { s = "x"; while (length(s) < 300000) s = s s; fill = substr(s, 1, 300000) } { gsub(/@FILL@/, fill); print }' "$d/answer" >"$core/answer.json"
else
printf '{}\n' >"$core/answer.json"
fi
rm -f "$core/answer.json.status"
[ ! -e "$d/status" ] || cp "$d/status" "$core/answer.json.status"
: >"$core/requests"
set --
while IFS= read -r word; do set -- "$@" "$word"; done <"$d/args"
yrun call confluence "$@" --json >"$d/stdout" 2>"$d/stderr"
status=$?
{
echo "--- requests"
cat "$core/requests"
echo "--- status"
echo "$status"
echo "--- stdout"
if [ -s "$d/stdout" ]; then jq -S . "$d/stdout" 2>/dev/null || cat "$d/stdout"; fi
echo "--- stderr"
cat "$d/stderr"
} | sed 's/the answer is [0-9][0-9]* bytes/the answer is N bytes/' >"$d/golden"
# (yardr counts an over-bound answer's bytes after the keys are kept, so
# the golden holds the bound and not the count.)
check "call $n" "$d/golden" "testdata/calls/$n.golden"
done
rm -f "$core/answer.json.status"
# Reads only: of all the requests the cases made, the stand-in saw GETs.
methods=$(awk '/^--- requests/ { r = 1; next } /^--- status/ { r = 0 } r && /^[A-Z]/ { print $1 }' "$core"/case_*/golden | sort -u)
[ "$methods" = GET ]
ok "no request of any case is anything but a GET" $? "methods seen: $methods"
# The select is all that keeps an account's email, avatar and time zone from
# every agent granted read: the case's answer carries them and the call keeps
# three keys. A golden written again after a wider select would pass, so the
# keys are checked here and not left to the golden.
jq -e 'keys == ["accountId", "accountType", "displayName"]' "$core/case_user/stdout" >/dev/null
ok "user keeps an account's id, name and type, and no email, avatar or time zone" $?
grep -q '"email"' "$core/case_user/answer" && grep -q '"profilePicture"' "$core/case_user/answer" && grep -q '"timeZone"' "$core/case_user/answer"
ok "(and the case's answer does carry them, so the check above looked at something)" $?
# The same calls through the gateway a token with scopes is called at: the
# base carries a path of its own, and the paths of the calls follow it.
cloud=0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d
sed "s|^base: .*|base: $site/ex/confluence/$cloud/wiki|" "$here/confluence.yaml" >"$core/mine/confluence.local.yaml"
yrun source add "$core/mine/confluence.local.yaml" >"$core/add.gateway.out" 2>&1
ok "a copy with the gateway's address is added" $? "$(tail -c 300 "$core/add.gateway.out")"
printf '{"id":"98404","title":"Runbook","body":{"storage":{"representation":"storage","value":"<p>x</p>"}}}\n' >"$core/answer.json"
: >"$core/requests"
yrun source check confluence >"$core/check.gateway.out" 2>&1
yrun call confluence page id=98404 --json >"$core/page.gateway.out" 2>&1
check "the requests through the gateway's address" "$core/requests" testdata/gateway.requests.golden
# ---- host: a pack applied again over an edited address ---------------------
sed "s|^base: .*|base: $site/wiki|" "$here/confluence.yaml" >"$core/mine/confluence.local.yaml"
yrun source add "$core/mine/confluence.local.yaml" >/dev/null 2>&1
yrun source show confluence >"$core/show.before-grant" 2>&1
yrun source grant confluence read --crew yardmaster >"$core/grant.out" 2>&1
ok "a crew member is granted every read call by name" $? "$(tail -c 300 "$core/grant.out")"
yrun source show confluence >"$core/show.edited" 2>&1
yrun pack check "$here" >"$core/plan.out" 2>&1
yrun pack apply "$here" --yes >"$core/reapply.out" 2>&1
yrun source show confluence >"$core/show.reapplied" 2>&1
grep -qF "$site/wiki" "$core/show.edited" && ! grep -qF "https://example.atlassian.net/wiki" "$core/show.edited"
ok "the operator's host is what the source calls after the edit is added" $?
grep -qF "$site/wiki" "$core/plan.out"
ok "the plan of an update names the host it would take away (how the operator notices)" $?
grep -qF "https://example.atlassian.net/wiki" "$core/show.reapplied" && ! grep -qF "$site/wiki" "$core/show.reapplied"
ok "a pack applied again puts the shipped host back (the edit does not survive)" $?
grep -qF "$envfile" "$core/show.reapplied"
ok "the env file stays set across the re-apply" $?
! grep -q yardmaster "$core/show.before-grant" && grep -q yardmaster "$core/show.edited" && grep -q yardmaster "$core/show.reapplied"
ok "a grant of the source stands across the re-apply" $?
yrun source add "$core/mine/confluence.local.yaml" >/dev/null 2>&1
yrun source show confluence >"$core/show.readded" 2>&1
grep -qF "$site/wiki" "$core/show.readded" && grep -qF "$envfile" "$core/show.readded"
ok "adding the edited copy again restores the host, and the env file still stands" $?
# ---- secrets ---------------------------------------------------------------
# Every output of the run is a file under $core, and so is everything the
# yard keeps. The token lives in the env file alone, and the credential made
# of it in the server's memory alone.
kill "$standin_pid" 2>/dev/null
standin_pid=
leaks=$(grep -rlF -e "$token" -e "$auth" "$core" 2>/dev/null | grep -vxF "$envfile")
[ -z "$leaks" ]
ok "the token and the credential are in no file of the run but the env file" $? "found in: $leaks"
grep -qF "$token" "$envfile"
ok "(and the env file does hold the token, so the search above looked at something)" $?
if [ "$fail" = 0 ]; then echo "test: confluence: all ok"; else echo "test: confluence: FAILED"; fi
exit $fail
=== args attachments id=98404/../../spaces
--- requests
--- status
1
--- stdout
{
"error": "source confluence, call attachments: parameter id does not match its pattern ^[0-9]+$"
}
--- stderr
=== args
attachments
id=98404
limit=2
cursor=eyJpZCI6ImF0dDEzMTUwMiIsImF0dGFjaG1lbnRTb3J0T3JkZXIiOnsiZGlyZWN0aW9uIjoiQVNDRU5ESU5HIiwiZmllbGQiOiJJRCJ9LCJhdHRhY2htZW50T3JkZXJWYWx1ZSI6ImF0dDEzMTUwMiJ9
=== answer
{"results":[{"mediaTypeDescription":"CSV File","webuiLink":"/pages/viewpageattachments.action?pageId=98404&preview=%2F98404%2F131509%2Fpager-rota.csv","mediaType":"text/csv","downloadLink":"/rest/api/content/98404/child/attachment/att131509/download","createdAt":"2026-10-19T07:45:03.000Z","version":{"number":1,"message":"","minorEdit":false,"authorId":"5b6c2f1a9d4e3b0c7a8d1e22","createdAt":"2026-10-19T07:45:03.000Z"},"comment":"","pageId":"98404","fileId":"03cce3d3-f7d0-425e-99e3-6c9f27999f11","status":"current","title":"pager-rota.csv","fileSize":1204,"id":"att131509","_links":{"download":"/rest/api/content/98404/child/attachment/att131509/download","webui":"/pages/viewpageattachments.action?pageId=98404&preview=%2F98404%2F131509%2Fpager-rota.csv"}}],"_links":{"base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/pages/98404/attachments?cursor=eyJpZCI6ImF0dDEzMTUwMiIsImF0dGFjaG1lbnRTb3J0T3JkZXIiOnsiZGlyZWN0aW9uIjoiQVNDRU5ESU5HIiwiZmllbGQiOiJJRCJ9LCJhdHRhY2htZW50T3JkZXJWYWx1ZSI6ImF0dDEzMTUwMiJ9&limit=2
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki"
},
"results": [
{
"_links": {
"download": "/rest/api/content/98404/child/attachment/att131509/download",
"webui": "/pages/viewpageattachments.action?pageId=98404&preview=%2F98404%2F131509%2Fpager-rota.csv"
},
"comment": "",
"createdAt": "2026-10-19T07:45:03.000Z",
"downloadLink": "/rest/api/content/98404/child/attachment/att131509/download",
"fileId": "03cce3d3-f7d0-425e-99e3-6c9f27999f11",
"fileSize": 1204,
"id": "att131509",
"mediaType": "text/csv",
"mediaTypeDescription": "CSV File",
"pageId": "98404",
"status": "current",
"title": "pager-rota.csv",
"version": {
"authorId": "5b6c2f1a9d4e3b0c7a8d1e22",
"createdAt": "2026-10-19T07:45:03.000Z",
"message": "",
"minorEdit": false,
"number": 1
},
"webuiLink": "/pages/viewpageattachments.action?pageId=98404&preview=%2F98404%2F131509%2Fpager-rota.csv"
}
]
}
--- stderr
=== args
attachments
id=98404
=== answer
{"results":[{"id":"att131500","title":"@FILL@"}],"_links":{}}
--- requests
GET /wiki/api/v2/pages/98404/attachments?limit=25
auth: ok
accept: application/json
--- status
1
--- stdout
{
"error": "source confluence, call attachments: the answer is N bytes, over the call's bound of 262144 (answer.max in the declaration raises it, to 1048576 at most): it is refused, not cut, since half a JSON document misleads"
}
--- stderr
=== args
attachments
id=98404
limit=2
=== answer
{"results":[{"mediaTypeDescription":"PNG Image","webuiLink":"/pages/viewpageattachments.action?pageId=98404&preview=%2F98404%2F131500%2Frollback-flow.png","mediaType":"image/png","downloadLink":"/rest/api/content/98404/child/attachment/att131500/download","createdAt":"2026-10-14T16:20:11.000Z","version":{"number":2,"message":"","minorEdit":false,"authorId":"5b10ac8d82e05b22cc7d4ef5","createdAt":"2026-10-14T16:20:11.000Z"},"comment":"","pageId":"98404","fileId":"4f0f8c97-7a64-496e-ac90-72beb11e1311","status":"current","title":"rollback-flow.png","fileSize":11038,"id":"att131500","_links":{"download":"/rest/api/content/98404/child/attachment/att131500/download","webui":"/pages/viewpageattachments.action?pageId=98404&preview=%2F98404%2F131500%2Frollback-flow.png"}},{"mediaTypeDescription":"PDF Document","webuiLink":"/pages/viewpageattachments.action?pageId=98404&preview=%2F98404%2F131502%2Foncall-handbook.pdf","mediaType":"application/pdf","downloadLink":"/rest/api/content/98404/child/attachment/att131502/download","createdAt":"2026-10-14T16:31:50.000Z","version":{"number":1,"message":"as of the October review","minorEdit":false,"authorId":"5b10ac8d82e05b22cc7d4ef5","createdAt":"2026-10-14T16:31:50.000Z"},"comment":"as of the October review","pageId":"98404","fileId":"95f7084d-d3a0-44ac-919f-30ab830d6911","status":"current","title":"oncall-handbook.pdf","fileSize":2048000,"id":"att131502","_links":{"download":"/rest/api/content/98404/child/attachment/att131502/download","webui":"/pages/viewpageattachments.action?pageId=98404&preview=%2F98404%2F131502%2Foncall-handbook.pdf"}}],"_links":{"next":"/wiki/api/v2/pages/98404/attachments?limit=2&cursor=eyJpZCI6ImF0dDEzMTUwMiIsImF0dGFjaG1lbnRTb3J0T3JkZXIiOnsiZGlyZWN0aW9uIjoiQVNDRU5ESU5HIiwiZmllbGQiOiJJRCJ9LCJhdHRhY2htZW50T3JkZXJWYWx1ZSI6ImF0dDEzMTUwMiJ9","base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/pages/98404/attachments?limit=2
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki",
"next": "/wiki/api/v2/pages/98404/attachments?limit=2&cursor=eyJpZCI6ImF0dDEzMTUwMiIsImF0dGFjaG1lbnRTb3J0T3JkZXIiOnsiZGlyZWN0aW9uIjoiQVNDRU5ESU5HIiwiZmllbGQiOiJJRCJ9LCJhdHRhY2htZW50T3JkZXJWYWx1ZSI6ImF0dDEzMTUwMiJ9"
},
"results": [
{
"_links": {
"download": "/rest/api/content/98404/child/attachment/att131500/download",
"webui": "/pages/viewpageattachments.action?pageId=98404&preview=%2F98404%2F131500%2Frollback-flow.png"
},
"comment": "",
"createdAt": "2026-10-14T16:20:11.000Z",
"downloadLink": "/rest/api/content/98404/child/attachment/att131500/download",
"fileId": "4f0f8c97-7a64-496e-ac90-72beb11e1311",
"fileSize": 11038,
"id": "att131500",
"mediaType": "image/png",
"mediaTypeDescription": "PNG Image",
"pageId": "98404",
"status": "current",
"title": "rollback-flow.png",
"version": {
"authorId": "5b10ac8d82e05b22cc7d4ef5",
"createdAt": "2026-10-14T16:20:11.000Z",
"message": "",
"minorEdit": false,
"number": 2
},
"webuiLink": "/pages/viewpageattachments.action?pageId=98404&preview=%2F98404%2F131500%2Frollback-flow.png"
},
{
"_links": {
"download": "/rest/api/content/98404/child/attachment/att131502/download",
"webui": "/pages/viewpageattachments.action?pageId=98404&preview=%2F98404%2F131502%2Foncall-handbook.pdf"
},
"comment": "as of the October review",
"createdAt": "2026-10-14T16:31:50.000Z",
"downloadLink": "/rest/api/content/98404/child/attachment/att131502/download",
"fileId": "95f7084d-d3a0-44ac-919f-30ab830d6911",
"fileSize": 2048000,
"id": "att131502",
"mediaType": "application/pdf",
"mediaTypeDescription": "PDF Document",
"pageId": "98404",
"status": "current",
"title": "oncall-handbook.pdf",
"version": {
"authorId": "5b10ac8d82e05b22cc7d4ef5",
"createdAt": "2026-10-14T16:31:50.000Z",
"message": "as of the October review",
"minorEdit": false,
"number": 1
},
"webuiLink": "/pages/viewpageattachments.action?pageId=98404&preview=%2F98404%2F131502%2Foncall-handbook.pdf"
}
]
}
--- stderr
=== args children id=98400/x
--- requests
--- status
1
--- stdout
{
"error": "source confluence, call children: parameter id does not match its pattern ^[0-9]+$"
}
--- stderr
=== args children id=98411 kind=folder
--- requests
--- status
1
--- stdout
{
"error": "source confluence, call children: parameter kind is one of pages, folders, whiteboards, databases, embeds"
}
--- stderr
=== args
children
id=98413
kind=databases
limit=2
=== answer
{"results":[{"id":"98414","status":"current","title":"Postmortem template","childPosition":1000,"type":"page"},{"id":"98416","status":"current","title":"Closed incidents","childPosition":2000,"type":"folder"}],"_links":{"next":"/wiki/api/v2/databases/98413/direct-children?limit=2&cursor=eyJpZCI6Ijk4NDE2IiwiY29udGVudE9yZGVyIjoiY2hpbGQtcG9zaXRpb24iLCJjb250ZW50T3JkZXJWYWx1ZSI6MjAwMH0=","base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/databases/98413/direct-children?limit=2
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki",
"next": "/wiki/api/v2/databases/98413/direct-children?limit=2&cursor=eyJpZCI6Ijk4NDE2IiwiY29udGVudE9yZGVyIjoiY2hpbGQtcG9zaXRpb24iLCJjb250ZW50T3JkZXJWYWx1ZSI6MjAwMH0="
},
"results": [
{
"childPosition": 1000,
"id": "98414",
"status": "current",
"title": "Postmortem template",
"type": "page"
},
{
"childPosition": 2000,
"id": "98416",
"status": "current",
"title": "Closed incidents",
"type": "folder"
}
]
}
--- stderr
=== args
children
id=98421
kind=embeds
limit=2
=== answer
{"results":[{"id":"98422","status":"current","title":"Roadmap review notes","childPosition":1000,"type":"page"},{"id":"98423","status":"current","title":"Roadmap sketch","childPosition":2000,"type":"whiteboard"}],"_links":{"next":"/wiki/api/v2/embeds/98421/direct-children?limit=2&cursor=eyJpZCI6Ijk4NDIzIiwiY29udGVudE9yZGVyIjoiY2hpbGQtcG9zaXRpb24iLCJjb250ZW50T3JkZXJWYWx1ZSI6MjAwMH0=","base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/embeds/98421/direct-children?limit=2
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki",
"next": "/wiki/api/v2/embeds/98421/direct-children?limit=2&cursor=eyJpZCI6Ijk4NDIzIiwiY29udGVudE9yZGVyIjoiY2hpbGQtcG9zaXRpb24iLCJjb250ZW50T3JkZXJWYWx1ZSI6MjAwMH0="
},
"results": [
{
"childPosition": 1000,
"id": "98422",
"status": "current",
"title": "Roadmap review notes",
"type": "page"
},
{
"childPosition": 2000,
"id": "98423",
"status": "current",
"title": "Roadmap sketch",
"type": "whiteboard"
}
]
}
--- stderr
=== args
children
id=98411
kind=folders
limit=2
=== answer
{"results":[{"id":"98412","status":"current","title":"Q3 retrospective","childPosition":1000,"type":"page"},{"id":"98413","status":"current","title":"Incident log","childPosition":2000,"type":"database"}],"_links":{"next":"/wiki/api/v2/folders/98411/direct-children?limit=2&cursor=eyJpZCI6Ijk4NDEzIiwiY29udGVudE9yZGVyIjoiY2hpbGQtcG9zaXRpb24iLCJjb250ZW50T3JkZXJWYWx1ZSI6MjAwMH0=","base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/folders/98411/direct-children?limit=2
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki",
"next": "/wiki/api/v2/folders/98411/direct-children?limit=2&cursor=eyJpZCI6Ijk4NDEzIiwiY29udGVudE9yZGVyIjoiY2hpbGQtcG9zaXRpb24iLCJjb250ZW50T3JkZXJWYWx1ZSI6MjAwMH0="
},
"results": [
{
"childPosition": 1000,
"id": "98412",
"status": "current",
"title": "Q3 retrospective",
"type": "page"
},
{
"childPosition": 2000,
"id": "98413",
"status": "current",
"title": "Incident log",
"type": "database"
}
]
}
--- stderr
=== args
children
id=98400
cursor=eyJpZCI6Ijk4NDExIiwiY29udGVudE9yZGVyIjoiY2hpbGQtcG9zaXRpb24iLCJjb250ZW50T3JkZXJWYWx1ZSI6MjAwMH0=
=== answer
{"results":[{"id":"98417","status":"current","title":"Planning board","childPosition":3000,"type":"whiteboard"}],"_links":{"base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/pages/98400/direct-children?cursor=eyJpZCI6Ijk4NDExIiwiY29udGVudE9yZGVyIjoiY2hpbGQtcG9zaXRpb24iLCJjb250ZW50T3JkZXJWYWx1ZSI6MjAwMH0%3D&limit=25
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki"
},
"results": [
{
"childPosition": 3000,
"id": "98417",
"status": "current",
"title": "Planning board",
"type": "whiteboard"
}
]
}
--- stderr
=== args
children
id=98417
kind=whiteboards
limit=2
=== answer
{"results":[{"id":"98418","status":"current","title":"Sprint planning notes","childPosition":1000,"type":"page"},{"id":"98421","status":"current","title":"Roadmap","childPosition":2000,"type":"embed"}],"_links":{"next":"/wiki/api/v2/whiteboards/98417/direct-children?limit=2&cursor=eyJpZCI6Ijk4NDIxIiwiY29udGVudE9yZGVyIjoiY2hpbGQtcG9zaXRpb24iLCJjb250ZW50T3JkZXJWYWx1ZSI6MjAwMH0=","base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/whiteboards/98417/direct-children?limit=2
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki",
"next": "/wiki/api/v2/whiteboards/98417/direct-children?limit=2&cursor=eyJpZCI6Ijk4NDIxIiwiY29udGVudE9yZGVyIjoiY2hpbGQtcG9zaXRpb24iLCJjb250ZW50T3JkZXJWYWx1ZSI6MjAwMH0="
},
"results": [
{
"childPosition": 1000,
"id": "98418",
"status": "current",
"title": "Sprint planning notes",
"type": "page"
},
{
"childPosition": 2000,
"id": "98421",
"status": "current",
"title": "Roadmap",
"type": "embed"
}
]
}
--- stderr
=== args
children
id=98400
limit=2
=== answer
{"results":[{"id":"98404","status":"current","title":"Runbooks","childPosition":1000,"type":"page"},{"id":"98411","status":"current","title":"Archive","childPosition":2000,"type":"folder"}],"_links":{"next":"/wiki/api/v2/pages/98400/direct-children?limit=2&cursor=eyJpZCI6Ijk4NDExIiwiY29udGVudE9yZGVyIjoiY2hpbGQtcG9zaXRpb24iLCJjb250ZW50T3JkZXJWYWx1ZSI6MjAwMH0=","base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/pages/98400/direct-children?limit=2
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki",
"next": "/wiki/api/v2/pages/98400/direct-children?limit=2&cursor=eyJpZCI6Ijk4NDExIiwiY29udGVudE9yZGVyIjoiY2hpbGQtcG9zaXRpb24iLCJjb250ZW50T3JkZXJWYWx1ZSI6MjAwMH0="
},
"results": [
{
"childPosition": 1000,
"id": "98404",
"status": "current",
"title": "Runbooks",
"type": "page"
},
{
"childPosition": 2000,
"id": "98411",
"status": "current",
"title": "Archive",
"type": "folder"
}
]
}
--- stderr
=== args comments id=98404/../../spaces kind=footer
--- requests
--- status
1
--- stdout
{
"error": "source confluence, call comments: parameter id does not match its pattern ^[0-9]+$"
}
--- stderr
=== args comments id=98404 kind=footer-comments
--- requests
--- status
1
--- stdout
{
"error": "source confluence, call comments: parameter kind is one of footer, inline"
}
--- stderr
=== args
comments
id=98404
kind=footer
limit=2
cursor=eyJpZCI6IjEzMTIwNyIsImNvbW1lbnRTb3J0T3JkZXIiOnsiZGlyZWN0aW9uIjoiQVNDRU5ESU5HIiwiZmllbGQiOiJDUkVBVEVEX0RBVEUifSwiY29tbWVudE9yZGVyVmFsdWUiOjE3NjA2MjQ1ODk3NzF9
=== answer
{"results":[{"id":"131215","status":"current","title":"Re: Runbooks","pageId":"98404","version":{"number":1,"message":"","minorEdit":false,"authorId":"5b10ac8d82e05b22cc7d4ef5","createdAt":"2026-10-18T11:20:31.902Z","contributorIds":[]},"resolutionStatus":"resolved","body":{"storage":{"representation":"storage","value":"<p>Done, thanks.</p>"}},"_links":{"webui":"/spaces/ENG/pages/98404/Runbooks?focusedCommentId=131215"}}],"_links":{"base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/pages/98404/footer-comments?body-format=storage&cursor=eyJpZCI6IjEzMTIwNyIsImNvbW1lbnRTb3J0T3JkZXIiOnsiZGlyZWN0aW9uIjoiQVNDRU5ESU5HIiwiZmllbGQiOiJDUkVBVEVEX0RBVEUifSwiY29tbWVudE9yZGVyVmFsdWUiOjE3NjA2MjQ1ODk3NzF9&limit=2
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki"
},
"results": [
{
"_links": {
"webui": "/spaces/ENG/pages/98404/Runbooks?focusedCommentId=131215"
},
"body": {
"storage": {
"representation": "storage",
"value": "<p>Done, thanks.</p>"
}
},
"id": "131215",
"pageId": "98404",
"resolutionStatus": "resolved",
"status": "current",
"title": "Re: Runbooks",
"version": {
"authorId": "5b10ac8d82e05b22cc7d4ef5",
"contributorIds": [],
"createdAt": "2026-10-18T11:20:31.902Z",
"message": "",
"minorEdit": false,
"number": 1
}
}
]
}
--- stderr
=== args
comments
id=98404
kind=footer
limit=2
=== answer
{"results":[{"id":"131201","status":"current","title":"Re: Runbooks","pageId":"98404","version":{"number":1,"message":"","minorEdit":false,"authorId":"5b10ac8d82e05b22cc7d4ef5","createdAt":"2026-10-15T09:12:44.318Z","contributorIds":[]},"resolutionStatus":"open","body":{"storage":{"representation":"storage","value":"<p>Is the rollback step still right after the <ac:link><ri:page ri:content-title=\"On-call rota\" /></ac:link> change?</p>"}},"_links":{"webui":"/spaces/ENG/pages/98404/Runbooks?focusedCommentId=131201"}},{"id":"131207","status":"current","title":"Re: Runbooks","pageId":"98404","version":{"number":1,"message":"","minorEdit":false,"authorId":"5b6c2f1a9d4e3b0c7a8d1e22","createdAt":"2026-10-16T14:03:09.771Z","contributorIds":[]},"resolutionStatus":"open","body":{"storage":{"representation":"storage","value":"<p>Typo in the deploy list & the "Rollbacks" item, déploiements too.</p>"}},"_links":{"webui":"/spaces/ENG/pages/98404/Runbooks?focusedCommentId=131207"}}],"_links":{"next":"/wiki/api/v2/pages/98404/footer-comments?body-format=storage&limit=2&cursor=eyJpZCI6IjEzMTIwNyIsImNvbW1lbnRTb3J0T3JkZXIiOnsiZGlyZWN0aW9uIjoiQVNDRU5ESU5HIiwiZmllbGQiOiJDUkVBVEVEX0RBVEUifSwiY29tbWVudE9yZGVyVmFsdWUiOjE3NjA2MjQ1ODk3NzF9","base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/pages/98404/footer-comments?body-format=storage&limit=2
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki",
"next": "/wiki/api/v2/pages/98404/footer-comments?body-format=storage&limit=2&cursor=eyJpZCI6IjEzMTIwNyIsImNvbW1lbnRTb3J0T3JkZXIiOnsiZGlyZWN0aW9uIjoiQVNDRU5ESU5HIiwiZmllbGQiOiJDUkVBVEVEX0RBVEUifSwiY29tbWVudE9yZGVyVmFsdWUiOjE3NjA2MjQ1ODk3NzF9"
},
"results": [
{
"_links": {
"webui": "/spaces/ENG/pages/98404/Runbooks?focusedCommentId=131201"
},
"body": {
"storage": {
"representation": "storage",
"value": "<p>Is the rollback step still right after the <ac:link><ri:page ri:content-title=\"On-call rota\" /></ac:link> change?</p>"
}
},
"id": "131201",
"pageId": "98404",
"resolutionStatus": "open",
"status": "current",
"title": "Re: Runbooks",
"version": {
"authorId": "5b10ac8d82e05b22cc7d4ef5",
"contributorIds": [],
"createdAt": "2026-10-15T09:12:44.318Z",
"message": "",
"minorEdit": false,
"number": 1
}
},
{
"_links": {
"webui": "/spaces/ENG/pages/98404/Runbooks?focusedCommentId=131207"
},
"body": {
"storage": {
"representation": "storage",
"value": "<p>Typo in the deploy list & the "Rollbacks" item, déploiements too.</p>"
}
},
"id": "131207",
"pageId": "98404",
"resolutionStatus": "open",
"status": "current",
"title": "Re: Runbooks",
"version": {
"authorId": "5b6c2f1a9d4e3b0c7a8d1e22",
"contributorIds": [],
"createdAt": "2026-10-16T14:03:09.771Z",
"message": "",
"minorEdit": false,
"number": 1
}
}
]
}
--- stderr
=== args
comments
id=98404
kind=inline
=== answer
{"results":[{"id":"131310","status":"current","title":"Re: Runbooks","pageId":"98404","version":{"number":1,"message":"","minorEdit":false,"authorId":"5b6c2f1a9d4e3b0c7a8d1e22","createdAt":"2026-10-17T08:41:12.040Z","contributorIds":[]},"resolutionStatus":"open","properties":{"inlineMarkerRef":"4d8f2c1e-7a3b-4b6e-9c5d-0e1f2a3b4c5d","inlineOriginalSelection":"Keep this page short & current"},"body":{"storage":{"representation":"storage","value":"<p>Who keeps it current?</p>"}},"_links":{"webui":"/spaces/ENG/pages/98404/Runbooks?focusedCommentId=131310"}}],"_links":{"base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/pages/98404/inline-comments?body-format=storage&limit=25
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki"
},
"results": [
{
"_links": {
"webui": "/spaces/ENG/pages/98404/Runbooks?focusedCommentId=131310"
},
"body": {
"storage": {
"representation": "storage",
"value": "<p>Who keeps it current?</p>"
}
},
"id": "131310",
"pageId": "98404",
"properties": {
"inlineMarkerRef": "4d8f2c1e-7a3b-4b6e-9c5d-0e1f2a3b4c5d",
"inlineOriginalSelection": "Keep this page short & current"
},
"resolutionStatus": "open",
"status": "current",
"title": "Re: Runbooks",
"version": {
"authorId": "5b6c2f1a9d4e3b0c7a8d1e22",
"contributorIds": [],
"createdAt": "2026-10-17T08:41:12.040Z",
"message": "",
"minorEdit": false,
"number": 1
}
}
]
}
--- stderr
=== args comments id=98404
--- requests
--- status
1
--- stdout
{
"error": "source confluence, call comments: parameter kind is missing"
}
--- stderr
=== args
comments
id=98404
kind=footer
=== answer
{"results":[{"id":"131201","body":{"storage":{"representation":"storage","value":"@FILL@"}}}],"_links":{}}
--- requests
GET /wiki/api/v2/pages/98404/footer-comments?body-format=storage&limit=25
auth: ok
accept: application/json
--- status
1
--- stdout
{
"error": "source confluence, call comments: the answer is N bytes, over the call's bound of 262144 (answer.max in the declaration raises it, to 1048576 at most): it is refused, not cut, since half a JSON document misleads"
}
--- stderr
=== args labels id=98404/../../spaces
--- requests
--- status
1
--- stdout
{
"error": "source confluence, call labels: parameter id does not match its pattern ^[0-9]+$"
}
--- stderr
=== args
labels
id=98404
limit=1
cursor=eyJsYWJlbElkIjoxNTk1ODEzOSwibGFiZWxPcmRlciI6ImlkIiwibGFiZWxPcmRlclZhbHVlIjoxNTk1ODEzOX0=
=== answer
{"results":[{"prefix":"global","name":"on-call","id":"17301691"}],"_links":{"base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/pages/98404/labels?cursor=eyJsYWJlbElkIjoxNTk1ODEzOSwibGFiZWxPcmRlciI6ImlkIiwibGFiZWxPcmRlclZhbHVlIjoxNTk1ODEzOX0%3D&limit=1
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki"
},
"results": [
{
"id": "17301691",
"name": "on-call",
"prefix": "global"
}
]
}
--- stderr
=== args
labels
id=98404
=== answer
{"results":[{"prefix":"global","name":"runbook","id":"15958139"},{"prefix":"global","name":"on-call","id":"17301691"}],"_links":{"base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/pages/98404/labels?limit=25
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki"
},
"results": [
{
"id": "15958139",
"name": "runbook",
"prefix": "global"
},
{
"id": "17301691",
"name": "on-call",
"prefix": "global"
}
]
}
--- stderr
=== args page id=98404/../../spaces
--- requests
--- status
1
--- stdout
{
"error": "source confluence, call page: parameter id does not match its pattern ^[0-9]+$"
}
--- stderr
=== args page
--- requests
--- status
1
--- stdout
{
"error": "source confluence, call page: parameter id is missing"
}
--- stderr
=== args
page
id=98999
=== answer
{"errors":[{"status":404,"code":"NOT_FOUND","title":"Not Found","detail":null}]}
=== status
404
--- requests
GET /wiki/api/v2/pages/98999?body-format=storage
auth: ok
accept: application/json
--- status
1
--- stdout
{
"error": "source confluence, call page: the service answered 404"
}
--- stderr
=== args page id=98404 body-format=view
--- requests
--- status
1
--- stdout
{
"error": "source confluence, call page: \"body-format\" is no parameter of the call (it takes: id)"
}
--- stderr
=== args
page
id=98404
=== answer
{"id":"98404","title":"Runbooks","body":{"storage":{"representation":"storage","value":"@FILL@"}}}
--- requests
GET /wiki/api/v2/pages/98404?body-format=storage
auth: ok
accept: application/json
--- status
1
--- stdout
{
"error": "source confluence, call page: the answer is N bytes, over the call's bound of 262144 (answer.max in the declaration raises it, to 1048576 at most): it is refused, not cut, since half a JSON document misleads"
}
--- stderr
=== args
page
id=98404
=== answer
{"code":401,"message":"Unauthorized; scope does not match"}
=== status
401
--- requests
GET /wiki/api/v2/pages/98404?body-format=storage
auth: ok
accept: application/json
--- status
1
--- stdout
{
"error": "source confluence, call page: the service answered 401"
}
--- stderr
=== args
page
id=98404
=== answer
{"parentType":"page","authorId":"5b10ac8d82e05b22cc7d4ef5","parentId":"98400","ownerId":"5b10ac8d82e05b22cc7d4ef5","lastOwnerId":null,"createdAt":"2024-03-12T09:41:07.000Z","position":12345,"version":{"number":7,"message":"","minorEdit":false,"authorId":"5b10ac8d82e05b22cc7d4ef5","createdAt":"2026-10-14T16:22:41.512Z","ncsStepVersion":"14"},"id":"98404","spaceId":"98304","body":{"storage":{"representation":"storage","value":"<h1>Runbooks</h1><p>How we run <strong>production</strong>. Start at <ac:link><ri:page ri:content-title=\"On-call rota\" /></ac:link>, the déploiements come after.</p><ac:structured-macro ac:name=\"info\" ac:schema-version=\"1\" ac:macro-id=\"3f1c2a9e-5b7d-4e21-9c0a-1d2e3f4a5b6c\"><ac:rich-text-body><p>Keep this page short & current 👋</p></ac:rich-text-body></ac:structured-macro><ul><li>Deploys</li><li>Rollbacks</li></ul>"}},"status":"current","title":"Runbooks","_links":{"editui":"/pages/resumedraft.action?draftId=98404","webui":"/spaces/ENG/pages/98404/Runbooks","edituiv2":"/spaces/ENG/pages/edit-v2/98404","tinyui":"/x/BIAB","base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/pages/98404?body-format=storage
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki",
"editui": "/pages/resumedraft.action?draftId=98404",
"edituiv2": "/spaces/ENG/pages/edit-v2/98404",
"tinyui": "/x/BIAB",
"webui": "/spaces/ENG/pages/98404/Runbooks"
},
"body": {
"storage": {
"representation": "storage",
"value": "<h1>Runbooks</h1><p>How we run <strong>production</strong>. Start at <ac:link><ri:page ri:content-title=\"On-call rota\" /></ac:link>, the déploiements come after.</p><ac:structured-macro ac:name=\"info\" ac:schema-version=\"1\" ac:macro-id=\"3f1c2a9e-5b7d-4e21-9c0a-1d2e3f4a5b6c\"><ac:rich-text-body><p>Keep this page short & current 👋</p></ac:rich-text-body></ac:structured-macro><ul><li>Deploys</li><li>Rollbacks</li></ul>"
}
},
"id": "98404",
"parentId": "98400",
"parentType": "page",
"spaceId": "98304",
"status": "current",
"title": "Runbooks",
"version": {
"authorId": "5b10ac8d82e05b22cc7d4ef5",
"createdAt": "2026-10-14T16:22:41.512Z",
"message": "",
"minorEdit": false,
"ncsStepVersion": "14",
"number": 7
}
}
--- stderr
=== args replies id=131201/../../spaces kind=footer
--- requests
--- status
1
--- stdout
{
"error": "source confluence, call replies: parameter id does not match its pattern ^[0-9]+$"
}
--- stderr
=== args replies id=131201 kind=pages
--- requests
--- status
1
--- stdout
{
"error": "source confluence, call replies: parameter kind is one of footer, inline"
}
--- stderr
=== args
replies
id=131201
kind=footer
=== answer
{"results":[{"id":"131204","status":"current","title":"Re: Runbooks","pageId":"98404","version":{"number":1,"message":"","minorEdit":false,"authorId":"5b6c2f1a9d4e3b0c7a8d1e22","createdAt":"2026-10-15T10:30:02.114Z","contributorIds":[]},"parentCommentId":"131201","body":{"storage":{"representation":"storage","value":"<p>Yes. Nothing in the rollback changed, see <a href=\"https://example.atlassian.net/wiki/spaces/ENG/pages/98411\">the archive</a>.</p>"}},"_links":{"webui":"/spaces/ENG/pages/98404/Runbooks?focusedCommentId=131204"}}],"_links":{"base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/footer-comments/131201/children?body-format=storage&limit=25
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki"
},
"results": [
{
"_links": {
"webui": "/spaces/ENG/pages/98404/Runbooks?focusedCommentId=131204"
},
"body": {
"storage": {
"representation": "storage",
"value": "<p>Yes. Nothing in the rollback changed, see <a href=\"https://example.atlassian.net/wiki/spaces/ENG/pages/98411\">the archive</a>.</p>"
}
},
"id": "131204",
"pageId": "98404",
"parentCommentId": "131201",
"status": "current",
"title": "Re: Runbooks",
"version": {
"authorId": "5b6c2f1a9d4e3b0c7a8d1e22",
"contributorIds": [],
"createdAt": "2026-10-15T10:30:02.114Z",
"message": "",
"minorEdit": false,
"number": 1
}
}
]
}
--- stderr
=== args
replies
id=131310
kind=inline
limit=1
cursor=eyJpZCI6IjEzMTMxMSIsImNvbW1lbnRTb3J0T3JkZXIiOnsiZGlyZWN0aW9uIjoiQVNDRU5ESU5HIiwiZmllbGQiOiJDUkVBVEVEX0RBVEUifSwiY29tbWVudE9yZGVyVmFsdWUiOjE3NjA2OTE3NTA1MDB9
=== answer
{"results":[{"id":"131311","status":"current","title":"Re: Runbooks","pageId":"98404","version":{"number":1,"message":"","minorEdit":false,"authorId":"5b10ac8d82e05b22cc7d4ef5","createdAt":"2026-10-17T09:02:30.500Z","contributorIds":[]},"parentCommentId":"131310","resolutionStatus":"open","properties":{"inlineMarkerRef":"4d8f2c1e-7a3b-4b6e-9c5d-0e1f2a3b4c5d","inlineOriginalSelection":"Keep this page short & current"},"body":{"storage":{"representation":"storage","value":"<p>The on-call lead, each quarter.</p>"}},"_links":{"webui":"/spaces/ENG/pages/98404/Runbooks?focusedCommentId=131311"}}],"_links":{"base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/inline-comments/131310/children?body-format=storage&cursor=eyJpZCI6IjEzMTMxMSIsImNvbW1lbnRTb3J0T3JkZXIiOnsiZGlyZWN0aW9uIjoiQVNDRU5ESU5HIiwiZmllbGQiOiJDUkVBVEVEX0RBVEUifSwiY29tbWVudE9yZGVyVmFsdWUiOjE3NjA2OTE3NTA1MDB9&limit=1
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki"
},
"results": [
{
"_links": {
"webui": "/spaces/ENG/pages/98404/Runbooks?focusedCommentId=131311"
},
"body": {
"storage": {
"representation": "storage",
"value": "<p>The on-call lead, each quarter.</p>"
}
},
"id": "131311",
"pageId": "98404",
"parentCommentId": "131310",
"properties": {
"inlineMarkerRef": "4d8f2c1e-7a3b-4b6e-9c5d-0e1f2a3b4c5d",
"inlineOriginalSelection": "Keep this page short & current"
},
"resolutionStatus": "open",
"status": "current",
"title": "Re: Runbooks",
"version": {
"authorId": "5b10ac8d82e05b22cc7d4ef5",
"contributorIds": [],
"createdAt": "2026-10-17T09:02:30.500Z",
"message": "",
"minorEdit": false,
"number": 1
}
}
]
}
--- stderr
=== args
replies
id=131201
kind=footer
=== answer
{"results":[{"id":"131204","body":{"storage":{"representation":"storage","value":"@FILL@"}}}],"_links":{}}
--- requests
GET /wiki/api/v2/footer-comments/131201/children?body-format=storage&limit=25
auth: ok
accept: application/json
--- status
1
--- stdout
{
"error": "source confluence, call replies: the answer is N bytes, over the call's bound of 262144 (answer.max in the declaration raises it, to 1048576 at most): it is refused, not cut, since half a JSON document misleads"
}
--- stderr
=== args
search
cql=space =
=== answer
{"statusCode":400,"data":{"authorized":false,"valid":true,"errors":[],"successful":false},"message":"Could not parse cql : space =","reason":"Bad Request"}
=== status
400
--- requests
GET /wiki/rest/api/content/search?cql=space+%3D&limit=25
auth: ok
accept: application/json
--- status
1
--- stdout
{
"error": "source confluence, call search: the service answered 400"
}
--- stderr
=== args
search
cql=title ~ "nothing like this"
=== answer
{"results":[],"start":0,"limit":25,"size":0,"_links":{"base":"https://example.atlassian.net/wiki","context":"/wiki"}}
--- requests
GET /wiki/rest/api/content/search?cql=title+~+%22nothing+like+this%22&limit=25
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki",
"context": "/wiki"
},
"limit": 25,
"results": [],
"size": 0
}
--- stderr
=== args
search
cql=type = page AND space = "ENG" AND title ~ "runbook"
limit=2
cursor=_t_WyJcdDk4NDA0ICsyOnFYI1pBTyo3JVNkQ2I+eCBhcCJd_h_W10=
=== answer
{"results":[{"id":"98433","type":"page","status":"current","title":"Runbook: payments","childTypes":{},"macroRenderedOutput":{},"restrictions":{},"_expandable":{"container":"","metadata":"","extensions":"","operations":"","children":"","history":"/rest/api/content/98433/history","ancestors":"","body":"","version":"","descendants":"","space":"/rest/api/space/OPS"},"_links":{"webui":"/spaces/OPS/pages/98433/Runbook:+payments","self":"https://example.atlassian.net/wiki/rest/api/content/98433","tinyui":"/x/BIAB"}}],"start":0,"limit":2,"size":1,"_links":{"base":"https://example.atlassian.net/wiki","context":"/wiki","self":"https://example.atlassian.net/wiki/rest/api/content/search?cql=type+%3d+page"}}
--- requests
GET /wiki/rest/api/content/search?cql=type+%3D+page+AND+space+%3D+%22ENG%22+AND+title+~+%22runbook%22&cursor=_t_WyJcdDk4NDA0ICsyOnFYI1pBTyo3JVNkQ2I%2BeCBhcCJd_h_W10%3D&limit=2
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki",
"context": "/wiki",
"self": "https://example.atlassian.net/wiki/rest/api/content/search?cql=type+%3d+page"
},
"limit": 2,
"results": [
{
"_expandable": {
"ancestors": "",
"body": "",
"children": "",
"container": "",
"descendants": "",
"extensions": "",
"history": "/rest/api/content/98433/history",
"metadata": "",
"operations": "",
"space": "/rest/api/space/OPS",
"version": ""
},
"_links": {
"self": "https://example.atlassian.net/wiki/rest/api/content/98433",
"tinyui": "/x/BIAB",
"webui": "/spaces/OPS/pages/98433/Runbook:+payments"
},
"childTypes": {},
"id": "98433",
"macroRenderedOutput": {},
"restrictions": {},
"status": "current",
"title": "Runbook: payments",
"type": "page"
}
],
"size": 1
}
--- stderr
=== args search cql=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
--- requests
--- status
1
--- stdout
{
"error": "source confluence, call search: parameter cql is 2001 characters, and at most 2000"
}
--- stderr
=== args
search
cql=type = page AND space = "ENG" AND title ~ "runbook"
=== answer
{"results":[{"id":"98404","type":"page","status":"current","title":"Runbooks","childTypes":{},"macroRenderedOutput":{},"restrictions":{},"_expandable":{"container":"","metadata":"","extensions":"","operations":"","children":"","history":"/rest/api/content/98404/history","ancestors":"","body":"","version":"","descendants":"","space":"/rest/api/space/ENG"},"_links":{"webui":"/spaces/ENG/pages/98404/Runbooks","self":"https://example.atlassian.net/wiki/rest/api/content/98404","tinyui":"/x/BIAB"}},{"id":"98421","type":"page","status":"current","title":"Runbook template","childTypes":{},"macroRenderedOutput":{},"restrictions":{},"_expandable":{"container":"","metadata":"","extensions":"","operations":"","children":"","history":"/rest/api/content/98421/history","ancestors":"","body":"","version":"","descendants":"","space":"/rest/api/space/ENG"},"_links":{"webui":"/spaces/ENG/pages/98421/Runbook+template","self":"https://example.atlassian.net/wiki/rest/api/content/98421","tinyui":"/x/BIAB"}}],"start":0,"limit":25,"size":2,"_links":{"base":"https://example.atlassian.net/wiki","context":"/wiki","self":"https://example.atlassian.net/wiki/rest/api/content/search?cql=type+%3d+page"}}
--- requests
GET /wiki/rest/api/content/search?cql=type+%3D+page+AND+space+%3D+%22ENG%22+AND+title+~+%22runbook%22&limit=25
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki",
"context": "/wiki",
"self": "https://example.atlassian.net/wiki/rest/api/content/search?cql=type+%3d+page"
},
"limit": 25,
"results": [
{
"_expandable": {
"ancestors": "",
"body": "",
"children": "",
"container": "",
"descendants": "",
"extensions": "",
"history": "/rest/api/content/98404/history",
"metadata": "",
"operations": "",
"space": "/rest/api/space/ENG",
"version": ""
},
"_links": {
"self": "https://example.atlassian.net/wiki/rest/api/content/98404",
"tinyui": "/x/BIAB",
"webui": "/spaces/ENG/pages/98404/Runbooks"
},
"childTypes": {},
"id": "98404",
"macroRenderedOutput": {},
"restrictions": {},
"status": "current",
"title": "Runbooks",
"type": "page"
},
{
"_expandable": {
"ancestors": "",
"body": "",
"children": "",
"container": "",
"descendants": "",
"extensions": "",
"history": "/rest/api/content/98421/history",
"metadata": "",
"operations": "",
"space": "/rest/api/space/ENG",
"version": ""
},
"_links": {
"self": "https://example.atlassian.net/wiki/rest/api/content/98421",
"tinyui": "/x/BIAB",
"webui": "/spaces/ENG/pages/98421/Runbook+template"
},
"childTypes": {},
"id": "98421",
"macroRenderedOutput": {},
"restrictions": {},
"status": "current",
"title": "Runbook template",
"type": "page"
}
],
"size": 2
}
--- stderr
=== args spaces limit=many
--- requests
--- status
1
--- stdout
{
"error": "source confluence, call spaces: parameter limit is no whole number"
}
--- stderr
=== args
spaces
limit=2
=== answer
{"results":[{"id":"98304","key":"ENG","name":"Engineering","type":"global","status":"current","authorId":"5b10ac8d82e05b22cc7d4ef5","createdAt":"2024-03-12T09:30:00.000Z","homepageId":"98400","description":null,"icon":null,"spaceOwnerId":"5b10ac8d82e05b22cc7d4ef5","currentActiveAlias":"ENG","_links":{"webui":"/spaces/ENG"}},{"id":"131072","key":"OPS","name":"Operations","type":"global","status":"current","authorId":"5b10ac8d82e05b22cc7d4ef5","createdAt":"2024-03-12T09:30:00.000Z","homepageId":"131100","description":null,"icon":null,"spaceOwnerId":"5b10ac8d82e05b22cc7d4ef5","currentActiveAlias":"OPS","_links":{"webui":"/spaces/OPS"}}],"_links":{"next":"/wiki/api/v2/spaces?limit=2&cursor=eyJpZCI6MTMxMDcyLCJzcGFjZVNvcnRPcmRlciI6eyJmaWVsZCI6IklEIiwiZGlyZWN0aW9uIjoiQVNDRU5ESU5HIn19","base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/spaces?limit=2
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki",
"next": "/wiki/api/v2/spaces?limit=2&cursor=eyJpZCI6MTMxMDcyLCJzcGFjZVNvcnRPcmRlciI6eyJmaWVsZCI6IklEIiwiZGlyZWN0aW9uIjoiQVNDRU5ESU5HIn19"
},
"results": [
{
"_links": {
"webui": "/spaces/ENG"
},
"authorId": "5b10ac8d82e05b22cc7d4ef5",
"createdAt": "2024-03-12T09:30:00.000Z",
"currentActiveAlias": "ENG",
"description": null,
"homepageId": "98400",
"icon": null,
"id": "98304",
"key": "ENG",
"name": "Engineering",
"spaceOwnerId": "5b10ac8d82e05b22cc7d4ef5",
"status": "current",
"type": "global"
},
{
"_links": {
"webui": "/spaces/OPS"
},
"authorId": "5b10ac8d82e05b22cc7d4ef5",
"createdAt": "2024-03-12T09:30:00.000Z",
"currentActiveAlias": "OPS",
"description": null,
"homepageId": "131100",
"icon": null,
"id": "131072",
"key": "OPS",
"name": "Operations",
"spaceOwnerId": "5b10ac8d82e05b22cc7d4ef5",
"status": "current",
"type": "global"
}
]
}
--- stderr
=== args
spaces
keys=ENG,OPS
=== answer
{"results":[{"id":"98304","key":"ENG","name":"Engineering","type":"global","status":"current","authorId":"5b10ac8d82e05b22cc7d4ef5","createdAt":"2024-03-12T09:30:00.000Z","homepageId":"98400","description":null,"icon":null,"spaceOwnerId":"5b10ac8d82e05b22cc7d4ef5","currentActiveAlias":"ENG","_links":{"webui":"/spaces/ENG"}},{"id":"131072","key":"OPS","name":"Operations","type":"global","status":"current","authorId":"5b10ac8d82e05b22cc7d4ef5","createdAt":"2024-03-12T09:30:00.000Z","homepageId":"131100","description":null,"icon":null,"spaceOwnerId":"5b10ac8d82e05b22cc7d4ef5","currentActiveAlias":"OPS","_links":{"webui":"/spaces/OPS"}}],"_links":{"base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/spaces?keys=ENG%2COPS&limit=25
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki"
},
"results": [
{
"_links": {
"webui": "/spaces/ENG"
},
"authorId": "5b10ac8d82e05b22cc7d4ef5",
"createdAt": "2024-03-12T09:30:00.000Z",
"currentActiveAlias": "ENG",
"description": null,
"homepageId": "98400",
"icon": null,
"id": "98304",
"key": "ENG",
"name": "Engineering",
"spaceOwnerId": "5b10ac8d82e05b22cc7d4ef5",
"status": "current",
"type": "global"
},
{
"_links": {
"webui": "/spaces/OPS"
},
"authorId": "5b10ac8d82e05b22cc7d4ef5",
"createdAt": "2024-03-12T09:30:00.000Z",
"currentActiveAlias": "OPS",
"description": null,
"homepageId": "131100",
"icon": null,
"id": "131072",
"key": "OPS",
"name": "Operations",
"spaceOwnerId": "5b10ac8d82e05b22cc7d4ef5",
"status": "current",
"type": "global"
}
]
}
--- stderr
=== args
spaces
limit=2
cursor=eyJpZCI6MTMxMDcyLCJzcGFjZVNvcnRPcmRlciI6eyJmaWVsZCI6IklEIiwiZGlyZWN0aW9uIjoiQVNDRU5ESU5HIn19
=== answer
{"results":[{"id":"196608","key":"~5b10ac8d82e05b22cc7d4ef5","name":"Ada Lovelace","type":"personal","status":"current","authorId":"5b10ac8d82e05b22cc7d4ef5","createdAt":"2024-03-12T09:30:00.000Z","homepageId":null,"description":null,"icon":null,"spaceOwnerId":"5b10ac8d82e05b22cc7d4ef5","currentActiveAlias":"~5b10ac8d82e05b22cc7d4ef5","_links":{"webui":"/spaces/~5b10ac8d82e05b22cc7d4ef5"}}],"_links":{"base":"https://example.atlassian.net/wiki"}}
--- requests
GET /wiki/api/v2/spaces?cursor=eyJpZCI6MTMxMDcyLCJzcGFjZVNvcnRPcmRlciI6eyJmaWVsZCI6IklEIiwiZGlyZWN0aW9uIjoiQVNDRU5ESU5HIn19&limit=2
auth: ok
accept: application/json
--- status
0
--- stdout
{
"_links": {
"base": "https://example.atlassian.net/wiki"
},
"results": [
{
"_links": {
"webui": "/spaces/~5b10ac8d82e05b22cc7d4ef5"
},
"authorId": "5b10ac8d82e05b22cc7d4ef5",
"createdAt": "2024-03-12T09:30:00.000Z",
"currentActiveAlias": "~5b10ac8d82e05b22cc7d4ef5",
"description": null,
"homepageId": null,
"icon": null,
"id": "196608",
"key": "~5b10ac8d82e05b22cc7d4ef5",
"name": "Ada Lovelace",
"spaceOwnerId": "5b10ac8d82e05b22cc7d4ef5",
"status": "current",
"type": "personal"
}
]
}
--- stderr
=== args user accountId=5b10ac8d82e05b22cc7d4ef5&expand=operations
--- requests
--- status
1
--- stdout
{
"error": "source confluence, call user: parameter accountId does not match its pattern ^[A-Za-z0-9:._-]+$"
}
--- stderr
=== args
user
accountId=712020:7c9e6679-7425-40de-944b-e07fc1f90ae7
=== answer
{"statusCode":403,"data":{"authorized":false,"valid":true,"errors":[],"successful":false},"message":"com.atlassian.confluence.api.service.exceptions.api.PermissionException: User not permitted to view user profiles"}
=== status
403
--- requests
GET /wiki/rest/api/user?accountId=712020%3A7c9e6679-7425-40de-944b-e07fc1f90ae7
auth: ok
accept: application/json
--- status
1
--- stdout
{
"error": "source confluence, call user: the service answered 403"
}
--- stderr
=== args
user
accountId=712020:7c9e6679-7425-40de-944b-e07fc1f90ae7
=== answer
{"statusCode":404,"data":{"authorized":false,"valid":true,"errors":[],"successful":false},"message":"Not Found","reason":"Not Found"}
=== status
404
--- requests
GET /wiki/rest/api/user?accountId=712020%3A7c9e6679-7425-40de-944b-e07fc1f90ae7
auth: ok
accept: application/json
--- status
1
--- stdout
{
"error": "source confluence, call user: the service answered 404"
}
--- stderr
=== args
user
accountId=5b10ac8d82e05b22cc7d4ef5
=== answer
{"type":"known","accountId":"5b10ac8d82e05b22cc7d4ef5","accountType":"atlassian","email":"ada@example.com","publicName":"Ada","profilePicture":{"path":"/wiki/aa-avatar/5b10ac8d82e05b22cc7d4ef5","width":48,"height":48,"isDefault":false},"displayName":"Ada Lovelace","timeZone":"Europe/London","externalCollaborator":false,"isExternalCollaborator":false,"isGuest":false,"_expandable":{"operations":"","details":"","personalSpace":""},"_links":{"self":"https://example.atlassian.net/wiki/rest/api/user?accountId=5b10ac8d82e05b22cc7d4ef5","base":"https://example.atlassian.net/wiki","context":"/wiki"}}
--- requests
GET /wiki/rest/api/user?accountId=5b10ac8d82e05b22cc7d4ef5
auth: ok
accept: application/json
--- status
0
--- stdout
{
"accountId": "5b10ac8d82e05b22cc7d4ef5",
"accountType": "atlassian",
"displayName": "Ada Lovelace"
}
--- stderr
GET /wiki/api/v2/spaces?limit=25 auth: ok accept: application/json
{
"about": "Confluence Cloud pages read through its REST API; spaces listed, content found by CQL, one page read, the children of a page, folder, whiteboard, database or embed listed, a page's comments, replies, labels and attached files listed, and an author named from an account id.",
"base": "https://example.atlassian.net/wiki",
"calls": [
{
"about": "List the spaces the account may see, 25 at a time. Answers each space's id, key, name, type and homepageId, the page children starts from. keys narrows it to spaces by key, as an address shows it (/spaces/ENG/...). When more than limit follow, _links.next is in the answer, and its cursor= value is the next call's cursor.",
"answer": {
"max": 262144,
"select": [
"results",
"_links"
]
},
"kind": "read",
"name": "spaces",
"params": [
{
"log": true,
"max": 500,
"name": "keys",
"optional": true,
"type": "string"
},
{
"default": 25,
"name": "limit",
"optional": true,
"type": "int"
},
{
"max": 1000,
"name": "cursor",
"optional": true,
"type": "string"
}
],
"request": {
"method": "GET",
"path": "/api/v2/spaces",
"query": [
{
"key": "keys",
"value": "{keys}"
},
{
"key": "limit",
"value": "{limit}"
},
{
"key": "cursor",
"value": "{cursor}"
}
]
}
},
{
"about": "Read one page by its id, which is in its address (/spaces/ENG/pages/123456/Title). Answers its title, status, spaceId, parentId, version and links, and its body in Confluence storage format, in body.storage.value, which is XHTML with Confluence's own tags and macros, markup and not text.",
"answer": {
"max": 262144,
"select": [
"id",
"status",
"title",
"spaceId",
"parentId",
"parentType",
"version",
"body",
"_links"
]
},
"kind": "read",
"name": "page",
"params": [
{
"log": true,
"max": 20,
"name": "id",
"pattern": "^[0-9]+$",
"type": "string"
}
],
"request": {
"method": "GET",
"path": "/api/v2/pages/{id}",
"query": [
{
"key": "body-format",
"value": "storage"
}
]
}
},
{
"about": "List the direct children of a page, folder, whiteboard, database or embed, 25 at a time, in the order of the tree. Answers each child's id, title, status and type (page, folder, whiteboard, database or embed). To go down a level, give the child's id, and give its type with an s as kind. A child whose type is folder takes kind=folders. kind is one of pages (the default), folders, whiteboards, databases and embeds. When more than limit follow, _links.next is in the answer, and its cursor= value is the next call's cursor, with the same id and kind.",
"answer": {
"max": 65536,
"select": [
"results",
"_links"
]
},
"kind": "read",
"name": "children",
"params": [
{
"log": true,
"max": 20,
"name": "id",
"pattern": "^[0-9]+$",
"type": "string"
},
{
"default": "pages",
"log": true,
"name": "kind",
"optional": true,
"type": "enum",
"values": [
"pages",
"folders",
"whiteboards",
"databases",
"embeds"
]
},
{
"default": 25,
"name": "limit",
"optional": true,
"type": "int"
},
{
"max": 1000,
"name": "cursor",
"optional": true,
"type": "string"
}
],
"request": {
"method": "GET",
"path": "/api/v2/{kind}/{id}/direct-children",
"query": [
{
"key": "limit",
"value": "{limit}"
},
{
"key": "cursor",
"value": "{cursor}"
}
]
}
},
{
"about": "Find content by CQL, Confluence's query language, 25 at a time, as in type = page AND space = \"ENG\" AND title ~ \"runbook\". Answers each hit's id, type, status, title and _links.webui, which names its space. When more than limit follow, _links.next is in the answer, and its cursor= value, percent-decoded, is the next call's cursor.",
"answer": {
"max": 262144,
"select": [
"results",
"size",
"limit",
"_links"
]
},
"kind": "read",
"name": "search",
"params": [
{
"max": 2000,
"name": "cql",
"type": "string"
},
{
"default": 25,
"name": "limit",
"optional": true,
"type": "int"
},
{
"max": 1000,
"name": "cursor",
"optional": true,
"type": "string"
}
],
"request": {
"method": "GET",
"path": "/rest/api/content/search",
"query": [
{
"key": "cql",
"value": "{cql}"
},
{
"key": "limit",
"value": "{limit}"
},
{
"key": "cursor",
"value": "{cursor}"
}
]
}
},
{
"about": "List the comments on a page, given by its id, 25 at a time, of one kind. kind=footer is the discussion under the page and kind=inline is the comments on a passage of its text, so a page is read with both. Only the first comment of each thread is listed here; its replies are the call replies, by the comment's id. Answers each comment's id, status, pageId, version (number, authorId, createdAt) and its body, in Confluence storage format, in body.storage.value, markup and not text. A comment may have resolutionStatus (open, reopened, resolved or dangling), and an inline comment has properties.inlineOriginalSelection, the passage it is on. When more than limit follow, _links.next is in the answer, and its cursor= value is the next call's cursor.",
"answer": {
"max": 262144,
"select": [
"results",
"_links"
]
},
"kind": "read",
"name": "comments",
"params": [
{
"log": true,
"max": 20,
"name": "id",
"pattern": "^[0-9]+$",
"type": "string"
},
{
"log": true,
"name": "kind",
"type": "enum",
"values": [
"footer",
"inline"
]
},
{
"default": 25,
"name": "limit",
"optional": true,
"type": "int"
},
{
"max": 1000,
"name": "cursor",
"optional": true,
"type": "string"
}
],
"request": {
"method": "GET",
"path": "/api/v2/pages/{id}/{kind}-comments",
"query": [
{
"key": "body-format",
"value": "storage"
},
{
"key": "limit",
"value": "{limit}"
},
{
"key": "cursor",
"value": "{cursor}"
}
]
}
},
{
"about": "List the replies to one comment, 25 at a time. Give the comment's id and its kind, footer or inline, as comments lists it. A kind that is not the comment's is no error and answers an empty list. Answers each reply as a comment is answered, with parentCommentId, the id of the comment it answers. A reply may have replies of its own, and its id with the same kind goes down a level. When more than limit follow, _links.next is in the answer, and its cursor= value is the next call's cursor.",
"answer": {
"max": 262144,
"select": [
"results",
"_links"
]
},
"kind": "read",
"name": "replies",
"params": [
{
"log": true,
"max": 20,
"name": "id",
"pattern": "^[0-9]+$",
"type": "string"
},
{
"log": true,
"name": "kind",
"type": "enum",
"values": [
"footer",
"inline"
]
},
{
"default": 25,
"name": "limit",
"optional": true,
"type": "int"
},
{
"max": 1000,
"name": "cursor",
"optional": true,
"type": "string"
}
],
"request": {
"method": "GET",
"path": "/api/v2/{kind}-comments/{id}/children",
"query": [
{
"key": "body-format",
"value": "storage"
},
{
"key": "limit",
"value": "{limit}"
},
{
"key": "cursor",
"value": "{cursor}"
}
]
}
},
{
"about": "List the labels on a page, given by its id, 25 at a time. Answers each label's id, name and prefix. When more than limit follow, _links.next is in the answer, and its cursor= value is the next call's cursor.",
"answer": {
"max": 65536,
"select": [
"results",
"_links"
]
},
"kind": "read",
"name": "labels",
"params": [
{
"log": true,
"max": 20,
"name": "id",
"pattern": "^[0-9]+$",
"type": "string"
},
{
"default": 25,
"name": "limit",
"optional": true,
"type": "int"
},
{
"max": 1000,
"name": "cursor",
"optional": true,
"type": "string"
}
],
"request": {
"method": "GET",
"path": "/api/v2/pages/{id}/labels",
"query": [
{
"key": "limit",
"value": "{limit}"
},
{
"key": "cursor",
"value": "{cursor}"
}
]
}
},
{
"about": "List the files attached to a page, given by its id, 25 at a time. Answers the metadata of each file and not the file. That is its id, title (the file's name), mediaType, fileSize in bytes, status, createdAt, version, comment, and downloadLink and webuiLink, which are paths on the site that go after _links.base. Nothing here fetches a file. When more than limit follow, _links.next is in the answer, and its cursor= value is the next call's cursor.",
"answer": {
"max": 262144,
"select": [
"results",
"_links"
]
},
"kind": "read",
"name": "attachments",
"params": [
{
"log": true,
"max": 20,
"name": "id",
"pattern": "^[0-9]+$",
"type": "string"
},
{
"default": 25,
"name": "limit",
"optional": true,
"type": "int"
},
{
"max": 1000,
"name": "cursor",
"optional": true,
"type": "string"
}
],
"request": {
"method": "GET",
"path": "/api/v2/pages/{id}/attachments",
"query": [
{
"key": "limit",
"value": "{limit}"
},
{
"key": "cursor",
"value": "{cursor}"
}
]
}
},
{
"about": "Name the author of a comment or a file, given an Atlassian account id, which is version.authorId in the answers of comments, replies and attachments. Answers that account's accountId, displayName and accountType (atlassian for an Atlassian account, app for the bot user of an app, or empty) and nothing else of it. Names one account a call, so several authors are several calls. A 404 is an id that is not there, and a 403 is an account that may not view users. The name may be limited by the user's profile visibility.",
"answer": {
"max": 65536,
"select": [
"accountId",
"displayName",
"accountType"
]
},
"kind": "read",
"name": "user",
"params": [
{
"log": true,
"max": 128,
"name": "accountId",
"pattern": "^[A-Za-z0-9:._-]+$",
"type": "string"
}
],
"request": {
"method": "GET",
"path": "/rest/api/user",
"query": [
{
"key": "accountId",
"value": "{accountId}"
}
]
}
}
],
"check": "spaces",
"credential": {
"env": "CONFLUENCE_AUTH",
"header": "Authorization: Basic {credential}",
"mint": [
"CONFLUENCE_EMAIL",
"CONFLUENCE_API_TOKEN"
],
"mint_command": [
"confluence/mint"
],
"not_secret": [
"CONFLUENCE_EMAIL"
]
},
"name": "confluence"
}
GET /ex/confluence/0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d/wiki/api/v2/spaces?limit=25 auth: ok accept: application/json GET /ex/confluence/0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d/wiki/api/v2/pages/98404?body-format=storage auth: ok accept: application/json
{"results":[{"id":"98304","key":"ENG","name":"Engineering","type":"global","status":"current","authorId":"5b10ac8d82e05b22cc7d4ef5","createdAt":"2024-03-12T09:30:00.000Z","homepageId":"98400","description":null,"icon":null,"spaceOwnerId":"5b10ac8d82e05b22cc7d4ef5","currentActiveAlias":"ENG","_links":{"webui":"/spaces/ENG"}},{"id":"131072","key":"OPS","name":"Operations","type":"global","status":"current","authorId":"5b10ac8d82e05b22cc7d4ef5","createdAt":"2024-03-12T09:30:00.000Z","homepageId":"131100","description":null,"icon":null,"spaceOwnerId":"5b10ac8d82e05b22cc7d4ef5","currentActiveAlias":"OPS","_links":{"webui":"/spaces/OPS"}}],"_links":{"base":"https://example.atlassian.net/wiki"}}
# A stand-in for Confluence Cloud's REST API, for the calls the test makes
# through the real yardr. It answers every request with the body and status
# the test has left in its answer files, read again for each request, so
# one process serves every case in turn. It records each request in the log
# it is given: the method, the path and query exactly as they arrived,
# whether Authorization was the Basic value expected, and Accept. Any
# method is recorded, so that a request that is not a GET shows in a
# golden. It writes the port it bound, on 127.0.0.1, to the file it is
# given once it listens. The Basic value it expects is the first line of its
# standard input and not an argument, which anyone on the machine could read.
#
# python3 -I standin.py <port file> <request log> <answer file> < <expected Basic value>
#
# The status is the first line of <answer file>.status, 200 if there is no
# such file.
import http.server
import os
import sys
port_file, log_file, answer_file = sys.argv[1:4]
expected = sys.stdin.readline().strip()
class Handler(http.server.BaseHTTPRequestHandler):
def handle_any(self):
auth = "ok" if self.headers.get("Authorization") == "Basic " + expected else "WRONG"
with open(log_file, "a") as f:
f.write("%s %s\n auth: %s\n accept: %s\n" % (self.command, self.path, auth, self.headers.get("Accept")))
status = 200
if os.path.exists(answer_file + ".status"):
with open(answer_file + ".status") as f:
status = int(f.readline())
with open(answer_file, "rb") as f:
body = f.read()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = handle_any
def log_message(self, *args):
pass
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
with open(port_file, "w") as f:
f.write(str(server.server_address[1]))
server.serve_forever()