Команды: параметры обрезаются при постановке, имя вложения — NFC (#129, #111 T-F1, #49 M-4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-26 02:20:17 +04:00
parent 5fbfe3a8d4
commit abbcf0cc1f
6 changed files with 122 additions and 5 deletions

View File

@ -3,6 +3,7 @@
проверка «результат уже виден в зеркале» для подтверждения по состоянию.""" проверка «результат уже виден в зеркале» для подтверждения по состоянию."""
import math import math
import re import re
import unicodedata
from datetime import date from datetime import date
from ...core.errors import CommandError # noqa: F401 (реэкспорт: прежнее место) from ...core.errors import CommandError # noqa: F401 (реэкспорт: прежнее место)
@ -56,6 +57,7 @@ WRITE_TYPES = [t for t in SPEC if t not in ("refresh", "fetch_attachment")]
LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"} LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"}
BOOL_PARAMS = {"replace"} BOOL_PARAMS = {"replace"}
NUMBER_PARAMS = {"story_points"} NUMBER_PARAMS = {"story_points"}
TEXT_PARAMS = {"summary", "description", "body", "goal"} # содержательный текст: пробелы и отступы значимы
SPRINT_TYPES = ("ensure_sprint_meta", "ensure_sprint_state") # только в пакете проекта (check_batch) SPRINT_TYPES = ("ensure_sprint_meta", "ensure_sprint_state") # только в пакете проекта (check_batch)
DATE_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$") DATE_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$")
@ -105,6 +107,33 @@ def _type_error(ctype, name, value) -> str | None:
return None if isinstance(value, str) else f"{name} — строка" return None if isinstance(value, str) else f"{name} — строка"
def normalize(ctype, params: dict) -> dict:
"""Новый словарь параметров: строки и элементы списков обрезаны (кроме TEXT_PARAMS), filename — NFC (#129).
Нестроковые значения остаются как есть; вход не меняется."""
def trim(v):
return v.strip() if isinstance(v, str) else v
out = {}
for name, value in params.items():
if name in TEXT_PARAMS:
out[name] = value
elif name == "filename" and isinstance(value, str):
out[name] = unicodedata.normalize("NFC", value.strip())
elif isinstance(value, list):
out[name] = [trim(v) for v in value]
else:
out[name] = trim(value)
return out
def _blank_item_error(ctype, params) -> str | None:
for name, value in params.items():
is_list = name in LIST_PARAMS or (ctype == "ensure_status" and name == "target")
if is_list and isinstance(value, list) and any(v == "" for v in value):
return f"{name} — без пустых элементов"
return None
def _date(value) -> date | None: def _date(value) -> date | None:
"""Строка ГГГГ-ММ-ДД, которая задаёт настоящую дату; иначе None.""" """Строка ГГГГ-ММ-ДД, которая задаёт настоящую дату; иначе None."""
if not isinstance(value, str) or not DATE_RE.match(value): if not isinstance(value, str) or not DATE_RE.match(value):
@ -205,6 +234,13 @@ def validate(commands) -> list[dict]:
bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e] bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e]
if bad: if bad:
raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad)) raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad))
params = normalize(ctype, params)
missing = [f for f in spec["required"] if params.get(f) in (None, "", [])]
if missing:
raise CommandError(f"команда {i} ({ctype}): нет {', '.join(missing)}; {_expected(spec)}")
blank = _blank_item_error(ctype, params)
if blank:
raise CommandError(f"команда {i} ({ctype}): {blank}")
rule = _rule_error(ctype, params) rule = _rule_error(ctype, params)
if rule: if rule:
raise CommandError(f"команда {i} ({ctype}): {rule}") raise CommandError(f"команда {i} ({ctype}): {rule}")

View File

@ -6,6 +6,7 @@ import base64
import json import json
import stat import stat
import time import time
import unicodedata
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
@ -348,7 +349,8 @@ async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str:
found = [a for a in atts if str(a["attachment_id"]) == ref] if ref.isdigit() else [] found = [a for a in atts if str(a["attachment_id"]) == ref] if ref.isdigit() else []
prefix = "" prefix = ""
if not found: if not found:
found = [a for a in atts if a["filename"] == attachment.strip()] # хаб отдаёт вложения новыми сверху want = unicodedata.normalize("NFC", attachment.strip())
found = [a for a in atts if unicodedata.normalize("NFC", a["filename"]) == want] # хаб отдаёт вложения новыми сверху
if len(found) > 1: if len(found) > 1:
prefix = f"у {key} {len(found)} вложений «{found[0]['filename']}», взято самое новое #{found[0]['attachment_id']}\n" prefix = f"у {key} {len(found)} вложений «{found[0]['filename']}», взято самое новое #{found[0]['attachment_id']}\n"
if not found: if not found:
@ -478,7 +480,8 @@ def _attach_files(client, commands):
params = {k: v for k, v in cmd["params"].items() if k != "path"} params = {k: v for k, v in cmd["params"].items() if k != "path"}
up = client.post("/files", {"content": base64.b64encode(read[i]).decode("ascii")}) up = client.post("/files", {"content": base64.b64encode(read[i]).decode("ascii")})
params["file"] = up["file"] params["file"] = up["file"]
params["filename"] = params.get("filename") or Path(cmd["params"]["path"]).expanduser().name params["filename"] = params.get("filename") or unicodedata.normalize(
"NFC", Path(cmd["params"]["path"]).expanduser().name)
out.append({**cmd, "params": params}) out.append({**cmd, "params": params})
return out return out

View File

@ -1270,3 +1270,12 @@ def test_agent_state_empty_batch_with_boards(hub):
assert c.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 1 assert c.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 1
finally: finally:
c.close() c.close()
def test_rank_before_trimmed_on_claim(hub):
hub["agent"].post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс", "labels": []},
{"key": "X-2", "summary": "Два", "labels": []}], "links": []})
hub["client"].post("/batches", {"title": "r", "commands": [
{"type": "ensure_rank", "params": {"key": "X-1", "before": " X-2 "}}]})
cmd = hub["agent"].post("/agent/claim", {})["command"]
assert cmd["type"] == "ensure_rank" and cmd["params"]["before"] == "X-2"

View File

@ -1,4 +1,5 @@
import re import re
import unicodedata
import pytest import pytest
@ -284,7 +285,7 @@ def test_new_types_validate_ok():
({**META, "params": {**META["params"], "start_date": "28.10.2026"}}, "start_date — дата ГГГГ-ММ-ДД"), ({**META, "params": {**META["params"], "start_date": "28.10.2026"}}, "start_date — дата ГГГГ-ММ-ДД"),
({**META, "params": {**META["params"], "end_date": "2026-02-30"}}, "end_date — дата ГГГГ-ММ-ДД"), ({**META, "params": {**META["params"], "end_date": "2026-02-30"}}, "end_date — дата ГГГГ-ММ-ДД"),
({**META, "params": {**META["params"], "start_date": "2026-11-12"}}, "start_date позже end_date"), ({**META, "params": {**META["params"], "start_date": "2026-11-12"}}, "start_date позже end_date"),
({"type": "ensure_sprint_meta", "params": {"sprint": " "}}, "sprint — непустое имя"), ({"type": "ensure_sprint_meta", "params": {"sprint": " "}}, "нет sprint; ожидаются sprint"),
({"type": "ensure_sprint_meta", "params": {"sprint": "S", "rename_to": " "}}, "rename_to — непустое имя"), ({"type": "ensure_sprint_meta", "params": {"sprint": "S", "rename_to": " "}}, "rename_to — непустое имя"),
({"type": "ensure_sprint_meta", "params": {"sprint": "S", "board_id": 4216}}, "лишние параметры board_id"), ({"type": "ensure_sprint_meta", "params": {"sprint": "S", "board_id": 4216}}, "лишние параметры board_id"),
(state(state="open"), "state — active или closed"), (state(state="open"), "state — active или closed"),
@ -299,7 +300,7 @@ def test_new_types_validate_ok():
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("nan")}}, "story_points — число ≥ 0 или null"), # Review Focus 5 ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("nan")}}, "story_points — число ≥ 0 или null"), # Review Focus 5
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("inf")}}, "story_points — число ≥ 0 или null"), ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("inf")}}, "story_points — число ≥ 0 или null"),
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": 10**400}}, "story_points — число ≥ 0 или null"), # OverflowError ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": 10**400}}, "story_points — число ≥ 0 или null"), # OverflowError
({"type": "ensure_sprint_state", "params": {"sprint": " ", "state": "active"}}, "sprint — непустое имя"), ({"type": "ensure_sprint_state", "params": {"sprint": " ", "state": "active"}}, "нет sprint; ожидаются sprint, state"),
(state(state="closed", move_open_to=" "), "move_open_to обязателен при state = closed"), (state(state="closed", move_open_to=" "), "move_open_to обязателен при state = closed"),
(state(state="closed", move_open_to=" AWPCA_Sprint_3_Corrections "), "move_open_to — другой спринт"), (state(state="closed", move_open_to=" AWPCA_Sprint_3_Corrections "), "move_open_to — другой спринт"),
(state(state="active", move_open_to="x"), "move_open_to — только при state = closed"), (state(state="active", move_open_to="x"), "move_open_to — только при state = closed"),
@ -330,3 +331,50 @@ def test_story_points_confirmation(conn): # R-16
assert commands.check_confirmed(conn, sp("A-1", 3), {})[0] is True assert commands.check_confirmed(conn, sp("A-1", 3), {})[0] is True
assert commands.check_confirmed(conn, sp("A-2", 0), {})[0] is False assert commands.check_confirmed(conn, sp("A-2", 0), {})[0] is False
assert commands.check_confirmed(conn, sp("A-2", None), {})[0] is True assert commands.check_confirmed(conn, sp("A-2", None), {})[0] is True
def test_normalize_trims_identifiers_keeps_text():
out = commands.validate([{"type": "ensure_comment", "params": {"key": " A-1", "marker": "Итог ", "body": " отступ\n"}}])
assert out[0]["params"] == {"key": "A-1", "marker": "Итог", "body": " отступ\n"}
def test_normalize_rank_before():
out = commands.validate([{"type": "ensure_rank", "params": {"key": "A-1", "before": " A-2 "}}])
assert out[0]["params"] == {"key": "A-1", "before": "A-2"}
def test_normalize_temp_key_with_spaces():
out = commands.validate([
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Т"}},
{"type": "ensure_fields", "params": {"key": "A-5", "epic_key": " new:1 "}},
{"type": "refresh", "params": {"keys": [" new:1"]}}])
assert out[1]["params"]["epic_key"] == "new:1" and out[2]["params"]["keys"] == ["new:1"]
def test_normalize_blank_required_is_missing():
with pytest.raises(CommandError, match=r"^команда 1 \(ensure_status\): нет key; ожидаются key, target"):
commands.validate([{"type": "ensure_status", "params": {"key": " ", "target": "Готово"}}])
def test_normalize_blank_list_item():
with pytest.raises(CommandError, match="labels_add — без пустых элементов"):
commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "labels_add": ["x", " "]}}])
def test_normalize_filename_nfc():
nfd = unicodedata.normalize("NFD", "отчёт.pdf")
assert nfd != unicodedata.normalize("NFC", "отчёт.pdf")
out = commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": "a" * 64, "filename": f" {nfd} "}}])
assert out[0]["params"]["filename"] == unicodedata.normalize("NFC", "отчёт.pdf")
def test_normalize_does_not_mutate_input():
params = {"key": " A-1 ", "target": [" В работе ", "Готово "]}
out = commands.validate([{"type": "ensure_status", "params": params}])
assert params["key"] == " A-1 " and out[0]["params"]["target"] == ["В работе", "Готово"]
def test_normalize_summary_description_goal_untouched():
out = commands.validate([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task",
"summary": " Т ", "description": " d\n"}}])
assert out[0]["params"]["summary"] == " Т " and out[0]["params"]["description"] == " d\n"

View File

@ -1,5 +1,6 @@
import inspect import inspect
import asyncio import asyncio
import unicodedata
import hashlib import hashlib
import json import json
import os import os
@ -952,3 +953,23 @@ def test_batch_create_description_lists_sprint_types():
doc = jmcp.batch_create.__doc__ doc = jmcp.batch_create.__doc__
for word in ("ensure_sprint_meta", "ensure_sprint_state", "ensure_rank", "story_points", "refresh по JQL проекта"): for word in ("ensure_sprint_meta", "ensure_sprint_state", "ensure_rank", "story_points", "refresh по JQL проекта"):
assert word in doc assert word in doc
def test_batch_create_default_filename_nfc(live, tmp_path):
"""M-4 #49: имя по умолчанию из пути (NFD с macOS) приводится к NFC."""
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], "links": []})
nfd = unicodedata.normalize("NFD", "отчёт.pdf")
f = tmp_path / nfd
f.write_bytes(b"pdf")
out = jmcp.batch_create("файл", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(f)}}])
assert _batch_commands(out)[0]["params"]["filename"] == unicodedata.normalize("NFC", "отчёт.pdf")
@pytest.mark.parametrize("stored, asked", [("NFD", "NFC"), ("NFC", "NFD")])
def test_attachment_name_match_normalized(downloads, monkeypatch, stored, asked):
name = "отчёт.pdf"
att = _att("31337", unicodedata.normalize(stored, name), 3, state="ready")
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "ready", "command_id": 5}, polls=[att], data=b"abc")
_use(monkeypatch, hub)
out = asyncio.run(jmcp.attachment(ATT_KEY, unicodedata.normalize(asked, name)))
assert not out.startswith("у ") and ("POST", "/attachments/31337/fetch", {"author": "claude"}) in hub.calls

View File

@ -601,7 +601,7 @@ def test_attachment_without_file_rejected(conn, store):
@pytest.mark.parametrize("params, msg", [ @pytest.mark.parametrize("params, msg", [
({"file": SHA.upper()}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"), ({"file": SHA.upper()}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"),
({"file": SHA[:63]}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"), ({"file": SHA[:63]}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"),
({"filename": " "}, "команда 1: filename пустой"), ({"filename": " "}, "команда 1 (ensure_attachment): нет filename; ожидаются key, file, filename"),
({"filename": "docs/ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"), ({"filename": "docs/ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"),
({"filename": "C:\\ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"), ({"filename": "C:\\ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"),
({"filename": 'ОСВ "60".xlsx'}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"), ({"filename": 'ОСВ "60".xlsx'}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"),