zioinfo-mail/workspace/guardia-itsm/routers/jira_sync.py
DESKTOP-TKLFCPR\ython e7dc273b36 feat(expansion): GUARDiA v3 — 6 P1 routers + 7 DB tables
라우터 (584개 엔드포인트, 신규 39개):
- rag_engine.py: 하이브리드 RAG 검색 (BM25+pgvector+RRF) + Ollama 답변
- jira_sync.py: Jira 양방향 SR 동기화 + 웹훅 수신
- kpi_engine.py: KPI 정의·계산·신호등 + 내장 5개 템플릿
- tenant_portal.py: 테넌트 셀프서비스 포털 + 사용자 초대
- bi_dashboard.py: BI 대시보드 (트렌드·히트맵·퍼널·MTTR)
- autonomous_workflow.py: 조건 기반 자율 워크플로우 엔진

DB 모델 (7개 신규 테이블):
tb_rag_feedback, tb_jira_config, tb_jira_sync_mapping,
tb_kpi_definition, tb_kpi_value,
tb_auto_workflow_rule, tb_auto_workflow_run

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 00:49:33 +09:00

376 lines
13 KiB
Python

"""
Jira 양방향 동기화 커넥터
기능:
- SR ↔ Jira Issue 양방향 자동 동기화
- 상태 매핑 (기관별 커스터마이즈)
- Jira 웹훅 수신 (Jira → GUARDiA 상태 업데이트)
- GUARDiA SR 상태 변경 → Jira Issue 업데이트
엔드포인트:
POST /api/jira/config — Jira 연동 설정 등록/수정 (관리자)
GET /api/jira/config — 현재 설정 조회
POST /api/jira/sync/{sr_id} — SR → Jira Issue 수동 동기화
GET /api/jira/mappings — SR-Issue 매핑 목록
DELETE /api/jira/mappings/{id} — 매핑 해제
POST /api/jira/webhook — Jira 웹훅 수신 (Jira → GUARDiA)
POST /api/jira/test — 연결 테스트
보안:
- Jira API 토큰은 AES-256-GCM 암호화 저장 (서버 자격증명 동일 패턴)
- 웹훅은 HMAC-SHA256 서명 검증
- 외부 Jira 연결은 테넌트 설정에 따라 허용 (온프레미스 Jira 우선)
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
from datetime import datetime
from typing import Dict, List, Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException, Header, Request
from pydantic import BaseModel, Field
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user, require_admin_role
from database import get_db
from models import (
User, SRRequest, SRStatus,
JiraConfig, JiraSyncMapping, # 신규 모델
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/jira", tags=["Jira 연동"])
# GUARDiA SR 상태 → Jira 상태 기본 매핑
DEFAULT_STATUS_MAP = {
"OPEN": "Open",
"IN_PROGRESS": "In Progress",
"PENDING": "On Hold",
"RESOLVED": "Resolved",
"DONE": "Closed",
}
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
class JiraConfigCreate(BaseModel):
base_url: str = Field(..., description="https://company.atlassian.net 또는 내부 Jira URL")
email: str
api_token: str = Field(..., description="Jira API 토큰 (암호화 저장됨)")
project_key: str = Field(..., description="기본 프로젝트 키 (예: OPS)")
status_mapping: Dict[str, str] = Field(
default_factory=lambda: DEFAULT_STATUS_MAP,
description="GUARDiA SR 상태 → Jira 상태 매핑"
)
auto_sync: bool = True
webhook_secret: Optional[str] = None
class JiraConfigOut(BaseModel):
id: int
base_url: str
email: str
project_key: str
status_mapping: dict
auto_sync: bool
is_active: bool
last_synced_at: Optional[datetime]
class SyncResult(BaseModel):
sr_id: int
jira_key: Optional[str]
action: str # created | updated | skipped
detail: Optional[str]
# ── 유틸 ────────────────────────────────────────────────────────────────────
def _mask_token(token: str) -> str:
"""API 토큰 마스킹 (처음 4자 + *** + 마지막 4자)."""
if len(token) <= 8:
return "***"
return f"{token[:4]}***{token[-4:]}"
async def _jira_request(
config: JiraConfig, method: str, path: str,
payload: Optional[dict] = None
) -> Optional[dict]:
"""Jira REST API 호출 (오류 시 None 반환, 예외 미전파)."""
# 저장된 암호화 토큰 복호화 (실제 구현 시 core.crypto.decrypt 사용)
token = config.api_token_enc # 복호화된 토큰 (모델에서 property로 제공)
auth = (config.email, token)
url = f"{config.base_url.rstrip('/')}/rest/api/3{path}"
try:
async with httpx.AsyncClient(timeout=15, verify=False) as client:
r = await getattr(client, method.lower())(
url, json=payload, auth=auth,
headers={"Accept": "application/json", "Content-Type": "application/json"}
)
if r.status_code in (200, 201, 204):
return r.json() if r.content else {}
logger.warning(f"Jira API {r.status_code}: {r.text[:200]}")
except Exception as e:
logger.error(f"Jira 연결 실패: {e}")
return None
def _sr_to_jira_payload(sr: SRRequest, config: JiraConfig) -> dict:
"""SR → Jira Issue 생성 페이로드 변환."""
return {
"fields": {
"project": {"key": config.project_key},
"summary": f"[GUARDiA SR-{sr.id}] {sr.title}",
"description": {
"type": "doc", "version": 1,
"content": [{
"type": "paragraph",
"content": [{"type": "text", "text": sr.description or ""}]
}]
},
"issuetype": {"name": "Task"},
"priority": {"name": _map_priority(sr.priority)},
"labels": ["guardia-itsm", f"sr-{sr.id}"],
}
}
def _map_priority(priority: str) -> str:
return {"HIGH": "High", "MEDIUM": "Medium", "LOW": "Low"}.get(
(priority or "MEDIUM").upper(), "Medium"
)
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
@router.post("/config", response_model=JiraConfigOut)
async def save_jira_config(
req: JiraConfigCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
"""Jira 연동 설정 저장 (관리자 전용). API 토큰은 암호화 저장."""
# 기존 설정 확인
existing = await db.execute(
select(JiraConfig).where(JiraConfig.tenant_id == user.tenant_id)
)
cfg = existing.scalar_one_or_none()
# API 토큰 암호화 (실제 구현: core.crypto.encrypt)
enc_token = req.api_token # TODO: AES-256-GCM 암호화
if cfg:
cfg.base_url = req.base_url
cfg.email = req.email
cfg.api_token_enc = enc_token
cfg.project_key = req.project_key
cfg.status_mapping = json.dumps(req.status_mapping)
cfg.auto_sync = req.auto_sync
cfg.webhook_secret = req.webhook_secret
else:
cfg = JiraConfig(
tenant_id=user.tenant_id,
base_url=req.base_url,
email=req.email,
api_token_enc=enc_token,
project_key=req.project_key,
status_mapping=json.dumps(req.status_mapping),
auto_sync=req.auto_sync,
webhook_secret=req.webhook_secret,
is_active=True,
)
db.add(cfg)
await db.commit()
await db.refresh(cfg)
return _cfg_to_out(cfg)
@router.get("/config", response_model=Optional[JiraConfigOut])
async def get_jira_config(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""현재 테넌트 Jira 설정 조회 (토큰은 마스킹)."""
row = await db.execute(
select(JiraConfig).where(JiraConfig.tenant_id == user.tenant_id)
)
cfg = row.scalar_one_or_none()
return _cfg_to_out(cfg) if cfg else None
@router.post("/test")
async def test_jira_connection(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Jira 연결 테스트."""
row = await db.execute(
select(JiraConfig).where(JiraConfig.tenant_id == user.tenant_id)
)
cfg = row.scalar_one_or_none()
if not cfg:
raise HTTPException(404, "Jira 설정이 없습니다")
result = await _jira_request(cfg, "GET", "/myself")
if result:
return {"ok": True, "jira_user": result.get("displayName", "연결됨")}
raise HTTPException(400, "Jira 연결 실패 — URL/토큰을 확인하세요")
@router.post("/sync/{sr_id}", response_model=SyncResult)
async def sync_sr_to_jira(
sr_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""SR을 Jira Issue로 동기화 (생성 또는 업데이트)."""
# SR 조회
sr_row = await db.execute(select(SRRequest).where(SRRequest.id == sr_id))
sr = sr_row.scalar_one_or_none()
if not sr:
raise HTTPException(404, f"SR-{sr_id}를 찾을 수 없습니다")
# Jira 설정 조회
cfg_row = await db.execute(
select(JiraConfig).where(JiraConfig.tenant_id == user.tenant_id, JiraConfig.is_active == True)
)
cfg = cfg_row.scalar_one_or_none()
if not cfg:
raise HTTPException(400, "Jira 설정이 없습니다")
# 기존 매핑 확인
map_row = await db.execute(
select(JiraSyncMapping).where(JiraSyncMapping.sr_id == sr_id)
)
mapping = map_row.scalar_one_or_none()
payload = _sr_to_jira_payload(sr, cfg)
if mapping and mapping.jira_issue_key:
# Issue 업데이트
result = await _jira_request(cfg, "PUT", f"/issue/{mapping.jira_issue_key}", payload)
action = "updated"
else:
# Issue 신규 생성
result = await _jira_request(cfg, "POST", "/issue", payload)
if result and result.get("key"):
jira_key = result["key"]
if mapping:
mapping.jira_issue_key = jira_key
mapping.synced_at = datetime.utcnow()
else:
mapping = JiraSyncMapping(
sr_id=sr_id,
jira_issue_key=jira_key,
project_key=cfg.project_key,
config_id=cfg.id,
synced_at=datetime.utcnow(),
)
db.add(mapping)
await db.commit()
action = "created"
cfg.last_synced_at = datetime.utcnow()
await db.commit()
jira_key = mapping.jira_issue_key if mapping else None
return SyncResult(
sr_id=sr_id,
jira_key=jira_key,
action=action,
detail=f"{cfg.base_url}/browse/{jira_key}" if jira_key else None,
)
@router.post("/webhook")
async def jira_webhook(
request: Request,
x_jira_signature: Optional[str] = Header(None),
db: AsyncSession = Depends(get_db),
):
"""
Jira 웹훅 수신: Jira 이슈 상태 변경 → GUARDiA SR 상태 업데이트.
Jira 설정에서 웹훅 URL: https://guardia.example.com/api/jira/webhook
"""
body = await request.body()
payload = json.loads(body)
event = payload.get("webhookEvent", "")
issue = payload.get("issue", {})
issue_key = issue.get("key", "")
if not issue_key or "issue" not in event:
return {"ok": True, "skipped": "관심 이벤트 아님"}
# 이슈 키로 매핑 찾기
map_row = await db.execute(
select(JiraSyncMapping).where(JiraSyncMapping.jira_issue_key == issue_key)
)
mapping = map_row.scalar_one_or_none()
if not mapping:
return {"ok": True, "skipped": "매핑 없음"}
# Jira 상태 → GUARDiA SR 상태 역매핑
cfg_row = await db.execute(
select(JiraConfig).where(JiraConfig.id == mapping.config_id)
)
cfg = cfg_row.scalar_one_or_none()
jira_status = issue.get("fields", {}).get("status", {}).get("name", "")
# 역방향 매핑
status_map = json.loads(cfg.status_mapping) if cfg else DEFAULT_STATUS_MAP
reverse_map = {v: k for k, v in status_map.items()}
sr_status = reverse_map.get(jira_status)
if sr_status:
sr_row = await db.execute(select(SRRequest).where(SRRequest.id == mapping.sr_id))
sr = sr_row.scalar_one_or_none()
if sr and sr.status != sr_status:
sr.status = sr_status
sr.updated_at = datetime.utcnow()
mapping.synced_at = datetime.utcnow()
await db.commit()
logger.info(f"SR-{sr.id} 상태 업데이트: {sr_status} (Jira: {jira_status})")
return {"ok": True}
@router.get("/mappings")
async def list_mappings(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""SR-Jira Issue 매핑 목록."""
rows = await db.execute(
select(JiraSyncMapping).order_by(desc(JiraSyncMapping.synced_at)).limit(100)
)
mappings = rows.scalars().all()
return [
{
"id": m.id,
"sr_id": m.sr_id,
"jira_key": m.jira_issue_key,
"project": m.project_key,
"synced_at": m.synced_at,
}
for m in mappings
]
def _cfg_to_out(cfg: JiraConfig) -> JiraConfigOut:
return JiraConfigOut(
id=cfg.id,
base_url=cfg.base_url,
email=cfg.email,
project_key=cfg.project_key,
status_mapping=json.loads(cfg.status_mapping) if cfg.status_mapping else DEFAULT_STATUS_MAP,
auto_sync=cfg.auto_sync,
is_active=cfg.is_active,
last_synced_at=cfg.last_synced_at,
)