feat(atlassian-tools): anchor-preserving Confluence page edits - #215
feat(atlassian-tools): anchor-preserving Confluence page edits#215coroiu wants to merge 3 commits into
Conversation
Adds an ADF-native Confluence write path so pages can be edited without dropping the annotation marks that anchor open inline comments — the failure mode of the markdown-based updateConfluencePage round-trip. New, gated exactly like the Jira write tools (always listed, dry-run by default, live write refuses without the token): - editing-confluence-pages skill orchestrating the anchor-preserving workflow - replace_confluence_text (write): literal find/replace preserving marks - update_confluence_page (write): full ADF body push, dry run diffs anchors - get_confluence_page_adf, list_confluence_anchors (read-only helpers) - optional ATLASSIAN_CONFLUENCE_WRITE_TOKEN, opt-in per install Bumps the plugin to 2.7.0 with README, CHANGELOG, and marketplace updates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🤖 Bitwarden Claude Code ReviewOverall Assessment: APPROVE Reviewed the new ADF-native Confluence write path: the Code Review Details
|
Claude Code validationResult: Pass Validated the Claude material in PR #215 ( CriticalNone. Major
Minor
Notes
Checks run
|
| const updated = await writer.updatePage({ | ||
| pageId: page.id, | ||
| title: page.title, | ||
| currentVersion: page.version, | ||
| adfBody: validated.adfBody, | ||
| message: validated.message ?? "update via update_confluence_page", | ||
| }); |
There was a problem hiding this comment.
Details and fix
ConfluenceClient.updatePage's doc comment says the API "rejects with a conflict if the page changed underneath us." That holds only when the version travels with the body the caller edited. Here page.version comes from the refetch at line 41, a few milliseconds before the PUT, so currentVersion + 1 is always the correct next version and Confluence never returns a 409.
The workflow this tool is built for spans two tool calls with model editing in between: get_confluence_page_adf reports version N, the model rewrites the body, then update_confluence_page reads version M (possibly > N) and writes M+1 using a body derived from N. Anything a human changed in that window is overwritten with no warning. The anchor diff only surfaces it when the other edit happened to add or remove an inline comment.
One option — accept the version the body was fetched at and refuse on mismatch:
if (
validated.expectedVersion !== undefined &&
validated.expectedVersion !== page.version
) {
return (
`Refusing to overwrite: the page is now at version ${page.version}, but the ` +
`submitted body was fetched at version ${validated.expectedVersion}. ` +
"Re-fetch with get_confluence_page_adf and re-apply the edit."
);
}get_confluence_page_adf already prints the version, so the value is on hand.
replace_confluence_text is not affected — it reads, edits, and writes inside a single handler call.
There was a problem hiding this comment.
Good catch — fixed in 8f6d8a5.
update_confluence_page now takes an optional expectedVersion (the version the body was fetched at). When it's set and the live page has moved past it, the tool refuses the write with a re-fetch prompt instead of overwriting; the dry run surfaces the same mismatch. get_confluence_page_adf now prints the version to pass, and the editing-confluence-pages skill threads it through steps 4–5.
I kept it optional rather than required so the plain "just push a body" path and the dry-run preview still work without a token, but the fetch tool and skill both steer callers to pass it. replace_confluence_text is left as-is, since it reads/edits/writes in a single handler call. Added three tests covering match / stale-refusal / dry-run-warning.
The full-body overwrite spans two tool calls (get_confluence_page_adf → model edits → update_confluence_page). The handler refetched the version immediately before the PUT, so currentVersion+1 was always accepted and a concurrent edit in the gap was silently clobbered — the documented version guard never engaged. Add an optional expectedVersion parameter (the version the body was fetched at). When set and the live page has moved, the write is refused with a re-fetch prompt; the dry run surfaces the same mismatch. get_confluence_page_adf now emits the version to pass, and the skill threads it through. replace_confluence_text is unaffected (single-call read-edit-write). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Applies the automated skill review's findings, all in the new skill: - description now stands alone for dispatch: folds in trigger phrasings and the "do not use" exclusions, and drops the unverified "layouts" claim - add search_confluence / search_confluence_cql to allowed-tools and a page-id resolution step, so the skill can act on a URL or title (all edit tools require a numeric pageId); drop unused Read grant - unconditional approval gate before any dryRun:false write, as a numbered step in both workflows, matching filing-jira-tickets - fix the "big rewrite" off-ramp that routed comment-bearing pages down the anchor-destroying markdown path; describe the external tool by capability - known-gotcha now states what these tools add over external contentFormat:adf - add examples/anchor_preserving_edit.md (before/after + the deletion mistake) and link it from the edit step; consistent external-tool naming Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks — addressed all of the skill review's findings in 0e80626. Summary: Major
Minor
Re-ran locally: plugin-structure validator passes (the added tools are registered), |
|
@claude re-review |
|
Claude finished @coroiu's task in 3m 26s —— View job Re-reviewRe-read the full diff against Previous findings — verified fixed
Remaining
Nit (non-blocking)
Not verifiedI couldn't run |
| const result = replaceInTextNodes( | ||
| page.body, | ||
| validated.oldText, | ||
| validated.newText, | ||
| ); |
There was a problem hiding this comment.
newText can produce an invalid ADF text node, and the dry run reports it as safe.
Details and fix
newText is documented as "May be empty to delete the matched text" (line 158). When oldText is the whole of a node's text — the common case on exactly the pages this tool targets, since an annotation mark makes the anchored phrase its own text node — replaceInTextNodes leaves { "type": "text", "text": "", "marks": [...] } behind. ADF requires a text node's text to be non-empty, so the PUT either comes back as a 400 or the node is stripped, taking its annotation mark with it.
Concretely: "delete the phrase exportItems() from the page", where that phrase carries anchor a1b2c3. The dry run reports 1 occurrence(s) across 1 text node(s) and Inline-comment anchors: 1 before, 1 after (all preserved), the user approves, and the live write fails — or lands and drops the anchor. Neither outcome is what the preview promised.
Worth handling explicitly in replaceInTextNodes/the handler: after the replace, find text nodes whose text is now "", prune them from their parent's content, and let the existing anchor diff report the anchors that go with them (which also makes the diff below non-tautological). Refusing with a clear message when a replacement would empty an anchored node would be fine too — the point is that the preview and the write agree.
| name: editing-confluence-pages | ||
| description: Edit a Confluence page without breaking its open inline comments. Confluence anchors each inline comment to a text node with an annotation mark that a markdown round-trip strips, leaving the comment dangling; this skill edits through ADF so the anchor survives even when the text changes. Use for a small surgical edit (a typo, a renamed symbol, a reworded phrase) or for any edit to a page that has open inline comments — phrasings like "fix this on the Confluence page", "update the doc but keep the comments", "resolve comment 3 by changing the text", "preserve the anchors". Do not use to create a page, or to rewrite a page that has no inline comments (a plain markdown update is fine there). | ||
| when_to_use: Use when editing a Confluence page and any of these hold — the page has open inline comments that must stay attached after the edit, the user asks to preserve anchors/comments, or the edit is a small surgical change (a typo, a renamed symbol, a reworded phrase). Phrasings like "fix this on the Confluence page", "update the doc but keep the comments", "resolve comment 3 by changing the text", "preserve the anchors". Do not use for creating a fresh page, or for a large rewrite of a page that has no inline comments (a plain markdown update is fine there). | ||
| allowed-tools: mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__search_confluence, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__search_confluence_cql, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__list_confluence_anchors, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__get_confluence_page_adf, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__replace_confluence_text, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__update_confluence_page |
There was a problem hiding this comment.
🟡 SUGGESTED: allowed-tools has no Read, so the example added in the same commit can't be opened.
Step 4 of the full-body workflow (line 76) points at examples/anchor_preserving_edit.md — the worked "keep the marks array" example that step depends on — but with allowed-tools limited to the six MCP tools, the skill can't read it while active. Sibling filing-jira-tickets grants Read for the same reason. Adding Read to the front of the list restores the pointer:
| allowed-tools: mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__search_confluence, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__search_confluence_cql, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__list_confluence_anchors, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__get_confluence_page_adf, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__replace_confluence_text, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__update_confluence_page | |
| allowed-tools: Read, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__search_confluence, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__search_confluence_cql, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__list_confluence_anchors, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__get_confluence_page_adf, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__replace_confluence_text, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__update_confluence_page |
🎟️ Tracking
No linked ticket — contributing a personal skill that has proven useful for editing Confluence pages without breaking open inline comments.
📔 Objective
Adds an ADF-native Confluence write path to
bitwarden-atlassian-toolsso pages can be edited without dropping theannotationmarks that anchor open inline comments. Today the only way to update a page isupdateConfluencePagewithcontentFormat: "markdown", whose markdown round-trip strips those marks and leaves every inline comment dangling with no highlight on the page. Editing as ADF keeps the mark (the anchor is the mark, not the text), so an anchored span keeps its comment even when its text changes.The design mirrors the existing Jira write tools rather than introducing anything new: write is opt-in per install behind a token, tools are always listed, dry-run is the default, and a live write refuses without the token.
What's added
editing-confluence-pagesskill — orchestrates the anchor-preserving workflow (baseline anchors → edit → verify).replace_confluence_text(write, opt-in) — literal find/replace across text nodes, preserving marks. The low-bloat path for small edits; never routes the whole document through the conversation.update_confluence_page(write, opt-in) — full ADF body push for larger/structural edits. Its dry run diffs the submitted body's anchors against the live page and warns about any that would be dropped.get_confluence_page_adf,list_confluence_anchors(read-only) — fetch raw ADF + version, and list anchor ids and covered text.ATLASSIAN_CONFLUENCE_WRITE_TOKEN— opt-in per install, mirroringATLASSIAN_JIRA_WRITE_TOKEN. The current body is always fetched with the read-only token; only the final write uses the write token.Implementation notes
write-guardhelpers were parameterized (Jira defaults preserved byte-for-byte, all existing tests still green).plugin.json,marketplace.json, root README catalog, and CHANGELOG bumped to 2.7.0.Validation run locally
pnpm build+pnpm test— 377 tests passprettier --check .— cleancspell(full repo) — cleanvalidate-plugin-structure.sh,validate-marketplace.sh,validate-version-bump.sh— all pass🤖 Generated with Claude Code