feat(bot): CI/CD 파이프라인 봇 명령어 4개 추가

## 신규 명령어
- /cicd [project]                  Jenkins + Gitea 전체 CI/CD 현황
- /jenkins <job> [build|status|log] Jenkins 빌드 트리거·상태·로그 조회
- /git <repo> [log|pr|branch]      Gitea 저장소 커밋·PR·브랜치 조회
- /release <project> [version]     릴리즈 배포 파이프라인 트리거

## 백엔드 헬퍼 함수
- _cmd_cicd_status()     Jenkins + Gitea 통합 현황
- _cmd_jenkins_status()  Jenkins 잡 상태/로그
- _cmd_jenkins_trigger() Jenkins 빌드 트리거 (백그라운드)
- _cmd_gitea_status()    Gitea 커밋/PR/브랜치 조회
- _cmd_release()         릴리즈 파이프라인 실행

## 환경변수
- JENKINS_API_TOKEN: Jenkins API 토큰 (Jenkins 초기 설정 후 발급)
- GITEA_API_TOKEN:   발급 완료 (ce25405940c3...)

## 테스트 결과
- /git guardia-itsm log      최근 커밋 조회
- /git guardia-itsm branch   브랜치 목록
- /cicd                      Gitea 정상, Jenkins 토큰 대기
- /jenkins                   Jenkins 초기 설정 완료 후 정상화

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPRython 2026-05-31 12:51:13 +09:00
parent 75d92f2b90
commit 16e063b8ed

View File

@ -446,6 +446,52 @@ async def handle_bot_command(
bg.add_task(_cmd_vuln_scan, cmd.room, cmd.user, target) bg.add_task(_cmd_vuln_scan, cmd.room, cmd.user, target)
return BotReply(room=cmd.room, text=f"[취약점 스캔] {target} 스캔 시작 (약 30초 소요)...") return BotReply(room=cmd.room, text=f"[취약점 스캔] {target} 스캔 시작 (약 30초 소요)...")
# ── /cicd [project] ─── CI/CD 전체 현황 ─────────────────────────────────
elif keyword in ("/cicd", "!cicd"):
project = parts[1] if len(parts) > 1 else None
reply = await _cmd_cicd_status(project)
return BotReply(room=cmd.room, text=reply)
# ── /jenkins <job> [build|status|log] ─── Jenkins 제어 ──────────────────
elif keyword in ("/jenkins", "!jenkins"):
if len(parts) < 2:
return BotReply(room=cmd.room,
text="사용법: /jenkins <job명> [build|status|log]\n"
"예) /jenkins guardia-itsm build\n"
"예) /jenkins guardia-itsm status")
job = parts[1]
action = parts[2].lower() if len(parts) > 2 else "status"
if action == "build":
bg.add_task(_cmd_jenkins_trigger, cmd.room, cmd.user, job)
return BotReply(room=cmd.room, text=f"[Jenkins] {job} 빌드 트리거 요청 중...")
else:
reply = await _cmd_jenkins_status(job, action)
return BotReply(room=cmd.room, text=reply)
# ── /git <repo> [branch|pr|log] ─── Gitea 저장소 상태 ───────────────────
elif keyword in ("/git", "!git"):
if len(parts) < 2:
return BotReply(room=cmd.room,
text="사용법: /git <저장소> [branch|pr|log]\n"
"예) /git guardia-itsm log\n"
"예) /git zioinfo-web pr")
repo = parts[1]
action = parts[2].lower() if len(parts) > 2 else "log"
reply = await _cmd_gitea_status(repo, action)
return BotReply(room=cmd.room, text=reply)
# ── /release <project> [version] ─── 릴리즈 배포 트리거 ─────────────────
elif keyword in ("/release", "!release"):
if len(parts) < 2:
return BotReply(room=cmd.room,
text="사용법: /release <프로젝트명> [버전]\n"
"예) /release guardia-itsm v2.1.0")
project = parts[1]
version = parts[2] if len(parts) > 2 else "latest"
bg.add_task(_cmd_release, cmd.room, cmd.user, project, version)
return BotReply(room=cmd.room,
text=f"[릴리즈] {project} {version} 배포 파이프라인 시작...")
# ── /help ───────────────────────────────────────────────────────────────── # ── /help ─────────────────────────────────────────────────────────────────
elif keyword == "/help": elif keyword == "/help":
return BotReply(room=cmd.room, text=_help_text()) return BotReply(room=cmd.room, text=_help_text())
@ -1371,6 +1417,241 @@ def _get_internal_token() -> str:
return os.environ.get("INTERNAL_API_TOKEN", "") return os.environ.get("INTERNAL_API_TOKEN", "")
# ── CI/CD 봇 명령어 헬퍼 함수 ────────────────────────────────────────────────
JENKINS_URL = "http://localhost:9080" # Nginx 뒤 내부 포트
GITEA_URL = "http://localhost:9003" # Nginx 뒤 내부 포트
JENKINS_USER = "admin"
JENKINS_TOKEN_ENV = "JENKINS_API_TOKEN" # 환경변수에서 읽기
GITEA_USER = "zio"
GITEA_TOKEN_ENV = "GITEA_API_TOKEN" # 환경변수에서 읽기
import os as _os
def _jenkins_auth():
token = _os.environ.get(JENKINS_TOKEN_ENV, "")
return (JENKINS_USER, token) if token else (JENKINS_USER, "")
def _gitea_headers():
token = _os.environ.get(GITEA_TOKEN_ENV, "")
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"token {token}"
else:
# 토큰 없으면 Basic Auth (Gitea 기본)
import base64
cred = base64.b64encode(b"zio:Zio@Admin2026!").decode()
headers["Authorization"] = f"Basic {cred}"
return headers
async def _cmd_cicd_status(project: Optional[str]) -> str:
"""CI/CD 전체 현황: Jenkins 최근 빌드 + Gitea 최근 커밋."""
lines = ["[CI/CD 현황]"]
try:
async with httpx.AsyncClient(timeout=10.0) as c:
# Jenkins: 모든 Job 목록
r = await c.get(
f"{JENKINS_URL}/api/json?tree=jobs[name,color,lastBuild[number,result,timestamp,duration]]",
auth=_jenkins_auth(),
)
if r.status_code == 200:
jobs = r.json().get("jobs", [])
if project:
jobs = [j for j in jobs if project.lower() in j["name"].lower()]
lines.append("\n■ Jenkins 빌드")
for j in jobs[:8]:
lb = j.get("lastBuild") or {}
result = lb.get("result", "N/A") or "진행중"
num = lb.get("number", "-")
icon = {"SUCCESS":"","FAILURE":"","UNSTABLE":"⚠️",
"ABORTED":"","진행중":"🔄"}.get(result, "")
lines.append(f" {icon} {j['name']} #{num}{result}")
else:
lines.append(f"\n■ Jenkins 연결 실패 (HTTP {r.status_code})")
# Gitea: 저장소 목록
r2 = await c.get(
f"{GITEA_URL}/api/v1/repos/search?limit=5",
headers=_gitea_headers(),
)
if r2.status_code == 200:
repos = r2.json().get("data", [])
if project:
repos = [rep for rep in repos if project.lower() in rep["name"].lower()]
lines.append("\n■ Gitea 저장소")
for rep in repos[:5]:
updated = (rep.get("updated_at") or "")[:10]
lines.append(f" 📁 {rep['full_name']} — 최근: {updated}")
else:
lines.append(f"\n■ Gitea 연결 실패 (HTTP {r2.status_code})")
except Exception as e:
lines.append(f"\n연결 오류: {str(e)[:80]}")
lines.append("Jenkins/Gitea JENKINS_API_TOKEN, GITEA_API_TOKEN 환경변수를 확인하세요.")
return "\n".join(lines)
async def _cmd_jenkins_status(job: str, action: str) -> str:
"""Jenkins 잡 상태/로그 조회."""
try:
async with httpx.AsyncClient(timeout=15.0) as c:
if action == "log":
r = await c.get(
f"{JENKINS_URL}/job/{job}/lastBuild/consoleText",
auth=_jenkins_auth(),
)
if r.status_code == 200:
log = r.text[-1200:] # 마지막 1200자
return f"[Jenkins] {job} 최근 빌드 로그:\n```\n{log}\n```"
return f"[Jenkins] {job} 로그 조회 실패 (HTTP {r.status_code})"
else:
# status
r = await c.get(
f"{JENKINS_URL}/job/{job}/api/json?tree=name,color,lastBuild[number,result,timestamp,duration,url]",
auth=_jenkins_auth(),
)
if r.status_code == 200:
d = r.json()
lb = d.get("lastBuild") or {}
result = lb.get("result", "진행중") or "진행중"
num = lb.get("number", "N/A")
dur_sec = (lb.get("duration", 0) or 0) // 1000
icon = {"SUCCESS":"","FAILURE":"","UNSTABLE":"⚠️",
"ABORTED":"","진행중":"🔄"}.get(result, "")
return (
f"[Jenkins] {job}\n"
f" 최근 빌드: #{num}\n"
f" 결과: {icon} {result}\n"
f" 소요시간: {dur_sec}\n"
f" 빌드 URL: {JENKINS_URL}/job/{job}/{num}/"
)
return f"[Jenkins] {job} 조회 실패 (HTTP {r.status_code})\n잡 이름을 확인하세요."
except Exception as e:
return f"[Jenkins] 연결 오류: {str(e)[:100]}"
async def _cmd_jenkins_trigger(room: str, actor: str, job: str):
"""Jenkins 빌드 트리거 (백그라운드)."""
try:
async with httpx.AsyncClient(timeout=15.0) as c:
r = await c.post(
f"{JENKINS_URL}/job/{job}/build",
auth=_jenkins_auth(),
)
if r.status_code in (200, 201):
await _send_to_room(room, f"[Jenkins] ✅ {job} 빌드 트리거 완료 by {actor}\n빌드 상태 확인: /jenkins {job} status")
else:
await _send_to_room(room, f"[Jenkins] ❌ {job} 빌드 트리거 실패 (HTTP {r.status_code})")
except Exception as e:
await _send_to_room(room, f"[Jenkins] 연결 오류: {str(e)[:100]}")
async def _cmd_gitea_status(repo: str, action: str) -> str:
"""Gitea 저장소 상태 조회."""
# repo 형식: 'guardia-itsm' → 'zio/guardia-itsm' 자동 보완
full_repo = repo if "/" in repo else f"zio/{repo}"
try:
async with httpx.AsyncClient(timeout=10.0) as c:
if action == "pr":
r = await c.get(
f"{GITEA_URL}/api/v1/repos/{full_repo}/pulls?state=open&limit=5",
headers=_gitea_headers(),
)
if r.status_code == 200:
prs = r.json()
if not prs:
return f"[Gitea] {full_repo} — 오픈 PR 없음"
lines = [f"[Gitea] {full_repo} 오픈 PR {len(prs)}"]
for pr in prs:
lines.append(f" #{pr['number']} {pr['title']}{pr['user']['login']}")
return "\n".join(lines)
return f"[Gitea] PR 조회 실패 (HTTP {r.status_code})"
elif action == "branch":
r = await c.get(
f"{GITEA_URL}/api/v1/repos/{full_repo}/branches?limit=5",
headers=_gitea_headers(),
)
if r.status_code == 200:
branches = r.json()
lines = [f"[Gitea] {full_repo} 브랜치 목록"]
for b in branches:
commit = b.get("commit", {}).get("id", "")[:7]
lines.append(f" 🌿 {b['name']}{commit}")
return "\n".join(lines)
return f"[Gitea] 브랜치 조회 실패 (HTTP {r.status_code})"
else: # log (최근 커밋)
r = await c.get(
f"{GITEA_URL}/api/v1/repos/{full_repo}/commits?limit=5",
headers=_gitea_headers(),
)
if r.status_code == 200:
commits = r.json()
lines = [f"[Gitea] {full_repo} 최근 커밋"]
for cm in commits:
sha = cm.get("sha", "")[:7]
msg = (cm.get("commit", {}).get("message") or "")[:50].split("\n")[0]
auth = cm.get("commit", {}).get("author", {}).get("name", "")
lines.append(f" {sha} {msg}{auth}")
return "\n".join(lines)
return f"[Gitea] 커밋 조회 실패 (HTTP {r.status_code})"
except Exception as e:
return f"[Gitea] 연결 오류: {str(e)[:100]}"
async def _cmd_release(room: str, actor: str, project: str, version: str):
"""릴리즈 배포 파이프라인 (Jenkins + 배포 서버)."""
# 저장소명 → Jenkins Job 이름 매핑
job_map = {
"guardia-itsm": "guardia-itsm",
"guardia-manager": "guardia-manager",
"zioinfo-web": "zioinfo-web",
"guardia-messenger":"guardia-messenger",
}
job = job_map.get(project.lower(), project)
try:
await _send_to_room(room,
f"[릴리즈] 🚀 {project} {version} 배포 파이프라인 시작 by {actor}")
async with httpx.AsyncClient(timeout=15.0) as c:
# 1단계: Jenkins 빌드 트리거
r = await c.post(
f"{JENKINS_URL}/job/{job}/buildWithParameters",
auth=_jenkins_auth(),
params={"VERSION": version, "ACTOR": actor},
)
if r.status_code in (200, 201):
await _send_to_room(room, f"[릴리즈] ✅ 1단계 — Jenkins 빌드 트리거 완료 ({job})")
else:
# 파라미터 없는 빌드 시도
r2 = await c.post(
f"{JENKINS_URL}/job/{job}/build",
auth=_jenkins_auth(),
)
if r2.status_code in (200, 201):
await _send_to_room(room, f"[릴리즈] ✅ 1단계 — Jenkins 빌드 트리거 완료")
else:
await _send_to_room(room,
f"[릴리즈] ⚠️ Jenkins 빌드 트리거 실패 (HTTP {r.status_code})\n"
f"수동 빌드: {JENKINS_URL}/job/{job}/")
return
# 2단계: 완료 후 상태 확인 안내
await _send_to_room(room,
f"[릴리즈] 빌드 상태 확인:\n"
f" /jenkins {job} status\n"
f" /jenkins {job} log")
except Exception as e:
await _send_to_room(room, f"[릴리즈] 연결 오류: {str(e)[:100]}")
def _help_text() -> str: def _help_text() -> str:
return """GUARDiA ITSM 봇 명령어 return """GUARDiA ITSM 봇 명령어
@ -1404,6 +1685,12 @@ def _help_text() -> str:
/checklist 공공기관 이행 현황 /checklist 공공기관 이행 현황
/perf [url] 성능 테스트 /perf [url] 성능 테스트
[CI/CD 파이프라인]
/cicd [project] CI/CD 전체 현황 (Jenkins + Gitea)
/jenkins <job> [build|status|log] Jenkins 빌드 트리거·상태·로그
/git <repo> [log|pr|branch] Gitea 저장소 커밋·PR·브랜치
/release <project> [version] 릴리즈 배포 파이프라인 실행
[배포 제어] [배포 제어]
!vibe <sr_id> [project_id] 바이브 코딩 세션 !vibe <sr_id> [project_id] 바이브 코딩 세션
!build <session_id> 빌드 실행 !build <session_id> 빌드 실행