Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions adapters/atlassian/jira.py
Original file line number Diff line number Diff line change
@@ -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)
22 changes: 21 additions & 1 deletion cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
115 changes: 115 additions & 0 deletions tests/test_jira.py
Original file line number Diff line number Diff line change
@@ -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
Loading