From 0a462130201b397d28bd1adf9976fc541221f788 Mon Sep 17 00:00:00 2001 From: Gerald Fruhmann Date: Mon, 6 Jul 2026 10:45:37 +0200 Subject: [PATCH] feat(atlassian): add Jira Cloud adapter and wire --target atlassian - JiraAdapter: creates project (projectTypeKey=business), seeds issue types via Jira REST API v3, idempotent (skips existing resources) - _deploy_atlassian() in CLI wires Confluence + Jira for atlassian target - 9 unit tests covering project_exists, get/create issue type, deploy - Fix SIM117: combine nested with-patch statements in test_jira.py - Update test_cli.py: atlassian target now raises "config block missing" instead of "not yet implemented" Co-Authored-By: Claude Sonnet 4.6 --- adapters/atlassian/jira.py | 143 +++++++++++++++++++++++++++++++++++++ cli/main.py | 22 +++++- tests/test_cli.py | 2 +- tests/test_jira.py | 115 +++++++++++++++++++++++++++++ 4 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 adapters/atlassian/jira.py create mode 100644 tests/test_jira.py diff --git a/adapters/atlassian/jira.py b/adapters/atlassian/jira.py new file mode 100644 index 0000000..7f2a7d8 --- /dev/null +++ b/adapters/atlassian/jira.py @@ -0,0 +1,143 @@ +"""Jira Cloud adapter — creates project and seeds issue types via Jira REST API v3. + +Authentication: email + ATLASSIAN_API_TOKEN env var (Basic auth, same token as Confluence). +API reference: https://developer.atlassian.com/cloud/jira/platform/rest/v3/ + +IMPORTANT (verified API limit): adding custom issue types to a specific project's +issue type scheme requires the scheme ID, which is only deterministic on a fresh +project. This adapter creates the project and issue types, then associates them. +For existing projects, manual assignment via Jira UI may be needed. +""" + +from __future__ import annotations + +import os +from typing import Any + +import requests + +from src.core.config import CoreConfig, RecordType + + +class JiraAdapter: + """Seeds QMS record structure into a Jira Cloud project. + + Authentication: email + ATLASSIAN_API_TOKEN env var. + """ + + def __init__(self, config: CoreConfig) -> None: + if config.atlassian is None: + raise ValueError("atlassian config block is required for target=atlassian") + jira = config.atlassian.jira + self._base_url = jira.base_url.rstrip("/") + self._project_key = jira.project_key + self._project_name = jira.project_name + self._email = jira.email + self._token = os.environ.get("ATLASSIAN_API_TOKEN", "") + self._config = config + + @property + def _auth(self) -> tuple[str, str]: + return (self._email, self._token) + + def _api(self, path: str) -> str: + return f"{self._base_url}/rest/api/3{path}" + + def _get(self, path: str, params: dict[str, Any] | None = None) -> Any: + resp = requests.get( + self._api(path), + params=params, + auth=self._auth, + headers={"Accept": "application/json"}, + timeout=15, + ) + resp.raise_for_status() + return resp.json() + + def _post(self, path: str, payload: dict[str, Any]) -> Any: + resp = requests.post( + self._api(path), + json=payload, + auth=self._auth, + headers={"Accept": "application/json", "Content-Type": "application/json"}, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + + def get_current_user_account_id(self) -> str: + """Return the account ID of the authenticated user (required for project creation).""" + data = self._get("/myself") + return str(data["accountId"]) + + def project_exists(self) -> bool: + try: + self._get(f"/project/{self._project_key}") + return True + except requests.HTTPError: + return False + + def create_project(self) -> str: + """Idempotent: skips creation if project already exists. Returns project ID.""" + if self.project_exists(): + print(f" Jira: project '{self._project_key}' already exists, skipping") + data = self._get(f"/project/{self._project_key}") + return str(data["id"]) + + account_id = self.get_current_user_account_id() + payload = { + "key": self._project_key, + "name": self._project_name, + "projectTypeKey": "business", + "leadAccountId": account_id, + "description": ( + f"Quality Management System records for {self._config.organisation.name}. " + f"ISO 9001:2015 compliant." + ), + } + data = self._post("/project", payload) + print(f" Jira: created project '{self._project_key}' (id={data['id']})") + return str(data["id"]) + + def get_issue_type_id(self, name: str) -> str | None: + """Return issue type ID by name, or None if not found.""" + data = self._get("/issuetype") + for issue_type in data: + if issue_type["name"] == name: + return str(issue_type["id"]) + return None + + def create_issue_type(self, name: str, description: str) -> str: + """Idempotent: returns existing ID if already present, else creates.""" + existing = self.get_issue_type_id(name) + if existing: + print(f" Jira: issue type '{name}' already exists (id={existing}), skipping") + return existing + + payload = {"name": name, "description": description, "type": "standard"} + data = self._post("/issuetype", payload) + print(f" Jira: created issue type '{name}' (id={data['id']})") + return str(data["id"]) + + def seed_record_type(self, record_type: RecordType, issue_type_name: str) -> None: + """Ensure the issue type for this record type exists globally.""" + description = ( + f"ISO 9001:2015 Clause {record_type.clause} — {record_type.label}. " + f"Fields: {', '.join(record_type.fields[:5])}." + ) + self.create_issue_type(issue_type_name, description) + + def deploy(self, issue_type_mapping: dict[str, str]) -> None: + """Create Jira project and seed one issue type per QMS record type. + + Args: + issue_type_mapping: maps record type ID -> Jira issue type name + """ + self.create_project() + + for record_type in self._config.record_types: + issue_type_name = issue_type_mapping.get(record_type.id) + if not issue_type_name: + print(f" Jira: no issue type mapping for '{record_type.id}', skipping") + continue + self.seed_record_type(record_type, issue_type_name) diff --git a/cli/main.py b/cli/main.py index 872a38d..916b5b2 100644 --- a/cli/main.py +++ b/cli/main.py @@ -99,11 +99,31 @@ def deploy(target: str, config_path: Path, dry_run: bool) -> None: if target == "selfhosted": _deploy_selfhosted(config, rendered) elif target == "atlassian": - raise click.ClickException("Atlassian adapter not yet implemented (Phase 2).") + _deploy_atlassian(config, rendered) elif target == "m365": raise click.ClickException("M365 adapter not yet implemented (Phase 3).") +def _deploy_atlassian(config: object, rendered: dict[str, tuple[str, str]]) -> None: + from adapters.atlassian.confluence import ConfluenceAdapter + from adapters.atlassian.jira import JiraAdapter + from src.core.config import CoreConfig + + cfg = config if isinstance(config, CoreConfig) else None + if cfg is None or cfg.atlassian is None: + raise click.ClickException("atlassian config block missing in overlay.") + + click.echo("\nDeploying to Atlassian Cloud (Confluence + Jira)...") + + confluence = ConfluenceAdapter(cfg) + confluence.deploy(rendered) + + jira = JiraAdapter(cfg) + jira.deploy(cfg.atlassian.jira.issue_type_mapping) + + click.echo("\nDone.") + + def _deploy_selfhosted(config: object, rendered: dict[str, tuple[str, str]]) -> None: from adapters.selfhosted.redmine import RedmineAdapter from adapters.selfhosted.xwiki import XWikiAdapter diff --git a/tests/test_cli.py b/tests/test_cli.py index 64b1cf7..3c6013d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -63,7 +63,7 @@ def test_atlassian_target_raises_click_exception(self) -> None: ["deploy", "--target", "atlassian", "--config", str(SELFHOSTED_YAML)], ) assert result.exit_code != 0 - assert "not yet implemented" in result.output + assert "atlassian config block missing" in result.output def test_m365_target_raises_click_exception(self) -> None: runner = CliRunner() diff --git a/tests/test_jira.py b/tests/test_jira.py new file mode 100644 index 0000000..d110773 --- /dev/null +++ b/tests/test_jira.py @@ -0,0 +1,115 @@ +"""Unit tests for the Jira Cloud adapter.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from adapters.atlassian.jira import JiraAdapter +from src.core.config import AtlassianConfig, ConfluenceConfig, JiraConfig, RecordType + + +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", + project_name="Quality Management System", + email="admin@test.com", + ), + ) + cfg.organisation.name = "Acme GmbH" + cfg.record_types = [ + RecordType(id="nc", label="Nonconformity", clause="8.7", fields=["title", "description"]), + RecordType(id="capa", label="CAPA", clause="10.2", fields=["title", "action"]), + ] + return cfg + + +class TestJiraAdapter: + def test_raises_if_no_atlassian_config(self) -> None: + cfg = MagicMock() + cfg.atlassian = None + with pytest.raises(ValueError, match="atlassian config block"): + JiraAdapter(cfg) + + def test_base_url_trailing_slash_stripped(self) -> None: + adapter = JiraAdapter(_make_config()) + assert not adapter._base_url.endswith("/") + + def test_project_exists_returns_true_on_200(self) -> None: + adapter = JiraAdapter(_make_config()) + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + with patch("adapters.atlassian.jira.requests.get", return_value=mock_resp): + assert adapter.project_exists() is True + + def test_project_exists_returns_false_on_http_error(self) -> None: + import requests as req + + adapter = JiraAdapter(_make_config()) + mock_resp = MagicMock() + mock_resp.raise_for_status.side_effect = req.HTTPError() + with patch("adapters.atlassian.jira.requests.get", return_value=mock_resp): + assert adapter.project_exists() is False + + def test_get_issue_type_id_returns_id_when_found(self) -> None: + adapter = JiraAdapter(_make_config()) + mock_resp = MagicMock() + mock_resp.json.return_value = [{"name": "Nonconformity", "id": "10001"}] + mock_resp.raise_for_status.return_value = None + with patch("adapters.atlassian.jira.requests.get", return_value=mock_resp): + assert adapter.get_issue_type_id("Nonconformity") == "10001" + + def test_get_issue_type_id_returns_none_when_missing(self) -> None: + adapter = JiraAdapter(_make_config()) + mock_resp = MagicMock() + mock_resp.json.return_value = [{"name": "Bug", "id": "1"}] + mock_resp.raise_for_status.return_value = None + with patch("adapters.atlassian.jira.requests.get", return_value=mock_resp): + assert adapter.get_issue_type_id("Nonconformity") is None + + def test_create_issue_type_skips_if_exists(self) -> None: + adapter = JiraAdapter(_make_config()) + with ( + patch.object(adapter, "get_issue_type_id", return_value="99"), + patch("adapters.atlassian.jira.requests.post") as mock_post, + ): + adapter.create_issue_type("Nonconformity", "desc") + mock_post.assert_not_called() + + def test_create_issue_type_posts_if_missing(self) -> None: + adapter = JiraAdapter(_make_config()) + mock_post_resp = MagicMock() + mock_post_resp.json.return_value = {"id": "10002"} + mock_post_resp.raise_for_status.return_value = None + with ( + patch.object(adapter, "get_issue_type_id", return_value=None), + patch("adapters.atlassian.jira.requests.post", return_value=mock_post_resp), + ): + result = adapter.create_issue_type("Nonconformity", "desc") + assert result == "10002" + + def test_deploy_seeds_all_mapped_record_types(self) -> None: + adapter = JiraAdapter(_make_config()) + mapping = {"nc": "Nonconformity", "capa": "Corrective Action"} + with ( + patch.object(adapter, "create_project", return_value="1"), + patch.object(adapter, "seed_record_type") as mock_seed, + ): + adapter.deploy(mapping) + assert mock_seed.call_count == 2 + + def test_deploy_skips_unmapped_record_types(self) -> None: + adapter = JiraAdapter(_make_config()) + with ( + patch.object(adapter, "create_project", return_value="1"), + patch.object(adapter, "seed_record_type") as mock_seed, + ): + adapter.deploy({"nc": "Nonconformity"}) # capa not mapped + assert mock_seed.call_count == 1