Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,6 @@ jobs:
push: false
tags: ${{ env.IMAGE }}

- name: Verify MCP OAuth runtime
run: >-
docker run --rm "$IMAGE" python -c
'from mcp.client.auth.oauth2 import OAuthClientProvider'

- name: Smoke-test image
shell: bash
run: |
Expand Down Expand Up @@ -125,11 +120,6 @@ jobs:
push: false
tags: ${{ env.IMAGE }}

- name: Verify MCP OAuth runtime
run: >-
docker run --rm "$IMAGE" python -c
'from mcp.client.auth.oauth2 import OAuthClientProvider'

- name: Smoke-test image
shell: bash
run: |
Expand Down
4 changes: 1 addition & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ FROM python:3.11.15-slim@sha256:db3ff2e1800a8581e2c48a27c3995339d47bdf046da21c76
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
HOME=/home/cowork \
HERMES_HOME=/data/hermes \
HERMES_PYTHON=/usr/local/bin/python \
COWORK_DATABASE_PATH=/data/cowork.db

WORKDIR /app
Expand All @@ -17,7 +15,7 @@ COPY pyproject.toml README.md ./
COPY app ./app

RUN python -m pip install --no-cache-dir --disable-pip-version-check \
"hermes-agent[mcp]==0.19.0" .
.

USER 1000:1000

Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 할 일 올리기

팀원이 자연어로 할 일만 적으면 서버 agent가 Jira MCP를 통해 `S15P11A705` 프로젝트에 Task를 생성하는 단일 목적 프로토타입입니다.
팀원이 자연어로 할 일을 적으면 SSAFY GMS AI API로 Task 초안을 만들고 Jira REST API를 통해 `S15P11A705` 프로젝트에 Task를 생성하는 서비스입니다.

## 범위

Expand All @@ -17,8 +17,9 @@

- FastAPI + vanilla HTML/CSS/JS
- private SQLite: 사용자, 세션, 생성 요청 원문, 생성 결과
- Hermes planner: 자연어를 평평한 Task 목록으로 정리
- direct Atlassian MCP bridge: 허용된 `createJiraIssue` 하나만 호출
- SSAFY GMS Anthropic Messages API: 자연어를 평평한 Task 목록으로 정리
- Jira REST API: 활성 Sprint 조회와 확인된 Task 생성
- 제한된 AI/Jira worker pool: 여러 팀원의 요청을 병렬 처리하고 초과 요청은 대기
- Mattermost Incoming Webhook: Jira/agent 실패 원문과 입력 원문을 리드에게 전달

화면은 서버의 과거 생성 내역을 조회하지 않습니다. SQLite 기록은 입력 유실 방지와 장애 조사만을 위한 내부 데이터입니다.
Expand All @@ -32,11 +33,11 @@ cp .env.example .env
chmod 600 .env
```

`.env`에서 실제 `MATTERMOST_WEBHOOK_URL`을 설정합니다. production에서는 Mattermost webhook URL과 secure cookie가 없으면 앱이 시작되지 않습니다. Jira 인증은 `cowork` 전용 OS 계정의 `atlassian` Hermes MCP OAuth를 사용합니다. root의 Hermes profile을 서비스에 노출하지 않습니다.
`.env`에서 `GMS_KEY`, `JIRA_EMAIL`, `JIRA_API_TOKEN`, `MATTERMOST_WEBHOOK_URL`을 설정합니다. production에서는 이 값들과 secure cookie가 없으면 앱이 시작되지 않습니다. API 키와 Token은 저장소나 이미지에 포함하지 않고 Kubernetes Secret으로 주입합니다.

## 사용자 등록

비밀번호와 Jira accountId는 repository나 shell argument에 넣지 않습니다. Jira accountId는 표시 이름으로 MCP에서 정확히 조회하고, 비밀번호는 TTY에서 두 번 입력합니다.
비밀번호와 Jira accountId는 repository나 shell argument에 넣지 않습니다. Jira accountId는 Jira에서 정확히 확인하고, 비밀번호는 TTY에서 두 번 입력합니다.

```bash
set -a; . ./.env; set +a
Expand All @@ -58,8 +59,8 @@ TLS reverse proxy 뒤에서 운영합니다. `COWORK_COOKIE_SECURE=true`를 유

## 컨테이너와 k3s 배포

`Dockerfile`은 Python과 Hermes Agent 버전을 고정하고 UID/GID 1000의 non-root
사용자로 실행합니다. SQLite와 Hermes profile/OAuth 상태는 `/data` 아래에 두며,
`Dockerfile`은 Python 베이스 이미지를 digest로 고정하고 UID/GID 1000의 non-root
사용자로 실행합니다. SQLite 상태는 `/data` 아래에 두며,
운영에서는 하나의 영속 PVC를 마운트합니다. image tag는 Git commit SHA만 사용하고
`main` CI가 검증한 image만 GHCR에 게시합니다.

Expand Down
4 changes: 2 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## 목적

Jira에 익숙하지 않은 팀원이 자연어로 할 일만 적으면 서버 agent가 Jira MCP로 티켓을 생성한다. 이 서비스는 Jira를 대체하지 않고 티켓 생성만 전담한다.
Jira에 익숙하지 않은 팀원이 자연어로 할 일만 적으면 SSAFY GMS AI API가 초안을 만들고 서버가 Jira REST API로 티켓을 생성한다. 이 서비스는 Jira를 대체하지 않고 티켓 생성만 전담한다.

## 확정 결정

Expand All @@ -12,7 +12,7 @@ Jira에 익숙하지 않은 팀원이 자연어로 할 일만 적으면 서버 a
- Jira 프로젝트: `S15P11A705`
- 타입: Task
- 담당자: 로그인 사용자의 필수 `jira_account_id`
- 생성 주체: 서버의 현재 Atlassian MCP 인증
- 생성 주체: Cowork 전용 Jira 이메일/API Token 인증
- 스프린트·parent·story point: 설정하지 않음

## 사용자 흐름
Expand Down
161 changes: 2 additions & 159 deletions app/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,9 @@

import json
import re
import subprocess
from dataclasses import dataclass

from typing import Any
from urllib.parse import urlparse

from .config import Settings
from .roles import ticket_summary, validate_role_tag


class AgentError(RuntimeError):
Expand All @@ -28,28 +23,6 @@ class AgentReconciliationRequired(AgentError):
pass


HERMES_QUERY_BOOTSTRAP = """
import sys
from hermes_cli.main import main

turns = sys.argv[1]
prompt = sys.stdin.read()
sys.argv = [
"hermes",
"chat",
"-q",
prompt,
"--safe-mode",
"-Q",
"--source",
"tool",
"--ignore-rules",
"--max-turns",
turns,
]
raise SystemExit(main())
"""


def _looks_like_network_error(text: str) -> bool:
lowered = text.lower()
Expand Down Expand Up @@ -158,37 +131,9 @@ def _extract_json(output: str) -> dict[str, Any]:
raise AgentError("agent returned no JSON object")
return max(candidates, key=lambda item: item[0])[1]


class HermesJiraAgent:
def __init__(self, settings: Settings):
self.settings = settings

class PlanningAgent:
def _run(self, prompt: str, *, max_turns: int) -> dict[str, Any]:
command = [
self.settings.hermes_python,
"-c",
HERMES_QUERY_BOOTSTRAP,
str(max_turns),
]
try:
completed = subprocess.run(
command,
input=" ".join(prompt.splitlines()) + "\n",
capture_output=True,
text=True,
timeout=self.settings.agent_timeout_seconds,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise AgentTimeout("agent subprocess timed out") from exc
except OSError as exc:
raise AgentError("agent subprocess unavailable") from exc
if completed.returncode != 0:
detail = _bounded_error_detail(completed.stderr)
if _looks_like_network_error(detail):
raise AgentNetworkError(f"planner network failure: {detail}")
raise AgentError(f"agent subprocess failed: {detail}")
return _extract_json(completed.stdout)
raise NotImplementedError

def plan(self, raw_input: str) -> Plan:
source_lines = [line.strip() for line in raw_input.splitlines() if line.strip()]
Expand Down Expand Up @@ -235,105 +180,3 @@ def plan(self, raw_input: str) -> Plan:
)
corrected_payload = self._run(correction_prompt, max_turns=2)
return _parse_plan_payload(corrected_payload, source_lines)

def list_active_sprints(self) -> list[ActiveSprint]:
request = {
"cloudId": self.settings.jira_base_url,
"projectKey": self.settings.project_key,
}
try:
completed = subprocess.run(
[self.settings.hermes_python, "-m", "app.mcp_sprint_bridge"],
input=json.dumps(request, ensure_ascii=False),
capture_output=True,
text=True,
timeout=self.settings.agent_timeout_seconds,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise AgentTimeout("active sprint lookup timed out") from exc
except OSError as exc:
raise AgentError("Jira MCP sprint bridge unavailable") from exc
if completed.returncode != 0:
detail = _bounded_error_detail(completed.stdout or completed.stderr)
if _looks_like_network_error(detail):
raise AgentNetworkError(f"active sprint lookup failed: {detail}")
raise AgentError(f"active sprint lookup failed: {detail}")
try:
response = json.loads(completed.stdout)
raw_sprints = response["sprints"]
if set(response) != {"sprints"} or not isinstance(raw_sprints, list):
raise ValueError
sprints = [ActiveSprint(id=item["id"], name=item["name"]) for item in raw_sprints]
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
raise AgentError("invalid active sprint response") from exc
if any(
not isinstance(sprint.id, int)
or sprint.id <= 0
or not isinstance(sprint.name, str)
or not sprint.name.strip()
for sprint in sprints
):
raise AgentError("invalid active sprint response")
return sprints

def create_task(
self,
task: PlannedTask,
jira_account_id: str,
sprint_id: int,
role_tag: str,
) -> CreatedTicket:
role_tag = validate_role_tag(role_tag)
request = {
"cloudId": self.settings.jira_base_url,
"projectKey": self.settings.project_key,
"issueTypeName": "Task",
"summary": ticket_summary(task.summary, role_tag),
"assignee_account_id": jira_account_id,
"contentFormat": "markdown",
"additional_fields": {
"customfield_10020": sprint_id,
"labels": [role_tag],
},
}
if task.description is not None:
request["description"] = task.description
try:
completed = subprocess.run(
[self.settings.hermes_python, "-m", "app.mcp_create_bridge"],
input=json.dumps(request, ensure_ascii=False),
capture_output=True,
text=True,
timeout=self.settings.agent_timeout_seconds,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise AgentReconciliationRequired("Jira create timed out after dispatch") from exc
except OSError as exc:
raise AgentError("Jira MCP bridge unavailable") from exc
if completed.returncode == 42:
raise AgentReconciliationRequired("Jira create result requires reconciliation")
if completed.returncode != 0:
detail = _bounded_error_detail(completed.stdout or completed.stderr)
if _looks_like_network_error(detail):
raise AgentReconciliationRequired(
f"Jira create network result requires reconciliation: {detail}"
)
raise AgentError(f"Jira MCP create failed: {detail}")
try:
response = json.loads(completed.stdout)
except json.JSONDecodeError as exc:
raise AgentReconciliationRequired("Jira create result requires reconciliation") from exc
if not isinstance(response, dict) or set(response) != {"issue_key"}:
raise AgentReconciliationRequired("Jira create result requires reconciliation")
issue_key = response["issue_key"]
if not isinstance(issue_key, str) or not re.fullmatch(
rf"{re.escape(self.settings.project_key)}-\d+", issue_key
):
raise AgentReconciliationRequired("Jira create result requires reconciliation")
base = urlparse(self.settings.jira_base_url)
if base.scheme != "https" or not base.netloc:
raise AgentError("invalid configured Jira URL")
url = f"{self.settings.jira_base_url}/browse/{issue_key}"
return CreatedTicket(issue_key=issue_key, summary=task.summary, url=url)
Loading