From 33b7fd25e345d9408dce9f0d4cc1e3b5af2963a9 Mon Sep 17 00:00:00 2001 From: Gerald Fruhmann Date: Mon, 6 Jul 2026 10:25:27 +0200 Subject: [PATCH] feat(atlassian): Confluence Cloud adapter with Markdown converter - src/core/config.py: add ConfluenceConfig, JiraConfig, AtlassianConfig models; extend CoreConfig with optional atlassian field - config/atlassian.yaml: updated overlay template with nested atlassian.* keys, single ATLASSIAN_API_TOKEN for both services - adapters/atlassian/confluence.py: md_to_confluence_storage() converter (headings, bold/italic, tables, lists, blockquotes, HR, code, links); ConfluenceAdapter with idempotent create_or_update_page(), space ID resolution, and flat deploy under a root index page - tests/test_confluence.py: 21 unit tests (converter + adapter, all mocked) Co-Authored-By: Claude Sonnet 4.6 --- adapters/atlassian/confluence.py | 254 +++++++++++++++++++++++++++++++ config/atlassian.yaml | 46 +++--- src/core/config.py | 21 +++ tests/test_confluence.py | 124 +++++++++++++++ 4 files changed, 424 insertions(+), 21 deletions(-) create mode 100644 adapters/atlassian/confluence.py create mode 100644 tests/test_confluence.py diff --git a/adapters/atlassian/confluence.py b/adapters/atlassian/confluence.py new file mode 100644 index 0000000..a7e37f8 --- /dev/null +++ b/adapters/atlassian/confluence.py @@ -0,0 +1,254 @@ +"""Confluence Cloud adapter — creates pages via the Confluence REST API v2. + +Templates are authored in Markdown (single source of truth). +This adapter converts Markdown to Confluence Storage Format (XHTML) before uploading. +Authentication: email + ATLASSIAN_API_TOKEN env var (Basic auth). +API reference: https://developer.atlassian.com/cloud/confluence/rest/v2/ +""" + +from __future__ import annotations + +import os +import re +from typing import Any + +import requests + +from src.core.config import CoreConfig + + +def md_to_confluence_storage(text: str) -> str: + """Convert a subset of Markdown to Confluence Storage Format (XHTML). + + Covers the constructs used in qms-kit templates: + headings, bold, italic, tables, lists, blockquotes, HR, inline code, links. + """ + lines = text.splitlines() + out: list[str] = [] + i = 0 + + while i < len(lines): + line = lines[i] + + # Headings: # →

, ## →

, up to h6 + heading = re.match(r"^(#{1,6})\s+(.*)", line) + if heading: + level = len(heading.group(1)) + out.append(f"{_cf_inline(heading.group(2).strip())}") + i += 1 + continue + + # Horizontal rule + if re.match(r"^(-{3,}|\*{3,}|_{3,})\s*$", line): + out.append("
") + i += 1 + continue + + # Table: collect all consecutive table lines and render together + if line.strip().startswith("|"): + table_lines: list[str] = [] + while i < len(lines) and lines[i].strip().startswith("|"): + table_lines.append(lines[i]) + i += 1 + out.append(_cf_table(table_lines)) + continue + + # Unordered list: collect consecutive items into one
    + if re.match(r"^\s*[*\-]\s+", line) and not line.strip().startswith("|"): + items: list[str] = [] + while i < len(lines) and re.match(r"^\s*[*\-]\s+", lines[i]): + m = re.match(r"^\s*[*\-]\s+(.*)", lines[i]) + if m: + items.append(f"
  • {_cf_inline(m.group(1))}
  • ") + i += 1 + out.append("
      " + "".join(items) + "
    ") + continue + + # Ordered list: collect consecutive items into one
      + if re.match(r"^\s*\d+\.\s+", line): + items = [] + while i < len(lines) and re.match(r"^\s*\d+\.\s+", lines[i]): + m = re.match(r"^\s*\d+\.\s+(.*)", lines[i]) + if m: + items.append(f"
    1. {_cf_inline(m.group(1))}
    2. ") + i += 1 + out.append("
        " + "".join(items) + "
      ") + continue + + # Blockquote + if line.startswith(">"): + content = line.lstrip("> ").strip() + out.append(f"

      {_cf_inline(content)}

      ") + i += 1 + continue + + # Empty line → paragraph break + if not line.strip(): + out.append("") + i += 1 + continue + + # Default: wrap in paragraph + out.append(f"

      {_cf_inline(line)}

      ") + i += 1 + + return "\n".join(out) + + +def _cf_inline(text: str) -> str: + """Apply inline Markdown → Confluence Storage Format conversions.""" + # Bold + italic: ***text*** + text = re.sub(r"\*{3}(.+?)\*{3}", r"\1", text) + # Bold: **text** + text = re.sub(r"\*{2}(.+?)\*{2}", r"\1", text) + # Italic: *text* (single asterisk, not part of **) + text = re.sub(r"(?\1", text) + # Italic: _text_ + text = re.sub(r"(?\1", text) + # Inline code + text = re.sub(r"`([^`]+)`", r"\1", text) + # Markdown links: [text](url) → text + text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'\1', text) + return text + + +def _cf_table(lines: list[str]) -> str: + """Render a Markdown table as Confluence Storage Format XHTML table.""" + is_header = len(lines) > 1 and re.match(r"^\|[\s\-:|]+\|", lines[1]) + rows: list[str] = [] + + for idx, line in enumerate(lines): + if re.match(r"^\|[\s\-:|]+\|", line): + continue # skip separator row + cells = [c.strip() for c in line.strip().strip("|").split("|")] + tag = "th" if (idx == 0 and is_header) else "td" + row = "" + "".join(f"<{tag}>{_cf_inline(c)}" for c in cells) + "" + rows.append(row) + + return "" + "".join(rows) + "
      " + + +class ConfluenceAdapter: + """Seeds QMS document structure into a Confluence Cloud space. + + Templates are Markdown; this adapter converts to Confluence Storage Format before upload. + Authentication: email + ATLASSIAN_API_TOKEN env var (Basic auth). + """ + + def __init__(self, config: CoreConfig) -> None: + if config.atlassian is None: + raise ValueError("atlassian config block is required for target=atlassian") + cf = config.atlassian.confluence + self._base_url = cf.base_url.rstrip("/") + self._space_key = cf.space_key + self._parent_page_title = cf.parent_page_title + self._email = cf.email + self._token = os.environ.get("ATLASSIAN_API_TOKEN", "") + + @property + def _auth(self) -> tuple[str, str]: + return (self._email, self._token) + + def _api(self, path: str) -> str: + return f"{self._base_url}/api/v2{path}" + + def get_page_id(self, title: str) -> str | None: + """Return page ID if a page with this title exists in the space, else None.""" + resp = requests.get( + self._api("/pages"), + params={"spaceKey": self._space_key, "title": title}, + auth=self._auth, + headers={"Accept": "application/json"}, + timeout=15, + ) + resp.raise_for_status() + results = resp.json().get("results", []) + return results[0]["id"] if results else None + + def create_or_update_page( + self, + title: str, + content: str, + parent_id: str | None = None, + ) -> str: + """Idempotent upsert. Content is Markdown; converted to Storage Format here. + + Returns the page ID. + """ + storage_content = md_to_confluence_storage(content) + body: dict[str, Any] = { + "spaceId": self._get_space_id(), + "title": title, + "body": {"representation": "storage", "value": storage_content}, + } + if parent_id: + body["parentId"] = parent_id + + existing_id = self.get_page_id(title) + if existing_id: + # Update existing page — need current version number + page = requests.get( + self._api(f"/pages/{existing_id}"), + auth=self._auth, + headers={"Accept": "application/json"}, + timeout=15, + ) + page.raise_for_status() + version = page.json()["version"]["number"] + 1 + body["version"] = {"number": version} + resp = requests.put( + self._api(f"/pages/{existing_id}"), + json=body, + auth=self._auth, + timeout=30, + ) + else: + resp = requests.post( + self._api("/pages"), + json=body, + auth=self._auth, + timeout=30, + ) + + resp.raise_for_status() + return resp.json()["id"] + + def _get_space_id(self) -> str: + """Resolve space key to numeric space ID (required by API v2).""" + resp = requests.get( + self._api("/spaces"), + params={"keys": self._space_key}, + auth=self._auth, + headers={"Accept": "application/json"}, + timeout=15, + ) + resp.raise_for_status() + results = resp.json().get("results", []) + if not results: + raise ValueError(f"Confluence space '{self._space_key}' not found.") + return results[0]["id"] + + def deploy(self, rendered_pages: dict[str, tuple[str, str]]) -> None: + """Deploy all rendered pages into the Confluence space. + + Structure created: + /QM Manual ← index / root page + /QM Manual/ ← all document pages as children + + Args: + rendered_pages: page_name -> (title, rendered_markdown) + """ + # Root index page + index_entry = rendered_pages.pop("qms_index", None) + index_title = self._parent_page_title + if index_entry: + _, content = index_entry + root_id = self.create_or_update_page(index_title, content) + else: + root_id = self.create_or_update_page(index_title, f"# {index_title}\n") + print(f" Confluence: upserted root page '{index_title}' (id={root_id})") + + # All document pages as children of the root page + for _page_name, (title, content) in rendered_pages.items(): + page_id = self.create_or_update_page(title, content, parent_id=root_id) + print(f" Confluence: upserted '{title}' (id={page_id})") diff --git a/config/atlassian.yaml b/config/atlassian.yaml index 7131806..2d7bf96 100644 --- a/config/atlassian.yaml +++ b/config/atlassian.yaml @@ -1,27 +1,31 @@ -# Atlassian overlay — Confluence (docs) + Jira (records) [Phase 2, not yet implemented] -# TODO: fill all fields before running: deploy --target atlassian +# Atlassian Cloud overlay — merge over config/core.yaml +# Copy to config/clients/.yaml and fill in the values. +# Never commit files from config/clients/ (gitignored). +# Secret: export ATLASSIAN_API_TOKEN= +# One token covers both Confluence and Jira (same Atlassian account). target: atlassian organisation: - name: "TODO" - short: "TODO" - quality_officer: "TODO" - management: "TODO" + name: "Acme GmbH" + short: "ACME" + quality_officer: "Jane Smith" + management: "John Doe" -confluence: - base_url: "TODO: https://.atlassian.net/wiki" - space_key: "TODO: QMS" - # token via env: CONFLUENCE_API_TOKEN - # NOTE (verified): approval workflow not native in Confluence Cloud. - # Requires Marketplace app (e.g. Comala). Partially automatable only. +atlassian: + confluence: + base_url: "https://acme.atlassian.net/wiki" # trailing /wiki required + space_key: "QMS" + email: "jane.smith@acme.com" + parent_page_title: "QM Manual" -jira: - base_url: "TODO: https://.atlassian.net" - project_key: "TODO: QMS" - # token via env: JIRA_API_TOKEN - issue_type_mapping: - nc: "TODO" - capa: "TODO" - audit: "TODO" - kpi: "TODO" + jira: + base_url: "https://acme.atlassian.net" + project_key: "QMS" + project_name: "Quality Management System" + email: "jane.smith@acme.com" + issue_type_mapping: + nc: "Nonconformity" + capa: "Corrective Action" + audit: "Internal Audit" + kpi: "KPI Measurement" diff --git a/src/core/config.py b/src/core/config.py index 384bd85..517491b 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -84,6 +84,26 @@ class SelfhostedConfig(BaseModel): redmine: RedmineConfig +class ConfluenceConfig(BaseModel): + base_url: str # https://mycompany.atlassian.net/wiki + space_key: str # QMS + email: str # atlassian account email + parent_page_title: str = "QM Manual" + + +class JiraConfig(BaseModel): + base_url: str # https://mycompany.atlassian.net + project_key: str # QMS + project_name: str = "Quality Management System" + email: str # atlassian account email (same as confluence) + issue_type_mapping: dict[str, str] = {} + + +class AtlassianConfig(BaseModel): + confluence: ConfluenceConfig + jira: JiraConfig + + class CoreConfig(BaseModel): meta: Meta organisation: Organisation @@ -95,6 +115,7 @@ class CoreConfig(BaseModel): capa_states: list[CapaState] target: str | None = None selfhosted: SelfhostedConfig | None = None + atlassian: AtlassianConfig | None = None @field_validator("documents") @classmethod diff --git a/tests/test_confluence.py b/tests/test_confluence.py new file mode 100644 index 0000000..5b5eb49 --- /dev/null +++ b/tests/test_confluence.py @@ -0,0 +1,124 @@ +"""Unit tests for the Markdown → Confluence Storage Format converter and adapter.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from adapters.atlassian.confluence import ConfluenceAdapter, _cf_table, md_to_confluence_storage +from src.core.config import AtlassianConfig, ConfluenceConfig, JiraConfig + + +def _make_config() -> MagicMock: + cfg = MagicMock() + cfg.atlassian = AtlassianConfig( + confluence=ConfluenceConfig( + base_url="http://atlassian.test/wiki", + space_key="QMS", + email="admin@test.com", + ), + jira=JiraConfig( + base_url="http://atlassian.test", + project_key="QMS", + email="admin@test.com", + ), + ) + return cfg + + +class TestMdToConfluenceStorage: + def test_h1(self) -> None: + assert md_to_confluence_storage("# Title") == "

      Title

      " + + def test_h2(self) -> None: + assert md_to_confluence_storage("## Section") == "

      Section

      " + + def test_h3(self) -> None: + assert md_to_confluence_storage("### Sub") == "

      Sub

      " + + def test_hr(self) -> None: + assert md_to_confluence_storage("---") == "
      " + + def test_bold(self) -> None: + assert "bold" in md_to_confluence_storage("**bold** text") + + def test_italic_asterisk(self) -> None: + assert "italic" in md_to_confluence_storage("*italic* text") + + def test_italic_underscore(self) -> None: + assert "italic" in md_to_confluence_storage("_italic_ text") + + def test_link(self) -> None: + result = md_to_confluence_storage("[Atlassian](https://atlassian.com)") + assert 'Atlassian' in result + + def test_inline_code(self) -> None: + assert "cmd" in md_to_confluence_storage("`cmd` here") + + def test_unordered_list_grouped(self) -> None: + result = md_to_confluence_storage("- a\n- b\n- c") + assert result.startswith("
        ") + assert result.count("
      • ") == 3 + assert result.count("
          ") == 1 + + def test_ordered_list_grouped(self) -> None: + result = md_to_confluence_storage("1. x\n2. y") + assert result.startswith("
            ") + assert result.count("
          1. ") == 2 + + def test_blockquote(self) -> None: + result = md_to_confluence_storage("> note") + assert "
            " in result + assert "note" in result + + def test_paragraph(self) -> None: + assert md_to_confluence_storage("hello world") == "

            hello world

            " + + def test_empty_line(self) -> None: + result = md_to_confluence_storage("a\n\nb") + assert result == "

            a

            \n\n

            b

            " + + +class TestCfTable: + def test_header_uses_th(self) -> None: + lines = ["| A | B |", "|---|---|", "| 1 | 2 |"] + result = _cf_table(lines) + assert "A" in result + assert "B" in result + + def test_data_uses_td(self) -> None: + lines = ["| A | B |", "|---|---|", "| 1 | 2 |"] + result = _cf_table(lines) + assert "1" in result + + def test_separator_skipped(self) -> None: + lines = ["| A |", "|---|", "| x |"] + result = _cf_table(lines) + assert "---" not in result + + +class TestConfluenceAdapter: + def test_raises_if_no_atlassian_config(self) -> None: + cfg = MagicMock() + cfg.atlassian = None + with pytest.raises(ValueError, match="atlassian config block"): + ConfluenceAdapter(cfg) + + def test_base_url_trailing_slash_stripped(self) -> None: + adapter = ConfluenceAdapter(_make_config()) + assert not adapter._base_url.endswith("/") + + def test_get_page_id_returns_none_on_empty(self) -> None: + adapter = ConfluenceAdapter(_make_config()) + mock_resp = MagicMock() + mock_resp.json.return_value = {"results": []} + mock_resp.raise_for_status.return_value = None + with patch("adapters.atlassian.confluence.requests.get", return_value=mock_resp): + assert adapter.get_page_id("Missing Page") is None + + def test_get_page_id_returns_id_when_found(self) -> None: + adapter = ConfluenceAdapter(_make_config()) + mock_resp = MagicMock() + mock_resp.json.return_value = {"results": [{"id": "42"}]} + mock_resp.raise_for_status.return_value = None + with patch("adapters.atlassian.confluence.requests.get", return_value=mock_resp): + assert adapter.get_page_id("Existing Page") == "42"