라우터 (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>
376 lines
13 KiB
Python
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,
|
|
)
|