Спринты и бэклог в модуле jira (#108) #109

Merged
aep merged 21 commits from feat/108-sprints-backlog into main 2026-10-25 13:56:42 +03:00
7 changed files with 154 additions and 9 deletions
Showing only changes of commit 212e9eae55 - Show all commits

View File

@ -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_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:<n>`), `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: файл <sha> не
загружен: POST /files». Агент ищет у задачи вложение с тем же именем и размером; нет — скачивает файл у хаба

View File

@ -1,7 +1,9 @@
"""Контракт команд: каждая — желаемый результат на одной задаче Jira (specs/commands/spec.md).
Здесь валидация, работа с ключами (в том числе временными new:<n>), оценка длительности и
проверка «результат уже виден в зеркале» для подтверждения по состоянию."""
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:<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"],
"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

View File

@ -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:

View File

@ -228,7 +228,7 @@
"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": {
"properties": {
"commands": {

View File

@ -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

View File

@ -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

View File

@ -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