"""Jira/Confluence 연동 — SR ↔ Jira 이슈 양방향 동기화. 환경변수: JIRA_BASE_URL: Jira 인스턴스 URL (ex: http://jira.internal:8080) JIRA_TOKEN: Jira API 토큰 JIRA_PROJECT_KEY: 프로젝트 키 (ex: GUARDIA) CONFLUENCE_BASE_URL: Confluence URL (ex: http://confluence.internal:8090) CONFLUENCE_TOKEN: Confluence API 토큰 CONFLUENCE_SPACE_KEY: Confluence 스페이스 키 (기본: GUARDIA) """ from __future__ import annotations import logging import os from typing import Optional import httpx logger = logging.getLogger(__name__) JIRA_BASE_URL = os.getenv("JIRA_BASE_URL", "") JIRA_TOKEN = os.getenv("JIRA_TOKEN", "") JIRA_PROJECT_KEY = os.getenv("JIRA_PROJECT_KEY", "GUARDIA") CONFLUENCE_BASE_URL = os.getenv("CONFLUENCE_BASE_URL", "") CONFLUENCE_TOKEN = os.getenv("CONFLUENCE_TOKEN", "") CONFLUENCE_SPACE = os.getenv("CONFLUENCE_SPACE_KEY", "GUARDIA") _PRIORITY_MAP = { "CRITICAL": "Highest", "HIGH": "High", "MEDIUM": "Medium", "LOW": "Low", } _STATUS_TRANSITION_MAP = { "IN_PROGRESS": "In Progress", "COMPLETED": "Done", "REJECTED": "Closed", "FAILED_ROLLBACK": "Closed", "PENDING_APPROVAL": "In Review", "APPROVED": "To Do", } def _jira_headers() -> dict: return { "Authorization": f"Bearer {JIRA_TOKEN}", "Content-Type": "application/json", "Accept": "application/json", } def _confluence_headers() -> dict: return { "Authorization": f"Bearer {CONFLUENCE_TOKEN}", "Content-Type": "application/json", "Accept": "application/json", } async def create_jira_issue( sr_id: str, title: str, description: str, priority: str, ) -> Optional[str]: """SR → Jira 이슈 생성. 성공 시 Jira 이슈 키 반환 (ex: GUARDIA-42).""" if not JIRA_BASE_URL or not JIRA_TOKEN: logger.debug("JIRA_BASE_URL/JIRA_TOKEN 미설정 — Jira 연동 스킵") return None payload = { "fields": { "project": {"key": JIRA_PROJECT_KEY}, "summary": f"[{sr_id}] {title}", "description": description or "", "issuetype": {"name": "Task"}, "priority": {"name": _PRIORITY_MAP.get(priority, "Medium")}, "labels": ["GUARDiA", "auto-sync"], } } try: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.post( f"{JIRA_BASE_URL}/rest/api/2/issue", headers=_jira_headers(), json=payload, ) if resp.status_code == 201: key = resp.json().get("key") logger.info("Jira 이슈 생성: sr_id=%s jira_key=%s", sr_id, key) return key else: logger.warning("Jira 이슈 생성 실패: status=%d body=%s", resp.status_code, resp.text[:200]) except Exception as e: logger.error("Jira 연동 오류: %s", str(e)[:100]) return None async def get_jira_issue_status(jira_key: str) -> Optional[dict]: """Jira 이슈 상태 조회.""" if not JIRA_BASE_URL or not JIRA_TOKEN: return None try: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get( f"{JIRA_BASE_URL}/rest/api/2/issue/{jira_key}", headers=_jira_headers(), ) if resp.status_code == 200: data = resp.json() fields = data.get("fields", {}) return { "jira_key": jira_key, "summary": fields.get("summary", ""), "status": fields.get("status", {}).get("name", ""), "priority": fields.get("priority", {}).get("name", ""), "assignee": (fields.get("assignee") or {}).get("displayName", ""), "updated": fields.get("updated", ""), } except Exception as e: logger.error("Jira 상태 조회 오류: %s", str(e)[:100]) return None async def sync_jira_status(jira_key: str, guardia_status: str) -> bool: """GUARDiA 상태를 Jira 트랜지션으로 반영.""" if not JIRA_BASE_URL or not JIRA_TOKEN: return False target = _STATUS_TRANSITION_MAP.get(guardia_status) if not target: return False try: async with httpx.AsyncClient(timeout=10.0) as client: # 트랜지션 목록 조회 r = await client.get( f"{JIRA_BASE_URL}/rest/api/2/issue/{jira_key}/transitions", headers=_jira_headers(), ) if r.status_code != 200: return False transitions = r.json().get("transitions", []) tid = next( (t["id"] for t in transitions if t["name"] == target), None, ) if not tid: return False await client.post( f"{JIRA_BASE_URL}/rest/api/2/issue/{jira_key}/transitions", headers=_jira_headers(), json={"transition": {"id": tid}}, ) logger.info("Jira 상태 동기화: key=%s status=%s", jira_key, target) return True except Exception as e: logger.error("Jira 상태 동기화 오류: %s", str(e)[:100]) return False async def list_jira_projects() -> list: """연결된 Jira 프로젝트 목록 조회.""" if not JIRA_BASE_URL or not JIRA_TOKEN: return [] try: async with httpx.AsyncClient(timeout=10.0) as client: r = await client.get( f"{JIRA_BASE_URL}/rest/api/2/project", headers=_jira_headers(), ) if r.status_code == 200: return [ {"key": p["key"], "name": p["name"], "id": p["id"]} for p in r.json() ] except Exception as e: logger.error("Jira 프로젝트 목록 오류: %s", str(e)[:100]) return [] async def create_confluence_page( title: str, content: str, space_key: Optional[str] = None, ) -> Optional[str]: """KB 문서를 Confluence 페이지로 발행. 성공 시 페이지 URL 반환.""" if not CONFLUENCE_BASE_URL or not CONFLUENCE_TOKEN: logger.debug("Confluence 미설정 — 스킵") return None space = space_key or CONFLUENCE_SPACE storage_content = f"

{content.replace(chr(10), '

')}

" payload = { "type": "page", "title": title, "space": {"key": space}, "body": { "storage": { "value": storage_content, "representation": "storage", } }, } try: async with httpx.AsyncClient(timeout=15.0) as client: resp = await client.post( f"{CONFLUENCE_BASE_URL}/rest/api/content", headers=_confluence_headers(), json=payload, ) if resp.status_code == 200: data = resp.json() page_id = data.get("id") url = f"{CONFLUENCE_BASE_URL}/pages/viewpage.action?pageId={page_id}" logger.info("Confluence 페이지 생성: title=%s url=%s", title, url) return url else: logger.warning("Confluence 페이지 생성 실패: status=%d", resp.status_code) except Exception as e: logger.error("Confluence 연동 오류: %s", str(e)[:100]) return None