From 212e9eae55550c0cf1bbca398b323337fd83e54c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:54:46 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D1=8B=20?= =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D1=82=D0=B0=20=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B3=D0=B0,=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B2=20ensure=5Ffields=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- README.md | 11 ++- src/sdlc_hub/modules/jira/commands.py | 78 ++++++++++++++++++- src/sdlc_hub/modules/jira/settings.py | 6 +- .../fixtures/baseline/mcp_tools.expected.json | 2 +- .../fixtures/baseline/settings.expected.json | 3 + tests/test_commands.py | 53 +++++++++++++ tests/test_settings.py | 10 +++ 7 files changed, 154 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 048bc16..934c954 100644 --- a/README.md +++ b/README.md @@ -290,13 +290,16 @@ make web | Тип | Параметры | |---|---| | `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | -| `ensure_fields` | `key` и подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` | +| `ensure_fields` | `key` и подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee`, `story_points` (число ≥ 0 или `null` — очистить) | | `ensure_comment` | `key`, `marker`, `body` | | `ensure_status` | `key`, `target` (имя статуса или список-путь) | | `ensure_link` | `source`, `target`, `link_type`, `replace` | | `ensure_sprint` | `key`, `sprint` (имя или `backlog`); доску проекта задачи (`board_id`) дописывает хаб, от клиента параметр отвергается | | `ensure_attachment` | `key` (ключ или `new:`), `file` (sha256 из `POST /files`), `filename` (имя вложения в Jira, без `/`, `\`, `"` и управляющих символов); `size` хаб дописывает при выдаче агенту | | `refresh` | `keys` или `jql`; при выдаче агенту хаб дописывает `boards` — доски проектов | +| `ensure_sprint_meta` | `sprint` (имя), необязательные `rename_to`, `start_date`, `end_date` (`ГГГГ-ММ-ДД`), `goal`; `board_id` хаб дописывает при постановке; результат `{sprint_id, created}` | +| `ensure_sprint_state` | `sprint`, `state` (`active` или `closed`), `move_open_to` (имя спринта или `backlog`, обязателен при `closed`, при `active` — 422); результат `{sprint_id, moved: [ключ…]}` | +| `ensure_rank` | `key` и ровно одно из `before`, `after` (ключ другой задачи); результат `{changed}` | Тип `bug` — «Ошибка» в Jira, обязательные поля те же, что у задачи. Ставится, только если в настройках есть `issue_types.bug` с непустым `name` или `id`, иначе 422 «команда N: нет настройки issue_types.bug». `init` берёт его из @@ -315,7 +318,11 @@ make web ``` Ответ: `{id, estimate_seconds}`. Зазоры между записями в Jira по умолчанию: `ensure_issue` и `ensure_fields` 120 с, -`ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment` 30 с, `refresh` 2 с (настройка `pacing`). +`ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment`, `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` +30 с, `refresh` 2 с (настройка `pacing`). + +Команды спринта (`ensure_sprint_meta`, `ensure_sprint_state`) и `ensure_rank` ставятся только в пакете проекта (поле `project`): +доску определяет хаб по проекту пакета. Даты проверяются хабом: настоящая дата, начало не позже конца. `ensure_attachment` ставится, только если файл уже загружен (`POST /files`), иначе 422 «команда N: файл не загружен: POST /files». Агент ищет у задачи вложение с тем же именем и размером; нет — скачивает файл у хаба diff --git a/src/sdlc_hub/modules/jira/commands.py b/src/sdlc_hub/modules/jira/commands.py index eac51d6..d8c5e7b 100644 --- a/src/sdlc_hub/modules/jira/commands.py +++ b/src/sdlc_hub/modules/jira/commands.py @@ -1,7 +1,9 @@ """Контракт команд: каждая — желаемый результат на одной задаче Jira (specs/commands/spec.md). Здесь валидация, работа с ключами (в том числе временными new:), оценка длительности и проверка «результат уже виден в зеркале» для подтверждения по состоянию.""" +import math import re +from datetime import date from ...core.errors import CommandError # noqa: F401 (реэкспорт: прежнее место) from . import mirror @@ -20,9 +22,10 @@ SPEC = { "epic_key", "assignee"], "keys": ["temp_key", "epic_key"], "hints": {"temp_key": "new:", "issue_type": "task|epic|bug", "labels": "список", **FIELD_HINTS}}, - "ensure_fields": {"required": ["key"], "optional": FIELD_PARAMS + ["labels_add", "labels_remove"], + "ensure_fields": {"required": ["key"], "optional": FIELD_PARAMS + ["labels_add", "labels_remove", "story_points"], "keys": ["key", "epic_key"], - "hints": {**FIELD_HINTS, "labels_add": "список", "labels_remove": "список"}, + "hints": {**FIELD_HINTS, "labels_add": "список", "labels_remove": "список", + "story_points": "число ≥ 0 или null"}, "note": "хотя бы одно поле"}, "ensure_comment": {"required": ["key", "marker", "body"], "optional": [], "keys": ["key"], "hints": {"marker": "метка в начале комментария"}}, @@ -39,10 +42,22 @@ SPEC = { "hints": {"attachment_id": "id вложения из issue (цифры)"}}, "refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"], "hints": {"keys": "список"}, "note": "keys или jql"}, + "ensure_sprint_meta": {"required": ["sprint"], "optional": ["rename_to", "start_date", "end_date", "goal"], "keys": [], + "hints": {"sprint": "имя спринта", "rename_to": "новое имя", "start_date": "YYYY-MM-DD", + "end_date": "YYYY-MM-DD", "goal": "цель спринта"}}, + "ensure_sprint_state": {"required": ["sprint", "state"], "optional": ["move_open_to"], "keys": [], + "hints": {"state": "active|closed", + "move_open_to": "имя спринта или backlog; обязателен при closed"}}, + "ensure_rank": {"required": ["key"], "optional": ["before", "after"], "keys": ["key", "before", "after"], + "hints": {"before": "ключ задачи, выше которой", "after": "ключ задачи, ниже которой"}, + "note": "ровно одно из before, after"}, } WRITE_TYPES = [t for t in SPEC if t not in ("refresh", "fetch_attachment")] LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"} BOOL_PARAMS = {"replace"} +NUMBER_PARAMS = {"story_points"} +SPRINT_TYPES = ("ensure_sprint_meta", "ensure_sprint_state") # только в пакете проекта (check_batch) +DATE_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$") def is_temp(key) -> bool: @@ -75,11 +90,59 @@ def _type_error(ctype, name, value) -> str | None: return None if _is_str_list(value) else f"{name} — список строк" if name in BOOL_PARAMS: return None if isinstance(value, bool) else f"{name} — true или false" + if name in NUMBER_PARAMS: # конечное число ≥ 0; bool, NaN и Infinity — не число (R-15) + ok = isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) and value >= 0 + return None if ok else f"{name} — число ≥ 0 или null" if ctype == "ensure_status" and name == "target": return None if isinstance(value, str) or _is_str_list(value) else f"{name} — строка или список строк" return None if isinstance(value, str) else f"{name} — строка" +def _date(value) -> date | None: + """Строка ГГГГ-ММ-ДД, которая задаёт настоящую дату; иначе None.""" + if not isinstance(value, str) or not DATE_RE.match(value): + return None + try: + return date.fromisoformat(value) + except ValueError: + return None + + +def _rule_error(ctype, p) -> str | None: + """Смысловые проверки постановки (R-15): типы уже проверены в _type_error.""" + if ctype == "ensure_sprint_meta": + if not p["sprint"].strip(): + return "sprint — непустое имя" + if p.get("rename_to") is not None and not p["rename_to"].strip(): + return "rename_to — непустое имя" + for name in ("start_date", "end_date"): + if p.get(name) is not None and _date(p[name]) is None: + return f"{name} — дата ГГГГ-ММ-ДД" + start, end = _date(p.get("start_date")), _date(p.get("end_date")) + if start and end and start > end: + return "start_date позже end_date" + if ctype == "ensure_sprint_state": + if p["state"] not in ("active", "closed"): + return "state — active или closed" + move = p.get("move_open_to") + if p["state"] == "closed": + if move in (None, ""): + return "move_open_to обязателен при state = closed" + if move == p["sprint"]: + return "move_open_to — другой спринт" + elif move not in (None, ""): + return "move_open_to — только при state = closed" + if ctype == "ensure_rank": + before, after = p.get("before") or None, p.get("after") or None + if bool(before) == bool(after): + return "ровно одно из before, after" + if before == p["key"]: + return "before — другая задача" + if after == p["key"]: + return "after — другая задача" + return None + + def _is_str_list(value) -> bool: return isinstance(value, list) and all(isinstance(v, str) for v in value) @@ -132,6 +195,9 @@ def validate(commands) -> list[dict]: bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e] if bad: raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad)) + rule = _rule_error(ctype, params) + if rule: + raise CommandError(f"команда {i} ({ctype}): {rule}") if ctype == "ensure_fields" and not any(f in params for f in spec["optional"]): raise CommandError(f"команда {i}: ensure_fields без полей") if ctype == "refresh" and not (params.get("keys") or params.get("jql")): @@ -183,14 +249,18 @@ def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: """Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment, ensure_attachment и refresh по состоянию не подтверждаются: потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля (bridge-reliability, решение 4); совпадение темы с задачей неизвестного автора не доказательство; вложений в - зеркале нет (bug-and-attachments).""" + зеркале нет (bug-and-attachments); команды спринта и ранга в зеркале не видны (design #18). + story_points сравнивается точно: null равен только null, 0 не равен null (R-16).""" ctype, p = cmd["type"], cmd["params"] - if ctype in ("ensure_issue", "ensure_comment", "ensure_attachment", "fetch_attachment", "refresh"): + if ctype in ("ensure_issue", "ensure_comment", "ensure_attachment", "fetch_attachment", "refresh", "ensure_rank", + *SPRINT_TYPES): return False, None issue = mirror.get_issue(conn, p.get("key") or p.get("source")) if issue is None: return False, None if ctype == "ensure_fields": + if "story_points" in p and issue.get("story_points") != p["story_points"]: + return False, None for field in FIELD_PARAMS: if field in p and _norm_text(issue.get(field)) != _norm_text(p[field]): return False, None diff --git a/src/sdlc_hub/modules/jira/settings.py b/src/sdlc_hub/modules/jira/settings.py index 6f110e9..127390c 100644 --- a/src/sdlc_hub/modules/jira/settings.py +++ b/src/sdlc_hub/modules/jira/settings.py @@ -3,7 +3,8 @@ from ...core import settings as core_settings from . import projects DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30, - "ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "fetch_attachment": 2, "refresh": 2} + "ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "fetch_attachment": 2, "refresh": 2, + "ensure_sprint_meta": 30, "ensure_sprint_state": 30, "ensure_rank": 30} DEFAULTS = { "min_agent_version": 1, @@ -75,7 +76,8 @@ def pacing_from_config(p: dict) -> dict: """Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд.""" issue, comment = p.get("issue_seconds", 120), p.get("comment_seconds", 30) return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment, - "ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "fetch_attachment": 2, "refresh": 2} + "ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "fetch_attachment": 2, "refresh": 2, + "ensure_sprint_meta": comment, "ensure_sprint_state": comment, "ensure_rank": comment} def _issue_types(cfg: dict) -> dict: diff --git a/tests/fixtures/baseline/mcp_tools.expected.json b/tests/fixtures/baseline/mcp_tools.expected.json index dd6551e..77a7337 100644 --- a/tests/fixtures/baseline/mcp_tools.expected.json +++ b/tests/fixtures/baseline/mcp_tools.expected.json @@ -228,7 +228,7 @@ "name": "notes_set" }, { - "description": "Поставить пакет команд. Каждая команда — {\"type\": <тип>, \"params\": {...}}. Хвостовой refresh хаб добавит сам.\nПараметры по типам (необязательные — в [ ]):\n- ensure_issue: temp_key (new:), issue_type (task|epic|bug), summary; [description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), labels (список), epic_key (ключ или new:), assignee (логин Jira)]\n- ensure_fields: key; [summary, description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), epic_key (ключ или new:), assignee (логин Jira), labels_add (список), labels_remove (список)] — хотя бы одно поле\n- ensure_comment: key, marker (метка в начале комментария), body\n- ensure_status: key, target (статус или список-путь)\n- ensure_link: source, target, link_type (логическое имя из link_types); [replace (true|false)]\n- ensure_sprint: key, sprint (имя спринта или backlog)\n- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), filename (имя вложения)\n- fetch_attachment: key, attachment_id (id вложения из issue (цифры))\n- refresh: —; [keys (список), jql] — keys или jql\nПроект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб.\nensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит\nфайл в хаб и подставит file, filename по умолчанию — имя файла.", + "description": "Поставить пакет команд. Каждая команда — {\"type\": <тип>, \"params\": {...}}. Хвостовой refresh хаб добавит сам.\nПараметры по типам (необязательные — в [ ]):\n- ensure_issue: temp_key (new:), issue_type (task|epic|bug), summary; [description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), labels (список), epic_key (ключ или new:), assignee (логин Jira)]\n- ensure_fields: key; [summary, description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), epic_key (ключ или new:), assignee (логин Jira), labels_add (список), labels_remove (список), story_points (число ≥ 0 или null)] — хотя бы одно поле\n- ensure_comment: key, marker (метка в начале комментария), body\n- ensure_status: key, target (статус или список-путь)\n- ensure_link: source, target, link_type (логическое имя из link_types); [replace (true|false)]\n- ensure_sprint: key, sprint (имя спринта или backlog)\n- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), filename (имя вложения)\n- fetch_attachment: key, attachment_id (id вложения из issue (цифры))\n- refresh: —; [keys (список), jql] — keys или jql\n- ensure_sprint_meta: sprint (имя спринта); [rename_to (новое имя), start_date (YYYY-MM-DD), end_date (YYYY-MM-DD), goal (цель спринта)]\n- ensure_sprint_state: sprint, state (active|closed); [move_open_to (имя спринта или backlog; обязателен при closed)]\n- ensure_rank: key; [before (ключ задачи, выше которой), after (ключ задачи, ниже которой)] — ровно одно из before, after\nПроект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб.\nensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит\nфайл в хаб и подставит file, filename по умолчанию — имя файла.", "inputSchema": { "properties": { "commands": { diff --git a/tests/fixtures/baseline/settings.expected.json b/tests/fixtures/baseline/settings.expected.json index 2e3eaa1..88ea33d 100644 --- a/tests/fixtures/baseline/settings.expected.json +++ b/tests/fixtures/baseline/settings.expected.json @@ -38,7 +38,10 @@ "ensure_fields": 120, "ensure_issue": 120, "ensure_link": 30, + "ensure_rank": 30, "ensure_sprint": 30, + "ensure_sprint_meta": 30, + "ensure_sprint_state": 30, "ensure_status": 30, "fetch_attachment": 2, "refresh": 2 diff --git a/tests/test_commands.py b/tests/test_commands.py index b047460..3254a7e 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,3 +1,5 @@ +import re + import pytest from sdlc_hub.modules.jira import commands, mirror @@ -266,3 +268,54 @@ def test_fetch_not_confirmed_by_state(conn): assert (ok, result) == (False, None) assert "fetch_attachment" not in commands.WRITE_TYPES assert commands.SPEC["fetch_attachment"]["required"] == ["key", "attachment_id"] + + +META = {"type": "ensure_sprint_meta", "params": {"sprint": "AWPCA_Sprint_4", "start_date": "2026-10-28", "end_date": "2026-11-11", "goal": "свод ВГО"}} +def state(**p): return {"type": "ensure_sprint_state", "params": {"sprint": "AWPCA_Sprint_3_Corrections", **p}} +def rank(**p): return {"type": "ensure_rank", "params": {"key": "AURORA-1612", **p}} + +def test_new_types_validate_ok(): + out = commands.validate([META, state(state="active"), state(state="closed", move_open_to="backlog"), rank(before="AURORA-1601"), + {"type": "ensure_fields", "params": {"key": "A-1", "story_points": 2.5}}, + {"type": "ensure_fields", "params": {"key": "A-1", "story_points": None}}]) + assert [c["type"] for c in out][:4] == ["ensure_sprint_meta", "ensure_sprint_state", "ensure_sprint_state", "ensure_rank"] + +@pytest.mark.parametrize("cmd, msg", [ + ({**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"], "start_date": "2026-11-12"}}, "start_date позже end_date"), + ({"type": "ensure_sprint_meta", "params": {"sprint": " "}}, "sprint — непустое имя"), + ({"type": "ensure_sprint_meta", "params": {"sprint": "S", "rename_to": " "}}, "rename_to — непустое имя"), + ({"type": "ensure_sprint_meta", "params": {"sprint": "S", "board_id": 4216}}, "лишние параметры board_id"), + (state(state="open"), "state — active или closed"), + (state(state="closed"), "move_open_to обязателен при state = closed"), + (state(state="active", move_open_to="backlog"), "move_open_to — только при state = closed"), + (state(state="closed", move_open_to="AWPCA_Sprint_3_Corrections"), "move_open_to — другой спринт"), + (rank(), "ровно одно из before, after"), (rank(before="A-2", after="A-3"), "ровно одно из before, after"), + (rank(before="AURORA-1612"), "before — другая задача"), (rank(after="AURORA-1612"), "after — другая задача"), + ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": -1}}, "story_points — число ≥ 0 или null"), + ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": "3"}}, "story_points — число ≥ 0 или null"), + ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": True}}, "story_points — число ≥ 0 или null"), + ({"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"), +]) +def test_new_types_errors(cmd, msg): + with pytest.raises(CommandError, match=re.escape(msg)): + commands.validate([cmd]) + +def test_rank_temp_keys(): + ok = [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Н"}}, rank(after="new:1")] + assert commands.temp_keys_used(commands.validate(ok)[1]) == ["new:1"] + with pytest.raises(CommandError, match="временный ключ new:2 не объявлен"): + commands.validate([rank(before="new:2")]) + +def test_new_types_not_confirmed_by_state(conn): # design #18 + for cmd in (META, state(state="active"), rank(before="A-2")): + assert commands.check_confirmed(conn, cmd, {}) == (False, None) + +def test_story_points_confirmation(conn): # R-16 + mirror.apply_state(conn, [{"key": "A-1", "labels": [], "story_points": 3}, {"key": "A-2", "labels": []}], []) + sp = lambda key, v: {"type": "ensure_fields", "params": {"key": key, "story_points": v}} + 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", None), {})[0] is True diff --git a/tests/test_settings.py b/tests/test_settings.py index ecf0971..50c6f20 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -128,3 +128,13 @@ def test_pacing_fallback_for_new_type(conn): settings.set_(conn, "pacing", {"ensure_fields": 5}) assert jira_settings.pacing(conn)["fetch_attachment"] == 2 settings.validate("pacing", {"fetch_attachment": 3}) + + +def test_sprint_types_pacing(conn): + for t in ("ensure_sprint_meta", "ensure_sprint_state", "ensure_rank"): + assert jira_settings.DEFAULT_PACING[t] == 30 and jira_settings.pacing(conn)[t] == 30 + assert jira_settings.pacing_from_config({"comment_seconds": 45})[t] == 45 + settings.set_(conn, "pacing", {"ensure_fields": 5}) # тип не в сохранённой настройке — умолчание + assert jira_settings.pacing(conn)["ensure_rank"] == 30 + settings.validate("pacing", {"ensure_rank": 10}) + assert queue.estimate(conn, ["ensure_sprint_meta", "ensure_sprint_state", "ensure_rank"]) == 90