- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
226 lines
7.6 KiB
Python
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
|