From f9be3ff7b4288eefbdd7ffd965b4b742d088c9d5 Mon Sep 17 00:00:00 2001 From: "Leo.Kim" <83855438+tpals0409@users.noreply.github.com> Date: Thu, 23 Jul 2026 02:16:47 +0000 Subject: [PATCH] feat(S15P11A705-14): require active sprint selection --- app/agent.py | 52 ++++++++++++++++++++- app/database.py | 35 +++++++++++--- app/main.py | 48 ++++++++++++++++++- app/mcp_create_bridge.py | 10 ++++ app/mcp_sprint_bridge.py | 82 +++++++++++++++++++++++++++++++++ app/static/app.js | 66 ++++++++++++++++++++++---- app/static/index.html | 10 ++++ app/static/style.css | 9 +++- app/worker.py | 9 +++- tests/test_agent.py | 9 ++-- tests/test_api.py | 20 ++++++-- tests/test_mcp_create_bridge.py | 2 + tests/test_ui_contract.py | 4 +- tests/test_worker.py | 9 +++- 14 files changed, 335 insertions(+), 30 deletions(-) create mode 100644 app/mcp_sprint_bridge.py diff --git a/app/agent.py b/app/agent.py index 98be710..d660648 100644 --- a/app/agent.py +++ b/app/agent.py @@ -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") @@ -212,7 +218,50 @@ 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, @@ -220,6 +269,7 @@ def create_task(self, task: PlannedTask, jira_account_id: str) -> CreatedTicket: "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 diff --git a/app/database.py b/app/database.py index fd7eaba..1103012 100644 --- a/app/database.py +++ b/app/database.py @@ -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 '[]', @@ -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(): @@ -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 diff --git a/app/main.py b/app/main.py index 6fb9934..b49ff04 100644 --- a/app/main.py +++ b/app/main.py @@ -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( @@ -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") @@ -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), @@ -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 @@ -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) @@ -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", diff --git a/app/mcp_create_bridge.py b/app/mcp_create_bridge.py index 45b52f9..c60a7c0 100644 --- a/app/mcp_create_bridge.py +++ b/app/mcp_create_bridge.py @@ -17,6 +17,7 @@ "description", "assignee_account_id", "contentFormat", + "additional_fields", } @@ -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: diff --git a/app/mcp_sprint_bridge.py b/app/mcp_sprint_bridge.py new file mode 100644 index 0000000..5d02615 --- /dev/null +++ b/app/mcp_sprint_bridge.py @@ -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() diff --git a/app/static/app.js b/app/static/app.js index 849b8a2..6859aba 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -14,6 +14,8 @@ const previewSection = document.querySelector('#preview-section'); const previewList = document.querySelector('#preview-list'); const editButton = document.querySelector('#edit-button'); const confirmButton = document.querySelector('#confirm-button'); +const sprintSelect = document.querySelector('#sprint-select'); +const previewSprintName = document.querySelector('#preview-sprint-name'); let csrfToken = ''; let creating = false; @@ -24,7 +26,11 @@ const pendingStorageKey = 'cowork_pending_request'; function loadPendingRequest() { try { const value = JSON.parse(sessionStorage.getItem(pendingStorageKey)); - if (typeof value?.text === 'string' && typeof value?.key === 'string') return value; + if ( + typeof value?.text === 'string' + && typeof value?.key === 'string' + && Number.isInteger(value?.sprintId) + ) return value; } catch (_) { /* discard invalid browser state */ } return null; } @@ -59,6 +65,31 @@ function showApp(me) { accountButton.textContent = me.display_name; loginScreen.hidden = true; appScreen.hidden = false; + loadActiveSprints(); +} + +async function loadActiveSprints() { + sprintSelect.disabled = true; + createButton.disabled = true; + sprintSelect.replaceChildren(new Option('불러오는 중...', '')); + try { + const result = await jsonFetch('/api/sprints'); + sprintSelect.replaceChildren(new Option('활성 스프린트를 선택해주세요', '')); + for (const sprint of result.sprints || []) { + sprintSelect.append(new Option(sprint.name, String(sprint.id))); + } + if (!result.sprints?.length) { + sprintSelect.replaceChildren(new Option('활성 스프린트가 없습니다', '')); + createError.textContent = '활성 스프린트가 없어서 티켓을 만들 수 없어요'; + createError.hidden = false; + return; + } + sprintSelect.disabled = false; + } catch (error) { + sprintSelect.replaceChildren(new Option('불러오지 못했습니다', '')); + createError.textContent = error.message; + createError.hidden = false; + } } async function boot() { @@ -105,6 +136,8 @@ logoutButton.addEventListener('click', async () => { pendingConfirmation = null; hidePreview(); todoInput.disabled = false; + sprintSelect.disabled = true; + sprintSelect.replaceChildren(new Option('불러오는 중...', '')); renderReceipts(); showLogin(); } @@ -136,9 +169,10 @@ function renderReceipts() { function hidePreview() { previewSection.hidden = true; previewList.replaceChildren(); + previewSprintName.textContent = ''; } -function showPreview(submissionId, sentText, tasks) { +function showPreview(submissionId, sentText, sprint, tasks) { previewList.replaceChildren(); for (const task of tasks) { const row = document.createElement('li'); @@ -153,8 +187,10 @@ function showPreview(submissionId, sentText, tasks) { previewList.append(row); } pendingConfirmation = {submissionId, sentText}; + previewSprintName.textContent = sprint?.name || ''; previewSection.hidden = false; todoInput.disabled = true; + sprintSelect.disabled = true; createButton.disabled = true; creating = false; createButton.textContent = '티켓 만들기'; @@ -166,7 +202,8 @@ function finishCreation() { pendingConfirmation = null; hidePreview(); todoInput.disabled = false; - createButton.disabled = false; + sprintSelect.disabled = false; + createButton.disabled = !sprintSelect.value; createButton.textContent = '티켓 만들기'; confirmButton.disabled = false; confirmButton.textContent = '확인하고 만들기'; @@ -188,7 +225,7 @@ async function pollSubmission(submissionId, sentText) { if (result.progress) progress.textContent = result.progress; if (['received', 'organizing', 'creating'].includes(result.state)) continue; if (result.state === 'review') { - showPreview(submissionId, sentText, result.preview || []); + showPreview(submissionId, sentText, result.sprint, result.preview || []); return; } if (result.tickets?.length) { @@ -214,7 +251,8 @@ editButton.addEventListener('click', () => { pendingConfirmation = null; hidePreview(); todoInput.disabled = false; - createButton.disabled = false; + sprintSelect.disabled = false; + createButton.disabled = !sprintSelect.value; progress.textContent = ''; todoInput.focus(); }); @@ -250,23 +288,29 @@ confirmButton.addEventListener('click', async () => { createButton.addEventListener('click', async () => { if (creating) return; const sentText = todoInput.value; - if (!sentText.trim()) return; + const sprintId = Number(sprintSelect.value); + if (!sentText.trim() || !Number.isInteger(sprintId) || sprintId <= 0) return; creating = true; hidePreview(); createError.hidden = true; + sprintSelect.disabled = true; createButton.disabled = true; createButton.textContent = '정리 중'; progress.textContent = '할 일 정리하는 중'; const previous = loadPendingRequest(); - const attempt = previous?.text === sentText + const attempt = previous?.text === sentText && previous?.sprintId === sprintId ? previous - : {text: sentText, key: crypto.randomUUID()}; + : {text: sentText, sprintId, key: crypto.randomUUID()}; savePendingRequest(attempt); try { const result = await jsonFetch('/api/submissions', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken}, - body: JSON.stringify({text: sentText, idempotency_key: attempt.key}), + body: JSON.stringify({ + text: sentText, + sprint_id: sprintId, + idempotency_key: attempt.key, + }), }); await pollSubmission(result.submission_id, sentText); } catch (error) { @@ -278,4 +322,8 @@ createButton.addEventListener('click', async () => { } }); +sprintSelect.addEventListener('change', () => { + createButton.disabled = !sprintSelect.value; +}); + boot(); diff --git a/app/static/index.html b/app/static/index.html index e7ed62f..76b40e8 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -36,6 +36,12 @@

할 일 올리기

+
+ + +