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
17 changes: 17 additions & 0 deletions app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
45 changes: 44 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion app/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
101 changes: 84 additions & 17 deletions app/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 || '';
Expand All @@ -205,6 +236,7 @@ function showPreview(submissionId, sentText, sprint, assignee, tasks) {

function finishCreation() {
creating = false;
editingPreview = false;
pendingConfirmation = null;
hidePreview();
todoInput.disabled = false;
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion app/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
27 changes: 27 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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},
Expand Down
4 changes: 4 additions & 0 deletions tests/test_ui_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def test_screen_contract_and_forbidden_vocabulary():
"티켓 정보 확인",
"아래 내용으로 Jira 티켓이 만들어집니다",
"수정하기",
"수정 완료",
"확인하고 만들기",
"활성 스프린트",
"활성 스프린트를 선택해주세요",
Expand All @@ -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
Expand Down