""" 바이브 코딩 세션 관리 API. 워크플로우: POST /api/vibe → 세션 생성 (PENDING) PATCH /api/vibe/{id}/status → 상태 변경 (Claude CLI 세션 ID 업데이트 포함) POST /api/vibe/{id}/build → Jenkins 파이프라인 빌드 트리거 POST /api/vibe/{id}/deploy → Jenkins 파이프라인 배포 트리거 POST /api/vibe/callback → Jenkins 파이프라인 완료 콜백 수신 GET /api/vibe/{id} → 세션 상세 """ from __future__ import annotations import asyncio import logging from datetime import datetime from typing import Any, List, Optional from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from core.cicd import ( abort_build, ping as jenkins_ping, trigger_pipeline, verify_callback_secret, ) from database import get_db from models import ( Project, SRRequest, SRStatus, VibeSession, VibeSessionCreate, VibeSessionOut, VibeSessionUpdate, VibeSessionStatus, User, UserRole, Server, WorkLog, WorkActionType, ) router = APIRouter(prefix="/api/vibe", tags=["vibe"]) logger = logging.getLogger(__name__) # ── 권한 헬퍼 ───────────────────────────────────────────────────────────────── def _require_ops(u: User = Depends(get_current_user)) -> User: if u.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER): raise HTTPException(403, "바이브 세션 권한이 없습니다.") return u # ── 요청/응답 스키마 ────────────────────────────────────────────────────────── class BuildRequest(BaseModel): deploy_env: str = "dev" skip_test: bool = False job_name: Optional[str] = None # 명시 지정 시 사용, 없으면 프로젝트에서 자동 결정 extra_params: Optional[dict[str, Any]] = None class DeployRequest(BaseModel): deploy_env: str = "dev" skip_test: bool = False job_name: Optional[str] = None extra_params: Optional[dict[str, Any]] = None class StatusChangeRequest(BaseModel): status: VibeSessionStatus note: Optional[str] = None claude_session_id: Optional[str] = None class PipelineCallbackRequest(BaseModel): """Jenkins Stage 8: ITSM Callback에서 POST하는 페이로드.""" session_id: str sr_id: Optional[str] = None status: str # COMPLETED | FAILED | UNSTABLE | ROLLED_BACK … stage: Optional[str] = None message: Optional[str] = None build_number: Optional[str] = None build_url: Optional[str] = None deploy_env: Optional[str] = None job_name: Optional[str] = None timestamp: Optional[str] = None logs: Optional[dict[str, str]] = None # 콜백 검증용 시크릿 (X-Jenkins-Secret 헤더로도 전달 가능) secret: Optional[str] = None # ── CRUD ───────────────────────────────────────────────────────────────────── @router.get("", response_model=List[VibeSessionOut]) async def list_sessions( sr_id: Optional[str] = None, status: Optional[str] = None, db: AsyncSession = Depends(get_db), _u: User = Depends(_require_ops), ): q = select(VibeSession).order_by(VibeSession.started_at.desc()) if sr_id: q = q.where(VibeSession.sr_id == sr_id) if status: q = q.where(VibeSession.status == status) r = await db.execute(q.limit(50)) return r.scalars().all() @router.get("/{session_id}", response_model=VibeSessionOut) async def get_session( session_id: int, db: AsyncSession = Depends(get_db), _u: User = Depends(_require_ops), ): r = await db.execute(select(VibeSession).where(VibeSession.id == session_id)) vs = r.scalars().first() if not vs: raise HTTPException(404, "바이브 세션을 찾을 수 없습니다.") return vs @router.post("", response_model=VibeSessionOut, status_code=201) async def create_session( payload: VibeSessionCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(_require_ops), ): """ 바이브 코딩 세션 시작. - sr_id: 연결할 SR - project_id: 프로젝트 (소스 경로, 빌드 명령, 배포 서버 포함) - claude_session_id: Claude CLI 세션 ID (이미 시작한 경우 전달) """ # SR 존재 확인 if payload.sr_id: r = await db.execute( select(SRRequest).where(SRRequest.sr_id == payload.sr_id) ) if not r.scalars().first(): raise HTTPException(404, f"SR을 찾을 수 없습니다: {payload.sr_id}") # 프로젝트 확인 workspace = payload.workspace_path if payload.project_id: r = await db.execute( select(Project).where(Project.id == payload.project_id) ) proj = r.scalars().first() if not proj: raise HTTPException(404, f"프로젝트를 찾을 수 없습니다: {payload.project_id}") if not workspace and proj.source_path: workspace = proj.source_path vs = VibeSession( sr_id=payload.sr_id, project_id=payload.project_id, claude_session_id=payload.claude_session_id, workspace_path=workspace, started_by=payload.started_by or current_user.username, status=VibeSessionStatus.PENDING, ) db.add(vs) await db.commit() await db.refresh(vs) asyncio.create_task(_notify_vibe_started(vs, current_user.username)) return vs @router.patch("/{session_id}/status", response_model=VibeSessionOut) async def update_session_status( session_id: int, payload: StatusChangeRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(_require_ops), ): """ 세션 상태 수동 변경 (CODING → BUILDING 등). Claude CLI 세션 ID도 이 엔드포인트로 업데이트. """ r = await db.execute(select(VibeSession).where(VibeSession.id == session_id)) vs = r.scalars().first() if not vs: raise HTTPException(404, "바이브 세션을 찾을 수 없습니다.") vs.status = payload.status if payload.claude_session_id: vs.claude_session_id = payload.claude_session_id now = datetime.now() if payload.status == VibeSessionStatus.CODING and not vs.started_at: vs.started_at = now elif payload.status == VibeSessionStatus.BUILDING: vs.coded_at = now elif payload.status == VibeSessionStatus.TESTING: vs.built_at = now elif payload.status == VibeSessionStatus.DEPLOYING: vs.tested_at = now elif payload.status == VibeSessionStatus.COMPLETED: vs.deployed_at = now await db.commit() await db.refresh(vs) return vs # ── Jenkins 파이프라인 트리거 ────────────────────────────────────────────────── @router.post("/{session_id}/build", response_model=VibeSessionOut) async def trigger_build( session_id: int, payload: BuildRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(_require_ops), ): """ Jenkins 빌드 + 테스트 파이프라인 트리거. tb_project.jenkins_job_name 또는 payload.job_name 사용. """ vs, proj = await _get_session_with_project(session_id, db) job_name = payload.job_name or getattr(proj, "jenkins_job_name", None) \ or f"{proj.project_name}-build" # 배포 서버 이름 target_server = await _resolve_target_server(proj, db) try: result = await trigger_pipeline( job_name=job_name, session_id=vs.id, sr_id=vs.sr_id or "", deploy_env=payload.deploy_env, target_server=target_server, skip_test=payload.skip_test, extra_params=payload.extra_params, ) except RuntimeError as exc: raise HTTPException(502, f"Jenkins 연동 오류: {exc}") # 상태 업데이트 vs.status = VibeSessionStatus.BUILDING vs.coded_at = datetime.now() # 큐 ID를 build_log에 임시 저장 vs.build_log = ( f"Jenkins 파이프라인 트리거 완료\n" f"Job: {job_name}\n" f"Queue ID: {result.get('queue_id', 'N/A')}\n" f"Queue URL: {result.get('queue_url', 'N/A')}" ) await db.commit() await db.refresh(vs) asyncio.create_task(_notify_build_triggered(vs, job_name, result)) logger.info("빌드 트리거: session=%d job=%s queue=%s", vs.id, job_name, result.get("queue_id")) return vs @router.post("/{session_id}/deploy", response_model=VibeSessionOut) async def trigger_deploy( session_id: int, payload: DeployRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(_require_ops), ): """ Jenkins Full Pipeline 배포 트리거. """ vs, proj = await _get_session_with_project(session_id, db) job_name = payload.job_name or getattr(proj, "jenkins_job_name", None) \ or f"{proj.project_name}-deploy" target_server = await _resolve_target_server(proj, db) extra: dict[str, Any] = {} if proj.health_check_url: extra["HEALTH_CHECK_URL"] = proj.health_check_url if payload.extra_params: extra.update(payload.extra_params) try: result = await trigger_pipeline( job_name=job_name, session_id=vs.id, sr_id=vs.sr_id or "", deploy_env=payload.deploy_env, target_server=target_server, skip_test=payload.skip_test, extra_params=extra, ) except RuntimeError as exc: raise HTTPException(502, f"Jenkins 연동 오류: {exc}") vs.status = VibeSessionStatus.DEPLOYING vs.tested_at = datetime.now() vs.deploy_log = ( f"Jenkins 배포 파이프라인 트리거 완료\n" f"Job: {job_name}\n" f"Environment: {payload.deploy_env}\n" f"Queue ID: {result.get('queue_id', 'N/A')}" ) await db.commit() await db.refresh(vs) asyncio.create_task(_notify_deploy_triggered(vs, job_name, result)) logger.info("배포 트리거: session=%d job=%s env=%s", vs.id, job_name, payload.deploy_env) return vs # ── Jenkins 콜백 수신 ───────────────────────────────────────────────────────── @router.post("/callback") async def pipeline_callback( body: PipelineCallbackRequest, x_jenkins_secret: Optional[str] = Header(None, alias="X-Jenkins-Secret"), db: AsyncSession = Depends(get_db), ): """ Jenkins Stage 8 (ITSM Callback) → 파이프라인 결과 수신. 인증: X-Jenkins-Secret 헤더 또는 body.secret 으로 검증. """ # 시크릿 검증 received_secret = x_jenkins_secret or body.secret or "" if not verify_callback_secret(received_secret): raise HTTPException(401, "콜백 인증 실패") # 세션 조회 try: vs_id = int(body.session_id) except ValueError: raise HTTPException(400, "유효하지 않은 session_id") r = await db.execute(select(VibeSession).where(VibeSession.id == vs_id)) vs = r.scalars().first() if not vs: raise HTTPException(404, f"바이브 세션을 찾을 수 없습니다: {body.session_id}") # 상태 매핑 status_map = { "BUILDING": VibeSessionStatus.BUILDING, "TESTING": VibeSessionStatus.TESTING, "DEPLOYING": VibeSessionStatus.DEPLOYING, "COMPLETED": VibeSessionStatus.COMPLETED, "FAILED": VibeSessionStatus.FAILED, "UNSTABLE": VibeSessionStatus.FAILED, "PENDING_APPROVAL": VibeSessionStatus.BUILDING, "ROLLED_BACK": VibeSessionStatus.FAILED, "CANCELLED": VibeSessionStatus.CANCELLED, } new_status = status_map.get(body.status.upper(), VibeSessionStatus.FAILED) vs.status = new_status now = datetime.now() if body.logs: if body.logs.get("build"): vs.build_log = body.logs["build"][:2000] vs.built_at = now if body.logs.get("test"): vs.test_result = body.logs["test"][:500] vs.tested_at = now if body.logs.get("deploy"): vs.deploy_log = body.logs["deploy"][:2000] vs.deployed_at = now if new_status == VibeSessionStatus.FAILED: vs.error_msg = body.message or "파이프라인 실패" is_success = new_status == VibeSessionStatus.COMPLETED # ── SR COMPLETED 처리 ───────────────────────────────────── sr_notifications = [] if is_success and vs.sr_id: rs = await db.execute( select(SRRequest).where(SRRequest.sr_id == vs.sr_id) ) sr = rs.scalars().first() if sr and sr.status not in (SRStatus.COMPLETED, SRStatus.FAILED_ROLLBACK): sr.status = SRStatus.COMPLETED sr.updated_at = now final_msg = ( body.message or f"CI/CD 배포 완료 (빌드 #{body.build_number or 'N/A'})" ) db.add(WorkLog( sr_id=vs.sr_id, engineer="jenkins-bot", action_type=WorkActionType.COMPLETE, content="CI/CD 파이프라인 배포 완료", result=final_msg, is_success=True, )) sr_notifications.append((sr, final_msg)) await db.commit() # 알림 (비동기) for sr_obj, msg in sr_notifications: asyncio.create_task(_notify_sr_done(sr_obj, msg)) asyncio.create_task(_notify_pipeline_result(vs, body, is_success)) # A-3: 배포 완료/실패 통합 알림 asyncio.create_task(_a3_notify_deploy_completed( vs, is_success, summary=body.message or f"빌드 #{body.build_number or 'N/A'}", )) logger.info( "Jenkins 콜백 수신: session=%d status=%s build=#%s", vs.id, body.status, body.build_number or "N/A", ) return { "ok": True, "sr_id": vs.sr_id, "sr_status": SRStatus.COMPLETED if is_success else "UNCHANGED", "notified": ["messenger"], } # ── Jenkins 연결 상태 확인 ────────────────────────────────────────────────────── @router.get("/jenkins/health") async def jenkins_health(_u: User = Depends(_require_ops)): """Jenkins 서버 연결 상태 확인.""" result = await jenkins_ping() if not result.get("ok"): raise HTTPException(503, f"Jenkins 연결 실패: {result.get('error', '알 수 없는 오류')}") return result # ── 내부 헬퍼 ──────────────────────────────────────────────────────────────── async def _get_session_with_project( session_id: int, db: AsyncSession, ) -> tuple[VibeSession, Project]: r = await db.execute(select(VibeSession).where(VibeSession.id == session_id)) vs = r.scalars().first() if not vs: raise HTTPException(404, "바이브 세션을 찾을 수 없습니다.") if not vs.project_id: raise HTTPException(400, "프로젝트가 연결되지 않은 세션입니다.") rp = await db.execute(select(Project).where(Project.id == vs.project_id)) proj = rp.scalars().first() if not proj: raise HTTPException(400, "프로젝트를 찾을 수 없습니다.") return vs, proj async def _resolve_target_server(proj: Project, db: AsyncSession) -> str: """프로젝트의 배포 서버 이름을 반환.""" if not proj.deploy_server_id: return "" rs = await db.execute( select(Server).where(Server.id == proj.deploy_server_id) ) srv = rs.scalars().first() return srv.server_name if srv else "" # ── 알림 헬퍼 ───────────────────────────────────────────────────────────────── async def _notify_vibe_started(vs: VibeSession, actor: str): try: import httpx, os webhook = os.environ.get("MESSENGER_WEBHOOK", "") if not webhook: return await _post_webhook(webhook, { "event": "vibe_started", "room": os.environ.get("NOTIFY_ROOM", "ops"), "session_id": vs.id, "sr_id": vs.sr_id or "—", "actor": actor, "workspace": vs.workspace_path or "—", }) except Exception: pass async def _notify_build_triggered(vs: VibeSession, job_name: str, result: dict): try: import os webhook = os.environ.get("MESSENGER_WEBHOOK", "") if not webhook: return await _post_webhook(webhook, { "event": "build_triggered", "room": os.environ.get("NOTIFY_ROOM", "ops"), "session_id": vs.id, "sr_id": vs.sr_id or "—", "job_name": job_name, "queue_id": result.get("queue_id"), }) except Exception: pass async def _notify_deploy_triggered(vs: VibeSession, job_name: str, result: dict): try: import os webhook = os.environ.get("MESSENGER_WEBHOOK", "") if not webhook: return await _post_webhook(webhook, { "event": "deploy_triggered", "room": os.environ.get("NOTIFY_ROOM", "ops"), "session_id": vs.id, "sr_id": vs.sr_id or "—", "job_name": job_name, "queue_id": result.get("queue_id"), }) except Exception: pass async def _notify_pipeline_result( vs: VibeSession, body: PipelineCallbackRequest, success: bool, ): try: import os webhook = os.environ.get("MESSENGER_WEBHOOK", "") if not webhook: return await _post_webhook(webhook, { "event": "pipeline_result", "room": os.environ.get("NOTIFY_ROOM", "ops"), "session_id": vs.id, "sr_id": vs.sr_id or "—", "success": success, "status": body.status, "message": body.message or "", "build_number": body.build_number or "", "build_url": body.build_url or "", "deploy_env": body.deploy_env or "", }) except Exception: pass async def _notify_sr_done(sr: SRRequest, final_msg: str): try: from core.notify import notify_sr_status_changed await notify_sr_status_changed(sr, SRStatus.COMPLETED, final_msg) except Exception: pass async def _post_webhook(url: str, payload: dict): import httpx async with httpx.AsyncClient(timeout=5.0) as client: await client.post(url, json=payload) # ── Priority 5: Jenkins 운영 배포 승인 연동 ────────────────────────────────── class ApprovalRequestBody(BaseModel): environment: str = "prd" build_number: Optional[str] = None @router.post("/{session_id}/request-approval") async def request_approval( session_id: int, body: ApprovalRequestBody, db: AsyncSession = Depends(get_db), ): """ Jenkins Pipeline → ITSM 승인 요청 생성. PRD 배포 시 Jenkins가 이 엔드포인트를 호출하여 PM 승인을 요청한다. 인증: ITSM_TOKEN (Jenkins에서 Bearer 토큰으로 전달) """ r = await db.execute(select(VibeSession).where(VibeSession.id == session_id)) vs = r.scalars().first() if not vs: raise HTTPException(404, f"세션 없음: {session_id}") # 승인 요청 상태로 전환 vs.status = VibeSessionStatus.BUILDING # 승인 대기 중 vs.error_msg = None # build_number를 deploy_log에 보관 vs.deploy_log = ( f"PRD 배포 승인 대기 중\n" f"환경: {body.environment}\n" f"빌드: #{body.build_number or 'N/A'}" ) await db.commit() # PM에게 메신저 알림 (기존) asyncio.create_task(_notify_approval_requested(vs, body.environment, body.build_number)) # A-3: 승인 필요 통합 알림 (이메일 + 메신저) asyncio.create_task(_a3_notify_approval_required(vs)) logger.info("PRD 배포 승인 요청: session=%d env=%s", session_id, body.environment) return {"ok": True, "status": "PENDING", "session_id": session_id} @router.get("/{session_id}/approval-status") async def get_approval_status( session_id: int, db: AsyncSession = Depends(get_db), ): """ Jenkins Pipeline polling → 승인 상태 반환. Jenkins `waitUntil` 블록이 이 엔드포인트를 폴링한다. Returns: "PENDING" | "APPROVED" | "REJECTED" """ r = await db.execute(select(VibeSession).where(VibeSession.id == session_id)) vs = r.scalars().first() if not vs: raise HTTPException(404, f"세션 없음: {session_id}") if vs.status == VibeSessionStatus.DEPLOYING: return "APPROVED" elif vs.status in (VibeSessionStatus.FAILED, VibeSessionStatus.CANCELLED): return "REJECTED" return "PENDING" @router.patch("/{session_id}/approve") async def approve_deploy( session_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ PM이 ITSM UI에서 PRD 배포를 승인한다. 승인되면 approval-status 폴링이 "APPROVED"를 반환하여 Jenkins 파이프라인이 재개된다. """ if current_user.role not in (UserRole.ADMIN, UserRole.PM): raise HTTPException(403, "PM 또는 ADMIN 권한이 필요합니다.") r = await db.execute(select(VibeSession).where(VibeSession.id == session_id)) vs = r.scalars().first() if not vs: raise HTTPException(404, f"세션 없음: {session_id}") vs.status = VibeSessionStatus.DEPLOYING vs.tested_at = datetime.now() await db.commit() await db.refresh(vs) logger.info("PRD 배포 승인: session=%d by=%s", session_id, current_user.username) asyncio.create_task(_notify_approval_result(vs, True, current_user.username)) return {"ok": True, "status": "APPROVED", "session_id": session_id} @router.patch("/{session_id}/reject") async def reject_deploy( session_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """PM이 PRD 배포를 반려한다.""" if current_user.role not in (UserRole.ADMIN, UserRole.PM): raise HTTPException(403, "PM 또는 ADMIN 권한이 필요합니다.") r = await db.execute(select(VibeSession).where(VibeSession.id == session_id)) vs = r.scalars().first() if not vs: raise HTTPException(404, f"세션 없음: {session_id}") vs.status = VibeSessionStatus.FAILED vs.error_msg = f"PRD 배포 반려 by {current_user.username}" await db.commit() await db.refresh(vs) logger.info("PRD 배포 반려: session=%d by=%s", session_id, current_user.username) asyncio.create_task(_notify_approval_result(vs, False, current_user.username)) return {"ok": True, "status": "REJECTED", "session_id": session_id} # ── Priority 5: SonarQube Quality Gate 결과 수신 ───────────────────────────── class SonarResultBody(BaseModel): session_id: int project_key: str @router.post("/sonar-result") async def receive_sonar_result( body: SonarResultBody, db: AsyncSession = Depends(get_db), ): """ Jenkins Quality Gate 단계에서 SonarQube 분석 결과를 ITSM에 전달한다. Quality Gate 실패(ERROR) 시 SR을 자동 생성한다. """ from core.cicd import get_sonarqube_result, handle_sonar_gate_failure try: result = await get_sonarqube_result(body.project_key) except Exception as exc: logger.warning("SonarQube 결과 조회 실패: %s", exc) return {"ok": False, "error": str(exc)} # 세션에 소나큐브 결과 기록 r = await db.execute(select(VibeSession).where(VibeSession.id == body.session_id)) vs = r.scalars().first() if vs: import json vs.test_result = json.dumps({ "sonar_status": result.status, "coverage": result.coverage, "bugs": result.bugs, "vulnerabilities": result.vulnerabilities, "code_smells": result.code_smells, }, ensure_ascii=False) vs.tested_at = datetime.now() await db.commit() # Quality Gate 실패 → SR 자동 생성 if result.status == "ERROR": await handle_sonar_gate_failure(body.session_id, result, db) return { "ok": True, "status": result.status, "coverage": result.coverage, "bugs": result.bugs, } # ── Priority 5: 빌드 로그 SSE 스트리밍 ─────────────────────────────────────── @router.get("/{session_id}/build/stream") async def stream_build_log( session_id: int, _u: User = Depends(_require_ops), ): """ Jenkins 빌드 로그를 실시간 SSE로 스트리밍한다. Jenkins Progressive Log API를 폴링하여 청크 단위로 전달한다. """ from fastapi.responses import StreamingResponse from core.cicd import get_progressive_log, is_build_complete import json async def _generator(): offset = 0 max_iter = 300 # 최대 10분 (2초 간격) for _ in range(max_iter): try: log_chunk, next_offset = await get_progressive_log(session_id, offset) if log_chunk: yield f"data: {json.dumps({'chunk': log_chunk, 'offset': next_offset})}\n\n" offset = next_offset done = await is_build_complete(session_id) if done: yield f"data: {json.dumps({'done': True})}\n\n" break except Exception as exc: yield f"data: {json.dumps({'error': str(exc)})}\n\n" break await asyncio.sleep(2) return StreamingResponse(_generator(), media_type="text/event-stream") # ── G-6: 배포 영향 분석 ─────────────────────────────────────────────────────── @router.post("/{session_id}/impact-analysis") async def deploy_impact_analysis( session_id: int, db: AsyncSession = Depends(get_db), _u: User = Depends(_require_ops), ): """바이브 세션의 대상 서버 CI 의존성 분석으로 배포 영향 범위 파악.""" from models import VibeSession, ConfigItem, Server from sqlalchemy import select vs = await db.get(VibeSession, session_id) if not vs: raise HTTPException(404, "세션을 찾을 수 없습니다.") # 대상 서버를 통해 CI 목록 수집 ci_id = None if vs.target_server: srv = (await db.execute( select(Server).where(Server.server_name == vs.target_server) )).scalars().first() if srv: ci = (await db.execute( select(ConfigItem).where(ConfigItem.linked_server_id == srv.id) )).scalars().first() if ci: ci_id = ci.id if not ci_id: return { "session_id": session_id, "message": "연결된 CI 정보가 없습니다. CMDB에서 서버와 CI를 연결하세요.", "risk_level": "UNKNOWN", "affected_cis": [], "affected_institutions": [], } try: from core.deploy_impact import analyze_deploy_impact result = await analyze_deploy_impact(ci_id, db) result["session_id"] = session_id return result except Exception as e: raise HTTPException(500, f"영향 분석 오류: {str(e)[:200]}") # ── 승인 알림 헬퍼 ──────────────────────────────────────────────────────────── async def _notify_approval_requested(vs: VibeSession, env: str, build_number: Optional[str]): try: import os from core.notify import send_messenger room = os.getenv("MESSENGER_PM_ROOM", "pm") await send_messenger(room, { "type": "text", "text": ( f"🚀 [PRD 배포 승인 요청]\n" f"세션: {vs.id} | SR: {vs.sr_id or '—'}\n" f"환경: {env} | 빌드: #{build_number or 'N/A'}\n" f"ITSM에서 승인 또는 반려해주세요." ), }) except Exception: pass async def _notify_approval_result(vs: VibeSession, approved: bool, approver: str): try: import os from core.notify import send_messenger icon = "✅" if approved else "❌" room = os.getenv("MESSENGER_OPS_ROOM", "ops") await send_messenger(room, { "type": "text", "text": ( f"{icon} [PRD 배포 {'승인' if approved else '반려'}]\n" f"세션: {vs.id} | SR: {vs.sr_id or '—'}\n" f"처리자: {approver}" ), }) except Exception: pass # ── A-3: 배포 승인/완료 통합 알림 헬퍼 ────────────────────────────────────── async def _a3_notify_approval_required(vs: VibeSession) -> None: """ A-3: 배포 승인 필요 시 이메일 + 메신저 통합 알림. PM / ADMIN 역할 사용자에게 승인 요청 발송. """ try: import os from core.notify import notify_deploy_approval_required from database import SessionLocal from sqlalchemy import select from models import User, UserRole # 승인 담당자: PM + ADMIN 역할 사용자 조회 async with SessionLocal() as db: result = await db.execute( select(User.username).where( User.role.in_([UserRole.PM, UserRole.ADMIN]), User.is_active == True, ) ) approvers = [row[0] for row in result.all()] if not approvers: approvers = ["admin"] project_name = f"세션 #{vs.id}" if vs.project_id: try: async with SessionLocal() as db: from models import Project pr = await db.execute( select(Project.project_name).where(Project.id == vs.project_id) ) row = pr.first() if row: project_name = row[0] except Exception: pass base_url = os.getenv("ITSM_BASE_URL", "http://localhost:8000") approve_url = f"{base_url}/vibe?session={vs.id}&action=approve" await notify_deploy_approval_required( session_id=vs.id, sr_id=vs.sr_id, project_name=project_name, approvers=approvers, approve_url=approve_url, ) except Exception as exc: logger.debug("A-3 승인 알림 오류: %s", exc) async def _a3_notify_deploy_completed(vs: VibeSession, success: bool, summary: str = "") -> None: """ A-3: 배포 완료/실패 시 운영팀 통합 알림. Jenkins 콜백 수신 후 호출. """ try: from core.notify import notify_deploy_completed project_name = f"세션 #{vs.id}" if vs.project_id: try: from database import SessionLocal from sqlalchemy import select from models import Project async with SessionLocal() as db: pr = await db.execute( select(Project.project_name).where(Project.id == vs.project_id) ) row = pr.first() if row: project_name = row[0] except Exception: pass await notify_deploy_completed( session_id=vs.id, sr_id=vs.sr_id, project_name=project_name, success=success, summary=summary or ("배포 성공" if success else "배포 실패"), ) except Exception as exc: logger.debug("A-3 배포완료 알림 오류: %s", exc)