From d4724be6c52e8d057c337076c5573d05aa4c5ec8 Mon Sep 17 00:00:00 2001 From: "Leo.Kim" <83855438+tpals0409@users.noreply.github.com> Date: Thu, 23 Jul 2026 03:06:38 +0000 Subject: [PATCH] feat(S15P11A705-14): edit draft tickets in preview --- app/database.py | 17 +++++++ app/main.py | 45 ++++++++++++++++- app/roles.py | 3 +- app/static/app.js | 101 +++++++++++++++++++++++++++++++------- app/static/style.css | 5 +- tests/test_api.py | 27 ++++++++++ tests/test_ui_contract.py | 4 ++ 7 files changed, 182 insertions(+), 20 deletions(-) diff --git a/app/database.py b/app/database.py index f691f01..e20cd3a 100644 --- a/app/database.py +++ b/app/database.py @@ -300,6 +300,23 @@ def claim_confirmation(self, submission_id: str, user_id: int) -> bool: ) return cursor.rowcount == 1 + def update_planned_tasks( + self, submission_id: str, user_id: int, tasks: list[dict[str, Any]] + ) -> bool: + with self.connect() as conn: + cursor = conn.execute( + """UPDATE submissions SET planned_tasks_json=?, updated_at=? + WHERE id=? AND user_id=? AND state='organizing' + AND planned_tasks_json != '[]'""", + ( + json.dumps(tasks, ensure_ascii=False), + int(time.time()), + submission_id, + user_id, + ), + ) + return cursor.rowcount == 1 + def add_ticket(self, submission_id: str, issue_key: str, summary: str, url: str) -> None: with self.connect() as conn: cursor = conn.execute( diff --git a/app/main.py b/app/main.py index 9bbc19f..cc9b039 100644 --- a/app/main.py +++ b/app/main.py @@ -18,7 +18,7 @@ from .alerts import MattermostAlerter from .config import Settings from .database import Database, IdempotencyConflict -from .roles import validate_role_tag +from .roles import ticket_description, ticket_summary, validate_role_tag from .security import hash_password, new_token, token_digest, verify_password from .worker import SubmissionWorker @@ -80,6 +80,15 @@ class SubmissionRequest(BaseModel): sprint_id: int = Field(gt=0) +class DraftTaskRequest(BaseModel): + summary: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=5000) + + +class DraftUpdateRequest(BaseModel): + tasks: list[DraftTaskRequest] = Field(min_length=1, max_length=20) + + def create_app( settings: Settings | None = None, *, @@ -316,6 +325,40 @@ def confirm_submission( raise HTTPException(status_code=409, detail="티켓 정보를 다시 확인해주세요") return {"submission_id": submission_id} + @app.put("/api/submissions/{submission_id}/draft") + def update_submission_draft( + submission_id: str, + payload: DraftUpdateRequest, + cowork_session: str | None = Cookie(default=None), + x_csrf_token: str | None = Header(default=None), + ) -> dict[str, Any]: + user, csrf_token, _ = session_context(cowork_session) + require_csrf(csrf_token, x_csrf_token) + try: + uuid.UUID(submission_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail="요청을 찾을 수 없습니다") from exc + submission = database.get_submission(submission_id, user["id"]) + if not submission: + raise HTTPException(status_code=404, detail="요청을 찾을 수 없습니다") + if submission["state"] != "organizing" or not submission["preview"]: + raise HTTPException(status_code=409, detail="더 이상 수정할 수 없는 티켓입니다") + if len(payload.tasks) != len(submission["preview"]): + raise HTTPException(status_code=409, detail="티켓 개수는 변경할 수 없습니다") + try: + tasks = [ + { + "summary": ticket_summary(task.summary, submission["role_tag"]), + "description": ticket_description(task.description), + } + for task in payload.tasks + ] + except ValueError as exc: + raise HTTPException(status_code=400, detail="티켓 제목을 확인해주세요") from exc + if not database.update_planned_tasks(submission_id, user["id"], tasks): + raise HTTPException(status_code=409, detail="더 이상 수정할 수 없는 티켓입니다") + return {"preview": tasks} + @app.get("/api/submissions/{submission_id}") def submission_status( submission_id: str, diff --git a/app/roles.py b/app/roles.py index fd4897c..188d60e 100644 --- a/app/roles.py +++ b/app/roles.py @@ -39,4 +39,5 @@ def ticket_summary(summary: str, role_tag: str) -> str: def ticket_description(description: str | None) -> str | None: if not description or not description.strip(): return None - return f"## 작업 내용\n{description.strip()}" + body = re.sub(r"^##\s*작업 내용\s*", "", description.strip()).strip() + return f"## 작업 내용\n{body}" if body else None diff --git a/app/static/app.js b/app/static/app.js index a91e146..649b803 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -21,6 +21,7 @@ const previewRoleTag = document.querySelector('#preview-role-tag'); let csrfToken = ''; let creating = false; +let editingPreview = false; let pendingConfirmation = null; const receipts = []; const pendingStorageKey = 'cowork_pending_request'; @@ -176,21 +177,51 @@ function hidePreview() { previewRoleTag.textContent = ''; } -function showPreview(submissionId, sentText, sprint, assignee, tasks) { +function descriptionBody(description) { + return (description || '').replace(/^##\s*작업 내용\s*/, '').trim(); +} + +function summaryBody(summary) { + return (summary || '').replace(/^\s*\[(?:FE|BE|Infra|AI)\]\s*/i, '').trim(); +} + +function renderPreviewTasks(tasks, editing = false) { previewList.replaceChildren(); - for (const task of tasks) { + tasks.forEach((task, index) => { const row = document.createElement('li'); - const summary = document.createElement('strong'); - summary.textContent = task.summary; - row.append(summary); - if (task.description) { + if (editing) { + const summary = document.createElement('input'); + summary.className = 'preview-summary-input'; + summary.value = summaryBody(task.summary); + summary.maxLength = 255; + summary.required = true; + summary.setAttribute('aria-label', `${index + 1}번 티켓 제목`); + const description = document.createElement('textarea'); + description.className = 'preview-description-input'; + description.value = descriptionBody(task.description); + description.maxLength = 5000; + description.setAttribute('aria-label', `${index + 1}번 티켓 설명`); + row.append(summary, description); + } else { + const summary = document.createElement('strong'); + summary.textContent = task.summary; + row.append(summary); + } + if (!editing && task.description) { const description = document.createElement('p'); description.textContent = task.description; row.append(description); } previewList.append(row); - } - pendingConfirmation = {submissionId, sentText}; + }); +} + +function showPreview(submissionId, sentText, sprint, assignee, tasks) { + renderPreviewTasks(tasks); + pendingConfirmation = {submissionId, sentText, sprint, assignee, tasks}; + editingPreview = false; + editButton.textContent = '수정하기'; + confirmButton.disabled = false; previewSprintName.textContent = sprint?.name || ''; previewAssigneeName.textContent = assignee?.display_name || ''; previewRoleTag.textContent = assignee?.role_tag || ''; @@ -205,6 +236,7 @@ function showPreview(submissionId, sentText, sprint, assignee, tasks) { function finishCreation() { creating = false; + editingPreview = false; pendingConfirmation = null; hidePreview(); todoInput.disabled = false; @@ -259,18 +291,53 @@ async function pollSubmission(submissionId, sentText) { } } -editButton.addEventListener('click', () => { - pendingConfirmation = null; - hidePreview(); - todoInput.disabled = false; - sprintSelect.disabled = false; - createButton.disabled = !sprintSelect.value; - progress.textContent = ''; - todoInput.focus(); +editButton.addEventListener('click', async () => { + if (!pendingConfirmation || creating) return; + if (!editingPreview) { + editingPreview = true; + renderPreviewTasks(pendingConfirmation.tasks, true); + editButton.textContent = '수정 완료'; + confirmButton.disabled = true; + previewList.querySelector('input')?.focus(); + return; + } + const summaries = [...previewList.querySelectorAll('.preview-summary-input')]; + const descriptions = [...previewList.querySelectorAll('.preview-description-input')]; + const tasks = summaries.map((summary, index) => ({ + summary: summary.value.trim(), + description: descriptions[index].value.trim() || null, + })); + if (tasks.some((task) => !task.summary)) { + createError.textContent = '모든 티켓의 제목을 입력해주세요'; + createError.hidden = false; + return; + } + editButton.disabled = true; + createError.hidden = true; + try { + const result = await jsonFetch( + `/api/submissions/${pendingConfirmation.submissionId}/draft`, + { + method: 'PUT', + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken}, + body: JSON.stringify({tasks}), + }, + ); + pendingConfirmation.tasks = result.preview; + renderPreviewTasks(result.preview); + editingPreview = false; + editButton.textContent = '수정하기'; + confirmButton.disabled = false; + } catch (error) { + createError.textContent = error.message; + createError.hidden = false; + } finally { + editButton.disabled = false; + } }); confirmButton.addEventListener('click', async () => { - if (creating || !pendingConfirmation) return; + if (creating || editingPreview || !pendingConfirmation) return; const pending = pendingConfirmation; creating = true; createError.hidden = true; diff --git a/app/static/style.css b/app/static/style.css index 92c18e1..8834600 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -49,7 +49,10 @@ input:focus, textarea:focus { box-shadow: 0 0 0 3px rgba(47,100,255,.14); } #preview-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; } #preview-list li { padding: 15px 16px; border: 1px solid #e1e6ed; border-radius: 10px; background: #f9fafc; } #preview-list strong { display: block; line-height: 1.5; } -#preview-list p { margin: 7px 0 0; color: #566174; line-height: 1.55; white-space: pre-wrap; } +#preview-list p { margin: 8px 0 0; color: #596579; white-space: pre-wrap; line-height: 1.55; } +.preview-summary-input { width: 100%; border: 1px solid #cbd4e1; border-radius: 8px; padding: 10px 11px; font-weight: 700; color: #263248; background: #fff; } +.preview-description-input { width: 100%; min-height: 96px; margin-top: 9px; border: 1px solid #cbd4e1; border-radius: 8px; padding: 10px 11px; resize: vertical; color: #45536a; background: #fff; } +.preview-summary-input:focus, .preview-description-input:focus { outline: 2px solid #9eb8ff; border-color: #5f82dd; } .preview-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; } .secondary { border: 1px solid #cfd6df; border-radius: 9px; background: #fff; color: #364153; padding: 11px 18px; font-weight: 700; } .secondary:hover { background: #f3f5f8; } diff --git a/tests/test_api.py b/tests/test_api.py index 28e2f13..3b57913 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -33,6 +33,12 @@ def prepare(self, submission_id: str) -> None: def create(self, submission_id: str) -> None: self.create_calls += 1 submission = self.database.get_submission_for_worker(submission_id) + assert submission["planned_tasks"] == [ + { + "summary": "[BE] 결제 연동 오류 수정", + "description": "## 작업 내용\n재현 절차를 확인한다", + } + ] self.database.add_ticket( submission_id, "S15P11A705-101", @@ -129,8 +135,29 @@ def test_submission_requires_preview_confirmation_before_ticket_creation(client_ assert preview["preview"] == [ {"summary": "결제 연동 완료", "description": None} ] + assert preview["assignee"] == {"display_name": "김팀원", "role_tag": "BE"} assert preview["tickets"] == [] + edited = client.put( + f"/api/submissions/{submission_id}/draft", + json={ + "tasks": [ + { + "summary": "[FE] 결제 연동 오류 수정", + "description": "재현 절차를 확인한다", + } + ] + }, + headers={"X-CSRF-Token": csrf}, + ) + assert edited.status_code == 200 + assert edited.json()["preview"] == [ + { + "summary": "[BE] 결제 연동 오류 수정", + "description": "## 작업 내용\n재현 절차를 확인한다", + } + ] + confirmed = client.post( f"/api/submissions/{submission_id}/confirm", headers={"X-CSRF-Token": csrf}, diff --git a/tests/test_ui_contract.py b/tests/test_ui_contract.py index 0794886..77bc768 100644 --- a/tests/test_ui_contract.py +++ b/tests/test_ui_contract.py @@ -21,6 +21,7 @@ def test_screen_contract_and_forbidden_vocabulary(): "티켓 정보 확인", "아래 내용으로 Jira 티켓이 만들어집니다", "수정하기", + "수정 완료", "확인하고 만들기", "활성 스프린트", "활성 스프린트를 선택해주세요", @@ -35,6 +36,9 @@ def test_screen_contract_and_forbidden_vocabulary(): assert 'id="preview-assignee-name"' in html assert 'id="preview-role-tag"' in html assert "/confirm" in script + assert "/draft" in script + assert "preview-summary-input" in script + assert "preview-description-input" in script for forbidden in ("이슈", "이슈 타입", "백로그", "에픽", "스토리", "하위 작업", "어사인", "담당자 배정"): assert forbidden not in visible_contract