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
15 changes: 12 additions & 3 deletions app/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from urllib.parse import urlparse

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


class AgentError(RuntimeError):
Expand Down Expand Up @@ -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
Expand Down
78 changes: 69 additions & 9 deletions app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 (
Expand All @@ -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 '[]',
Expand Down Expand Up @@ -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(
Expand All @@ -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()

Expand Down Expand Up @@ -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")
Expand All @@ -182,15 +225,19 @@ 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,
idempotency_key,
raw_input,
sprint_id,
sprint_name,
role_tag,
assignee_display_name,
assignee_jira_account_id,
"received",
now,
now,
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion app/mcp_create_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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")

Expand Down
42 changes: 42 additions & 0 deletions app/roles.py
Original file line number Diff line number Diff line change
@@ -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()}"
7 changes: 6 additions & 1 deletion app/seed_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand All @@ -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}")

Expand Down
16 changes: 14 additions & 2 deletions app/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Loading