Команды спринта и ранга, оценка в ensure_fields (#108)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bb133081b1
commit
212e9eae55
11
README.md
11
README.md
@ -290,13 +290,16 @@ make web
|
|||||||
| Тип | Параметры |
|
| Тип | Параметры |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ensure_issue` | `temp_key` (`new:<n>`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` |
|
| `ensure_issue` | `temp_key` (`new:<n>`), `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_comment` | `key`, `marker`, `body` |
|
||||||
| `ensure_status` | `key`, `target` (имя статуса или список-путь) |
|
| `ensure_status` | `key`, `target` (имя статуса или список-путь) |
|
||||||
| `ensure_link` | `source`, `target`, `link_type`, `replace` |
|
| `ensure_link` | `source`, `target`, `link_type`, `replace` |
|
||||||
| `ensure_sprint` | `key`, `sprint` (имя или `backlog`); доску проекта задачи (`board_id`) дописывает хаб, от клиента параметр отвергается |
|
| `ensure_sprint` | `key`, `sprint` (имя или `backlog`); доску проекта задачи (`board_id`) дописывает хаб, от клиента параметр отвергается |
|
||||||
| `ensure_attachment` | `key` (ключ или `new:<n>`), `file` (sha256 из `POST /files`), `filename` (имя вложения в Jira, без `/`, `\`, `"` и управляющих символов); `size` хаб дописывает при выдаче агенту |
|
| `ensure_attachment` | `key` (ключ или `new:<n>`), `file` (sha256 из `POST /files`), `filename` (имя вложения в Jira, без `/`, `\`, `"` и управляющих символов); `size` хаб дописывает при выдаче агенту |
|
||||||
| `refresh` | `keys` или `jql`; при выдаче агенту хаб дописывает `boards` — доски проектов |
|
| `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, обязательные поля те же, что у задачи. Ставится, только если в настройках есть
|
Тип `bug` — «Ошибка» в Jira, обязательные поля те же, что у задачи. Ставится, только если в настройках есть
|
||||||
`issue_types.bug` с непустым `name` или `id`, иначе 422 «команда N: нет настройки issue_types.bug». `init` берёт его из
|
`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 с,
|
Ответ: `{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: файл <sha> не
|
`ensure_attachment` ставится, только если файл уже загружен (`POST /files`), иначе 422 «команда N: файл <sha> не
|
||||||
загружен: POST /files». Агент ищет у задачи вложение с тем же именем и размером; нет — скачивает файл у хаба
|
загружен: POST /files». Агент ищет у задачи вложение с тем же именем и размером; нет — скачивает файл у хаба
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
"""Контракт команд: каждая — желаемый результат на одной задаче Jira (specs/commands/spec.md).
|
"""Контракт команд: каждая — желаемый результат на одной задаче Jira (specs/commands/spec.md).
|
||||||
Здесь валидация, работа с ключами (в том числе временными new:<n>), оценка длительности и
|
Здесь валидация, работа с ключами (в том числе временными new:<n>), оценка длительности и
|
||||||
проверка «результат уже виден в зеркале» для подтверждения по состоянию."""
|
проверка «результат уже виден в зеркале» для подтверждения по состоянию."""
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from ...core.errors import CommandError # noqa: F401 (реэкспорт: прежнее место)
|
from ...core.errors import CommandError # noqa: F401 (реэкспорт: прежнее место)
|
||||||
from . import mirror
|
from . import mirror
|
||||||
@ -20,9 +22,10 @@ SPEC = {
|
|||||||
"epic_key", "assignee"],
|
"epic_key", "assignee"],
|
||||||
"keys": ["temp_key", "epic_key"],
|
"keys": ["temp_key", "epic_key"],
|
||||||
"hints": {"temp_key": "new:<n>", "issue_type": "task|epic|bug", "labels": "список", **FIELD_HINTS}},
|
"hints": {"temp_key": "new:<n>", "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"],
|
"keys": ["key", "epic_key"],
|
||||||
"hints": {**FIELD_HINTS, "labels_add": "список", "labels_remove": "список"},
|
"hints": {**FIELD_HINTS, "labels_add": "список", "labels_remove": "список",
|
||||||
|
"story_points": "число ≥ 0 или null"},
|
||||||
"note": "хотя бы одно поле"},
|
"note": "хотя бы одно поле"},
|
||||||
"ensure_comment": {"required": ["key", "marker", "body"], "optional": [], "keys": ["key"],
|
"ensure_comment": {"required": ["key", "marker", "body"], "optional": [], "keys": ["key"],
|
||||||
"hints": {"marker": "метка в начале комментария"}},
|
"hints": {"marker": "метка в начале комментария"}},
|
||||||
@ -39,10 +42,22 @@ SPEC = {
|
|||||||
"hints": {"attachment_id": "id вложения из issue (цифры)"}},
|
"hints": {"attachment_id": "id вложения из issue (цифры)"}},
|
||||||
"refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"], "hints": {"keys": "список"},
|
"refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"], "hints": {"keys": "список"},
|
||||||
"note": "keys или jql"},
|
"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")]
|
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"}
|
||||||
|
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:
|
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} — список строк"
|
return None if _is_str_list(value) else f"{name} — список строк"
|
||||||
if name in BOOL_PARAMS:
|
if name in BOOL_PARAMS:
|
||||||
return None if isinstance(value, bool) else f"{name} — true или false"
|
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":
|
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) or _is_str_list(value) else f"{name} — строка или список строк"
|
||||||
return None if isinstance(value, str) 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:
|
def _is_str_list(value) -> bool:
|
||||||
return isinstance(value, list) and all(isinstance(v, str) for v in value)
|
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]
|
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))
|
||||||
|
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"]):
|
if ctype == "ensure_fields" and not any(f in params for f in spec["optional"]):
|
||||||
raise CommandError(f"команда {i}: ensure_fields без полей")
|
raise CommandError(f"команда {i}: ensure_fields без полей")
|
||||||
if ctype == "refresh" and not (params.get("keys") or params.get("jql")):
|
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, ensure_comment, ensure_attachment и refresh по состоянию не
|
||||||
подтверждаются: потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля
|
подтверждаются: потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля
|
||||||
(bridge-reliability, решение 4); совпадение темы с задачей неизвестного автора не доказательство; вложений в
|
(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"]
|
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
|
return False, None
|
||||||
issue = mirror.get_issue(conn, p.get("key") or p.get("source"))
|
issue = mirror.get_issue(conn, p.get("key") or p.get("source"))
|
||||||
if issue is None:
|
if issue is None:
|
||||||
return False, None
|
return False, None
|
||||||
if ctype == "ensure_fields":
|
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:
|
for field in FIELD_PARAMS:
|
||||||
if field in p and _norm_text(issue.get(field)) != _norm_text(p[field]):
|
if field in p and _norm_text(issue.get(field)) != _norm_text(p[field]):
|
||||||
return False, None
|
return False, None
|
||||||
|
|||||||
@ -3,7 +3,8 @@ from ...core import settings as core_settings
|
|||||||
from . import projects
|
from . import projects
|
||||||
|
|
||||||
DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30,
|
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 = {
|
DEFAULTS = {
|
||||||
"min_agent_version": 1,
|
"min_agent_version": 1,
|
||||||
@ -75,7 +76,8 @@ def pacing_from_config(p: dict) -> dict:
|
|||||||
"""Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд."""
|
"""Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд."""
|
||||||
issue, comment = p.get("issue_seconds", 120), p.get("comment_seconds", 30)
|
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,
|
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:
|
def _issue_types(cfg: dict) -> dict:
|
||||||
|
|||||||
@ -228,7 +228,7 @@
|
|||||||
"name": "notes_set"
|
"name": "notes_set"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Поставить пакет команд. Каждая команда — {\"type\": <тип>, \"params\": {...}}. Хвостовой refresh хаб добавит сам.\nПараметры по типам (необязательные — в [ ]):\n- ensure_issue: temp_key (new:<n>), 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:<n>), 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:<n>), 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:<n>), 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:<n>), 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:<n>), 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": {
|
"inputSchema": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"commands": {
|
"commands": {
|
||||||
|
|||||||
@ -38,7 +38,10 @@
|
|||||||
"ensure_fields": 120,
|
"ensure_fields": 120,
|
||||||
"ensure_issue": 120,
|
"ensure_issue": 120,
|
||||||
"ensure_link": 30,
|
"ensure_link": 30,
|
||||||
|
"ensure_rank": 30,
|
||||||
"ensure_sprint": 30,
|
"ensure_sprint": 30,
|
||||||
|
"ensure_sprint_meta": 30,
|
||||||
|
"ensure_sprint_state": 30,
|
||||||
"ensure_status": 30,
|
"ensure_status": 30,
|
||||||
"fetch_attachment": 2,
|
"fetch_attachment": 2,
|
||||||
"refresh": 2
|
"refresh": 2
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from sdlc_hub.modules.jira import commands, mirror
|
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 (ok, result) == (False, None)
|
||||||
assert "fetch_attachment" not in commands.WRITE_TYPES
|
assert "fetch_attachment" not in commands.WRITE_TYPES
|
||||||
assert commands.SPEC["fetch_attachment"]["required"] == ["key", "attachment_id"]
|
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
|
||||||
|
|||||||
@ -128,3 +128,13 @@ def test_pacing_fallback_for_new_type(conn):
|
|||||||
settings.set_(conn, "pacing", {"ensure_fields": 5})
|
settings.set_(conn, "pacing", {"ensure_fields": 5})
|
||||||
assert jira_settings.pacing(conn)["fetch_attachment"] == 2
|
assert jira_settings.pacing(conn)["fetch_attachment"] == 2
|
||||||
settings.validate("pacing", {"fetch_attachment": 3})
|
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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user