guardia-itsm/core/jira_sync.py
2026-05-30 23:02:43 +09:00

226 lines
7.6 KiB
Python

"""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"<p>{content.replace(chr(10), '</p><p>')}</p>"
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