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
52 changes: 51 additions & 1 deletion app/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ class CreatedTicket:
url: str


@dataclass(frozen=True)
class ActiveSprint:
id: int
name: str


def _parse_plan_payload(payload: dict[str, Any], source_lines: list[str]) -> Plan:
if set(payload) != {"tasks", "excluded"}:
raise AgentError("invalid planning response fields")
Expand Down Expand Up @@ -212,14 +218,58 @@ 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 create_task(self, task: PlannedTask, jira_account_id: str) -> CreatedTicket:
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
) -> CreatedTicket:
request = {
"cloudId": self.settings.jira_base_url,
"projectKey": self.settings.project_key,
"issueTypeName": "Task",
"summary": task.summary,
"assignee_account_id": jira_account_id,
"contentFormat": "markdown",
"additional_fields": {"customfield_10020": sprint_id},
}
if task.description is not None:
request["description"] = task.description
Expand Down
35 changes: 29 additions & 6 deletions app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
user_id INTEGER NOT NULL REFERENCES users(id),
idempotency_key TEXT NOT NULL,
raw_input TEXT NOT NULL,
sprint_id INTEGER,
sprint_name TEXT,
state TEXT NOT NULL CHECK (state IN ('received','organizing','creating','completed','partial','failed','reconcile')),
public_message TEXT,
excluded_json TEXT NOT NULL DEFAULT '[]',
Expand Down Expand Up @@ -99,6 +101,10 @@ def initialize(self) -> None:
conn.execute(
"ALTER TABLE submissions ADD COLUMN planned_tasks_json TEXT NOT NULL DEFAULT '[]'"
)
if "sprint_id" not in columns:
conn.execute("ALTER TABLE submissions ADD COLUMN sprint_id INTEGER")
if "sprint_name" not in columns:
conn.execute("ALTER TABLE submissions ADD COLUMN sprint_name TEXT")

def upsert_user(self, email: str, password_hash: str, display_name: str, jira_account_id: str) -> None:
if not jira_account_id.strip():
Expand Down Expand Up @@ -152,26 +158,43 @@ def delete_session(self, token_hash: str) -> None:
conn.execute("DELETE FROM sessions WHERE token_hash=?", (token_hash,))

def create_submission(
self, submission_id: str, user_id: int, idempotency_key: str, raw_input: str
self,
submission_id: str,
user_id: int,
idempotency_key: str,
raw_input: str,
sprint_id: int,
sprint_name: str,
) -> tuple[str, bool]:
now = int(time.time())
with self.connect() as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT id,raw_input FROM submissions WHERE user_id=? AND idempotency_key=?",
"""SELECT id,raw_input,sprint_id FROM submissions
WHERE user_id=? AND idempotency_key=?""",
(user_id, idempotency_key),
).fetchone()
if row:
if row["raw_input"] != raw_input:
if row["raw_input"] != raw_input or row["sprint_id"] != sprint_id:
conn.rollback()
raise IdempotencyConflict("idempotency key was reused with different input")
conn.commit()
return str(row["id"]), False
conn.execute(
"""INSERT INTO submissions
(id,user_id,idempotency_key,raw_input,state,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)""",
(submission_id, user_id, idempotency_key, raw_input, "received", now, now),
(id,user_id,idempotency_key,raw_input,sprint_id,sprint_name,state,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)""",
(
submission_id,
user_id,
idempotency_key,
raw_input,
sprint_id,
sprint_name,
"received",
now,
now,
),
)
conn.commit()
return submission_id, True
Expand Down
48 changes: 47 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ class LoginRequest(BaseModel):
class SubmissionRequest(BaseModel):
text: str = Field(max_length=10_000)
idempotency_key: str = Field(min_length=36, max_length=36)
sprint_id: int = Field(gt=0)


def create_app(
Expand Down Expand Up @@ -141,6 +142,15 @@ def require_csrf(expected: str, provided: str | None) -> None:
if not provided or not secrets.compare_digest(expected, provided):
raise HTTPException(status_code=403, detail="요청을 확인할 수 없습니다")

def active_sprints() -> list[Any]:
try:
return worker.list_active_sprints()
except Exception as exc:
raise HTTPException(
status_code=503,
detail="활성 스프린트를 불러오지 못했어요. 잠시 후 다시 시도해주세요",
) from exc

@app.get("/")
def index() -> FileResponse:
return FileResponse(static_dir / "index.html")
Expand Down Expand Up @@ -201,6 +211,17 @@ def login(payload: LoginRequest, request: Request, response: Response) -> dict[s
)
return {"display_name": user["display_name"]}

@app.get("/api/sprints")
def list_active_sprints(
cowork_session: str | None = Cookie(default=None),
) -> dict[str, Any]:
session_context(cowork_session)
return {
"sprints": [
{"id": sprint.id, "name": sprint.name} for sprint in active_sprints()
]
}

@app.post("/api/logout", status_code=204)
def logout(
cowork_session: str | None = Cookie(default=None),
Expand Down Expand Up @@ -230,9 +251,23 @@ def submit(
uuid.UUID(payload.idempotency_key)
except ValueError as exc:
raise HTTPException(status_code=400, detail="요청을 확인할 수 없습니다") from exc
selected_sprint = next(
(sprint for sprint in active_sprints() if sprint.id == payload.sprint_id),
None,
)
if not selected_sprint:
raise HTTPException(
status_code=409,
detail="선택한 스프린트가 더 이상 활성 상태가 아니에요",
)
try:
submission_id, created = database.create_submission(
str(uuid.uuid4()), user["id"], payload.idempotency_key, raw_input
str(uuid.uuid4()),
user["id"],
payload.idempotency_key,
raw_input,
selected_sprint.id,
selected_sprint.name,
)
except IdempotencyConflict as exc:
raise HTTPException(status_code=409, detail="요청을 다시 확인해주세요") from exc
Expand All @@ -256,6 +291,13 @@ def confirm_submission(
submission = database.get_submission(submission_id, user["id"])
if not submission:
raise HTTPException(status_code=404, detail="요청을 찾을 수 없습니다")
if submission["state"] == "organizing" and not any(
sprint.id == submission["sprint_id"] for sprint in active_sprints()
):
raise HTTPException(
status_code=409,
detail="선택한 스프린트가 더 이상 활성 상태가 아니에요",
)
claimed = database.claim_confirmation(submission_id, user["id"])
if claimed:
background_tasks.add_task(worker.create, submission_id)
Expand Down Expand Up @@ -290,6 +332,10 @@ def submission_status(
"state": state,
"progress": progress,
"message": submission["public_message"],
"sprint": {
"id": submission["sprint_id"],
"name": submission["sprint_name"],
},
"preview": submission["preview"],
"tickets": submission["tickets"],
"retryable": state == "failed",
Expand Down
10 changes: 10 additions & 0 deletions app/mcp_create_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"description",
"assignee_account_id",
"contentFormat",
"additional_fields",
}


Expand Down Expand Up @@ -45,6 +46,15 @@ def main() -> None:
raise SystemExit("invalid request")
if not all(isinstance(payload[key], str) and payload[key] for key in required):
raise SystemExit("invalid request")
additional_fields = payload.get("additional_fields")
if (
not isinstance(additional_fields, dict)
or set(additional_fields) != {"customfield_10020"}
or not isinstance(additional_fields["customfield_10020"], int)
or isinstance(additional_fields["customfield_10020"], bool)
or additional_fields["customfield_10020"] <= 0
):
raise SystemExit("invalid request")

discover_mcp_tools()
try:
Expand Down
82 changes: 82 additions & 0 deletions app/mcp_sprint_bridge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from __future__ import annotations

import json
import re
import sys
from typing import Any

from tools.mcp_tool import discover_mcp_tools
from tools.registry import registry

TOOL_NAME = "mcp__atlassian__searchJiraIssuesUsingJql"
SPRINT_FIELD = "customfield_10020"


def _decode(value: Any) -> Any:
if isinstance(value, str):
return json.loads(value)
return value


def main() -> None:
try:
payload: Any = json.load(sys.stdin)
except (json.JSONDecodeError, UnicodeDecodeError):
raise SystemExit("invalid request")
if not isinstance(payload, dict) or set(payload) != {"cloudId", "projectKey"}:
raise SystemExit("invalid request")
cloud_id = payload["cloudId"]
project_key = payload["projectKey"]
if not isinstance(cloud_id, str) or not cloud_id.startswith("https://"):
raise SystemExit("invalid request")
if not isinstance(project_key, str) or not re.fullmatch(r"[A-Z][A-Z0-9_]+", project_key):
raise SystemExit("invalid request")

discover_mcp_tools()
raw = registry.dispatch(
TOOL_NAME,
{
"cloudId": cloud_id,
"jql": f"project = {project_key} AND sprint in openSprints() ORDER BY updated DESC",
"maxResults": 100,
"fields": [SPRINT_FIELD],
"searchResultMode": "issues",
},
)
try:
outer = _decode(raw)
body = _decode(outer["result"])
issues = body["issues"]
if not isinstance(issues, list):
raise ValueError
sprints: dict[int, str] = {}
for issue in issues:
values = issue.get("fields", {}).get(SPRINT_FIELD) or []
if not isinstance(values, list):
raise ValueError
for sprint in values:
if not isinstance(sprint, dict) or sprint.get("state") != "active":
continue
sprint_id = sprint.get("id")
name = sprint.get("name")
if (
not isinstance(sprint_id, int)
or isinstance(sprint_id, bool)
or sprint_id <= 0
or not isinstance(name, str)
or not name.strip()
):
raise ValueError
sprints[sprint_id] = name.strip()
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise SystemExit("invalid Jira sprint response") from exc

json.dump(
{"sprints": [{"id": sprint_id, "name": name} for sprint_id, name in sorted(sprints.items())]},
sys.stdout,
ensure_ascii=False,
)


if __name__ == "__main__":
main()
Loading