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("")
+ 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"- {_cf_inline(m.group(1))}
")
+ 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)}{tag}>" for c in cells) + "
"
+ rows.append(row)
+
+ return ""
+
+
+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("- ") == 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\nb
"
+
+
+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"