From 3480dc16b1efcf0de0070a3e2163736305d4b947 Mon Sep 17 00:00:00 2001 From: "Leo.Kim" <83855438+tpals0409@users.noreply.github.com> Date: Thu, 23 Jul 2026 02:48:57 +0000 Subject: [PATCH] feat(S15P11A705-14): apply role ticket rules --- app/agent.py | 15 +++++-- app/database.py | 78 +++++++++++++++++++++++++++++---- app/main.py | 15 +++++++ app/mcp_create_bridge.py | 7 ++- app/roles.py | 42 ++++++++++++++++++ app/seed_user.py | 7 ++- app/static/app.js | 16 ++++++- app/static/index.html | 16 +++++-- app/static/style.css | 8 ++-- app/worker.py | 7 ++- tests/test_agent.py | 15 ++++--- tests/test_api.py | 12 ++++- tests/test_mcp_create_bridge.py | 4 +- tests/test_ui_contract.py | 4 ++ tests/test_worker.py | 20 +++++++-- 15 files changed, 230 insertions(+), 36 deletions(-) create mode 100644 app/roles.py diff --git a/app/agent.py b/app/agent.py index d660648..8e2118c 100644 --- a/app/agent.py +++ b/app/agent.py @@ -9,6 +9,7 @@ from urllib.parse import urlparse from .config import Settings +from .roles import ticket_summary, validate_role_tag class AgentError(RuntimeError): @@ -260,16 +261,24 @@ def list_active_sprints(self) -> list[ActiveSprint]: return sprints def create_task( - self, task: PlannedTask, jira_account_id: str, sprint_id: int + 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": task.summary, + "summary": ticket_summary(task.summary, role_tag), "assignee_account_id": jira_account_id, "contentFormat": "markdown", - "additional_fields": {"customfield_10020": sprint_id}, + "additional_fields": { + "customfield_10020": sprint_id, + "labels": [role_tag], + }, } if task.description is not None: request["description"] = task.description diff --git a/app/database.py b/app/database.py index 1103012..f691f01 100644 --- a/app/database.py +++ b/app/database.py @@ -8,6 +8,8 @@ from pathlib import Path from typing import Any +from .roles import ROLE_TAG_BY_DISPLAY_NAME, validate_role_tag + SCHEMA = """ PRAGMA journal_mode=WAL; @@ -18,6 +20,7 @@ password_hash TEXT NOT NULL, display_name TEXT NOT NULL, jira_account_id TEXT NOT NULL CHECK (length(jira_account_id) > 0), + role_tag TEXT NOT NULL CHECK (role_tag IN ('FE','BE','Infra','AI')), created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( @@ -34,6 +37,9 @@ raw_input TEXT NOT NULL, sprint_id INTEGER, sprint_name TEXT, + role_tag TEXT, + assignee_display_name TEXT, + assignee_jira_account_id TEXT, state TEXT NOT NULL CHECK (state IN ('received','organizing','creating','completed','partial','failed','reconcile')), public_message TEXT, excluded_json TEXT NOT NULL DEFAULT '[]', @@ -96,6 +102,16 @@ def connect(self) -> sqlite3.Connection: def initialize(self) -> None: with self.connect() as conn: conn.executescript(SCHEMA) + user_columns = { + row["name"] for row in conn.execute("PRAGMA table_info(users)") + } + if "role_tag" not in user_columns: + conn.execute("ALTER TABLE users ADD COLUMN role_tag TEXT") + for display_name, role_tag in ROLE_TAG_BY_DISPLAY_NAME.items(): + conn.execute( + "UPDATE users SET role_tag=? WHERE display_name=? AND role_tag IS NULL", + (role_tag, display_name), + ) columns = {row["name"] for row in conn.execute("PRAGMA table_info(submissions)")} if "planned_tasks_json" not in columns: conn.execute( @@ -105,21 +121,44 @@ def initialize(self) -> None: 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") + if "role_tag" not in columns: + conn.execute("ALTER TABLE submissions ADD COLUMN role_tag TEXT") + if "assignee_display_name" not in columns: + conn.execute("ALTER TABLE submissions ADD COLUMN assignee_display_name TEXT") + if "assignee_jira_account_id" not in columns: + conn.execute("ALTER TABLE submissions ADD COLUMN assignee_jira_account_id TEXT") - def upsert_user(self, email: str, password_hash: str, display_name: str, jira_account_id: str) -> None: + def upsert_user( + self, + email: str, + password_hash: str, + display_name: str, + jira_account_id: str, + role_tag: str, + ) -> None: if not jira_account_id.strip(): raise ValueError("jira_account_id is required") + role_tag = validate_role_tag(role_tag) now = int(time.time()) with self.connect() as conn: conn.execute("BEGIN IMMEDIATE") conn.execute( - """INSERT INTO users(email,password_hash,display_name,jira_account_id,created_at) - VALUES(?,?,?,?,?) + """INSERT INTO users + (email,password_hash,display_name,jira_account_id,role_tag,created_at) + VALUES(?,?,?,?,?,?) ON CONFLICT(email) DO UPDATE SET password_hash=excluded.password_hash, display_name=excluded.display_name, - jira_account_id=excluded.jira_account_id""", - (email.strip().lower(), password_hash, display_name.strip(), jira_account_id.strip(), now), + jira_account_id=excluded.jira_account_id, + role_tag=excluded.role_tag""", + ( + email.strip().lower(), + password_hash, + display_name.strip(), + jira_account_id.strip(), + role_tag, + now, + ), ) conn.commit() @@ -165,7 +204,11 @@ def create_submission( raw_input: str, sprint_id: int, sprint_name: str, + role_tag: str, + assignee_display_name: str, + assignee_jira_account_id: str, ) -> tuple[str, bool]: + role_tag = validate_role_tag(role_tag) now = int(time.time()) with self.connect() as conn: conn.execute("BEGIN IMMEDIATE") @@ -182,8 +225,9 @@ def create_submission( return str(row["id"]), False conn.execute( """INSERT INTO submissions - (id,user_id,idempotency_key,raw_input,sprint_id,sprint_name,state,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?)""", + (id,user_id,idempotency_key,raw_input,sprint_id,sprint_name,role_tag, + assignee_display_name,assignee_jira_account_id,state,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""", ( submission_id, user_id, @@ -191,6 +235,9 @@ def create_submission( raw_input, sprint_id, sprint_name, + role_tag, + assignee_display_name, + assignee_jira_account_id, "received", now, now, @@ -301,6 +348,7 @@ def get_submission(self, submission_id: str, user_id: int) -> dict[str, Any] | N result["excluded"] = json.loads(result.pop("excluded_json")) result["preview"] = json.loads(result.pop("planned_tasks_json")) result["tickets"] = [dict(ticket) for ticket in tickets] + result.pop("assignee_jira_account_id", None) result.pop("raw_input", None) result.pop("internal_error", None) result.pop("idempotency_key", None) @@ -309,13 +357,25 @@ def get_submission(self, submission_id: str, user_id: int) -> dict[str, Any] | N def get_submission_for_worker(self, submission_id: str) -> dict[str, Any] | None: with self.connect() as conn: row = conn.execute( - """SELECT s.*,u.display_name,u.jira_account_id FROM submissions s - JOIN users u ON u.id=s.user_id WHERE s.id=?""", + """SELECT s.*,u.display_name AS user_display_name, + u.jira_account_id AS user_jira_account_id, + u.role_tag AS user_role_tag + FROM submissions s JOIN users u ON u.id=s.user_id WHERE s.id=?""", (submission_id,), ).fetchone() if not row: return None result = dict(row) + result["display_name"] = ( + result.get("assignee_display_name") or result["user_display_name"] + ) + result["jira_account_id"] = ( + result.get("assignee_jira_account_id") or result["user_jira_account_id"] + ) + result["role_tag"] = result.get("role_tag") or result["user_role_tag"] + result.pop("user_display_name", None) + result.pop("user_jira_account_id", None) + result.pop("user_role_tag", None) result["excluded"] = json.loads(result.pop("excluded_json")) result["planned_tasks"] = json.loads(result.pop("planned_tasks_json")) return result diff --git a/app/main.py b/app/main.py index b49ff04..9bbc19f 100644 --- a/app/main.py +++ b/app/main.py @@ -18,6 +18,7 @@ from .alerts import MattermostAlerter from .config import Settings from .database import Database, IdempotencyConflict +from .roles import validate_role_tag from .security import hash_password, new_token, token_digest, verify_password from .worker import SubmissionWorker @@ -247,6 +248,13 @@ def submit( raw_input = payload.text if not raw_input.strip(): raise HTTPException(status_code=400, detail="할 일을 입력해주세요") + try: + role_tag = validate_role_tag(user.get("role_tag") or "") + except ValueError: + raise HTTPException( + status_code=409, + detail="역할 태그가 등록되지 않았어요. 팀 리드에게 요청하세요", + ) from None try: uuid.UUID(payload.idempotency_key) except ValueError as exc: @@ -268,6 +276,9 @@ def submit( raw_input, selected_sprint.id, selected_sprint.name, + role_tag, + user["display_name"], + user["jira_account_id"], ) except IdempotencyConflict as exc: raise HTTPException(status_code=409, detail="요청을 다시 확인해주세요") from exc @@ -336,6 +347,10 @@ def submission_status( "id": submission["sprint_id"], "name": submission["sprint_name"], }, + "assignee": { + "display_name": submission["assignee_display_name"], + "role_tag": submission["role_tag"], + }, "preview": submission["preview"], "tickets": submission["tickets"], "retryable": state == "failed", diff --git a/app/mcp_create_bridge.py b/app/mcp_create_bridge.py index c60a7c0..b1b6ead 100644 --- a/app/mcp_create_bridge.py +++ b/app/mcp_create_bridge.py @@ -8,6 +8,8 @@ from tools.mcp_tool import discover_mcp_tools from tools.registry import registry +from .roles import VALID_ROLE_TAGS + TOOL_NAME = "mcp__atlassian__createJiraIssue" ALLOWED = { "cloudId", @@ -49,10 +51,13 @@ def main() -> None: additional_fields = payload.get("additional_fields") if ( not isinstance(additional_fields, dict) - or set(additional_fields) != {"customfield_10020"} + or set(additional_fields) != {"customfield_10020", "labels"} or not isinstance(additional_fields["customfield_10020"], int) or isinstance(additional_fields["customfield_10020"], bool) or additional_fields["customfield_10020"] <= 0 + or not isinstance(additional_fields["labels"], list) + or len(additional_fields["labels"]) != 1 + or additional_fields["labels"][0] not in VALID_ROLE_TAGS ): raise SystemExit("invalid request") diff --git a/app/roles.py b/app/roles.py new file mode 100644 index 0000000..fd4897c --- /dev/null +++ b/app/roles.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import re + +ROLE_TAG_BY_DISPLAY_NAME = { + "김가현": "FE", + "유승주": "FE", + "김세민": "Infra", + "이정헌": "AI", + "박민용": "BE", + "홍석호": "BE", +} +VALID_ROLE_TAGS = frozenset(ROLE_TAG_BY_DISPLAY_NAME.values()) +_ROLE_PREFIX = re.compile(r"^\s*\[(?:FE|BE|Infra|AI)\]\s*", re.IGNORECASE) + + +def role_tag_for(display_name: str) -> str | None: + return ROLE_TAG_BY_DISPLAY_NAME.get(display_name.strip()) + + +def validate_role_tag(role_tag: str) -> str: + normalized = role_tag.strip() + if normalized not in VALID_ROLE_TAGS: + raise ValueError("invalid role tag") + return normalized + + +def ticket_summary(summary: str, role_tag: str) -> str: + role = validate_role_tag(role_tag) + body = summary.strip() + while _ROLE_PREFIX.match(body): + body = _ROLE_PREFIX.sub("", body, count=1).strip() + if not body: + raise ValueError("ticket summary is empty") + prefix = f"[{role}] " + return prefix + body[: 255 - len(prefix)].rstrip() + + +def ticket_description(description: str | None) -> str | None: + if not description or not description.strip(): + return None + return f"## 작업 내용\n{description.strip()}" diff --git a/app/seed_user.py b/app/seed_user.py index 9d4499d..7d9aa35 100644 --- a/app/seed_user.py +++ b/app/seed_user.py @@ -9,6 +9,7 @@ from .config import Settings from .database import Database +from .roles import VALID_ROLE_TAGS, role_tag_for from .security import hash_password @@ -47,7 +48,11 @@ def main() -> None: parser = argparse.ArgumentParser(description="Interactively seed one cowork user") parser.add_argument("--email", required=True) parser.add_argument("--display-name", required=True) + parser.add_argument("--role-tag", choices=sorted(VALID_ROLE_TAGS)) args = parser.parse_args() + role_tag = args.role_tag or role_tag_for(args.display_name) + if not role_tag: + raise SystemExit("신규 사용자는 --role-tag가 필요합니다") password = getpass.getpass("새 비밀번호: ") confirmation = getpass.getpass("새 비밀번호 확인: ") if password != confirmation: @@ -57,7 +62,7 @@ def main() -> None: database.initialize() account_id = lookup_account_id(settings, args.display_name) database.upsert_user( - args.email, hash_password(password), args.display_name, account_id + args.email, hash_password(password), args.display_name, account_id, role_tag ) print(f"등록 완료: {args.display_name}") diff --git a/app/static/app.js b/app/static/app.js index 6859aba..a91e146 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -16,6 +16,8 @@ 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'); +const previewAssigneeName = document.querySelector('#preview-assignee-name'); +const previewRoleTag = document.querySelector('#preview-role-tag'); let csrfToken = ''; let creating = false; @@ -170,9 +172,11 @@ function hidePreview() { previewSection.hidden = true; previewList.replaceChildren(); previewSprintName.textContent = ''; + previewAssigneeName.textContent = ''; + previewRoleTag.textContent = ''; } -function showPreview(submissionId, sentText, sprint, tasks) { +function showPreview(submissionId, sentText, sprint, assignee, tasks) { previewList.replaceChildren(); for (const task of tasks) { const row = document.createElement('li'); @@ -188,6 +192,8 @@ function showPreview(submissionId, sentText, sprint, tasks) { } pendingConfirmation = {submissionId, sentText}; previewSprintName.textContent = sprint?.name || ''; + previewAssigneeName.textContent = assignee?.display_name || ''; + previewRoleTag.textContent = assignee?.role_tag || ''; previewSection.hidden = false; todoInput.disabled = true; sprintSelect.disabled = true; @@ -225,7 +231,13 @@ 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.sprint, result.preview || []); + showPreview( + submissionId, + sentText, + result.sprint, + result.assignee, + result.preview || [], + ); return; } if (result.tickets?.length) { diff --git a/app/static/index.html b/app/static/index.html index 76b40e8..1e31e5d 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -51,9 +51,19 @@

할 일 올리기