Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5fbfe3a8d4
commit
abbcf0cc1f
@ -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}")
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 и др.)"),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user