diff --git a/src/jira_hub/commands.py b/src/jira_hub/commands.py new file mode 100644 index 0000000..8dfcdd5 --- /dev/null +++ b/src/jira_hub/commands.py @@ -0,0 +1,140 @@ +"""Контракт команд: каждая — желаемый результат на одной задаче Jira (specs/commands/spec.md). +Здесь валидация, работа с ключами (в том числе временными new:), оценка длительности и +проверка «результат уже виден в зеркале» для подтверждения по состоянию.""" +import re + +from . import mirror + +TEMP_KEY = re.compile(r"^new:\d+$") +FIELD_PARAMS = ["summary", "description", "priority", "duedate", "planned_start", "planned_end", "epic_key", "assignee"] + +SPEC = { + "ensure_issue": {"required": ["temp_key", "issue_type", "summary"], + "optional": ["description", "priority", "duedate", "planned_start", "planned_end", "labels", + "epic_key", "assignee"], + "keys": ["temp_key", "epic_key"]}, + "ensure_fields": {"required": ["key"], "optional": FIELD_PARAMS + ["labels_add", "labels_remove"], + "keys": ["key", "epic_key"]}, + "ensure_comment": {"required": ["key", "marker", "body"], "optional": [], "keys": ["key"]}, + "ensure_status": {"required": ["key", "target"], "optional": [], "keys": ["key"]}, + "ensure_link": {"required": ["source", "target", "link_type"], "optional": ["replace"], "keys": ["source", "target"]}, + "ensure_sprint": {"required": ["key", "sprint"], "optional": [], "keys": ["key"]}, + "refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"]}, +} +WRITE_TYPES = [t for t in SPEC if t != "refresh"] + + +class CommandError(ValueError): + """Ошибка валидации пакета — наружу уходит как 422.""" + + +def is_temp(key) -> bool: + return isinstance(key, str) and bool(TEMP_KEY.match(key)) + + +def referenced_keys(cmd) -> list[str]: + out = [] + for field in SPEC[cmd["type"]]["keys"]: + value = cmd["params"].get(field) + if isinstance(value, list): + out += [v for v in value if isinstance(v, str) and v] + elif isinstance(value, str) and value: + out.append(value) + return out + + +def temp_keys_used(cmd) -> list[str]: + """Чужие временные ключи (свой temp_key у ensure_issue — не зависимость).""" + own = cmd["params"].get("temp_key") if cmd["type"] == "ensure_issue" else None + return [k for k in referenced_keys(cmd) if is_temp(k) and k != own] + + +def validate(commands) -> list[dict]: + if not isinstance(commands, list) or not commands: + raise CommandError("пакет без команд") + defined, out = set(), [] + for i, cmd in enumerate(commands, 1): + if not isinstance(cmd, dict) or "type" not in cmd: + raise CommandError(f"команда {i}: нет type") + ctype, params = cmd["type"], cmd.get("params") or {} + if ctype not in SPEC: + raise CommandError(f"команда {i}: неизвестный тип «{ctype}»") + if not isinstance(params, dict): + raise CommandError(f"команда {i}: params не объект") + spec = SPEC[ctype] + missing = [f for f in spec["required"] if params.get(f) in (None, "", [])] + if missing: + raise CommandError(f"команда {i} ({ctype}): нет {', '.join(missing)}") + unknown = set(params) - set(spec["required"]) - set(spec["optional"]) + if unknown: + raise CommandError(f"команда {i} ({ctype}): лишние параметры {', '.join(sorted(unknown))}") + 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")): + raise CommandError(f"команда {i}: refresh без keys и jql") + if ctype == "ensure_issue": + if not is_temp(params["temp_key"]): + raise CommandError(f"команда {i}: temp_key должен быть new:") + if params["temp_key"] in defined: + raise CommandError(f"команда {i}: temp_key {params['temp_key']} уже объявлен") + if params["issue_type"] not in ("task", "epic"): + raise CommandError(f"команда {i}: issue_type — task или epic") + for key in temp_keys_used({"type": ctype, "params": params}): + if key not in defined: + raise CommandError(f"команда {i}: временный ключ {key} не объявлен ранее в пакете командой ensure_issue") + if ctype == "ensure_issue": + defined.add(params["temp_key"]) + out.append({"type": ctype, "params": params}) + return out + + +def substitute(cmd_type, params, mapping: dict) -> dict: + """Подставить настоящие ключи вместо временных (temp_key самой ensure_issue не трогается).""" + out = dict(params) + for field in SPEC[cmd_type]["keys"]: + if field == "temp_key": + continue + value = out.get(field) + if isinstance(value, list): + out[field] = [mapping.get(v, v) for v in value] + elif isinstance(value, str): + out[field] = mapping.get(value, value) + return out + + +def estimate(types, pacing: dict) -> int: + return sum(int(pacing.get(t, 0)) for t in types) + + +def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: + """Виден ли желаемый результат в зеркале. ensure_comment и refresh по состоянию не подтверждаются.""" + ctype, p = cmd["type"], cmd["params"] + if ctype in ("ensure_comment", "refresh"): + return False, None + if ctype == "ensure_issue": + row = conn.execute("SELECT key FROM issue WHERE summary = ? AND substr(created, 1, 10) >= date('now', '-7 days') " + "ORDER BY created DESC LIMIT 1", (p["summary"],)).fetchone() + return (True, {"key": row["key"]}) if row else (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": + for field in FIELD_PARAMS: + if field in p and (issue.get(field) or None) != (p[field] or None): + return False, None + if any(l not in issue["labels"] for l in p.get("labels_add", [])): + return False, None + if any(l in issue["labels"] for l in p.get("labels_remove", [])): + return False, None + return True, None + if ctype == "ensure_status": + target = p["target"] + final = target[-1] if isinstance(target, list) else target + return issue["status"] == final, None + if ctype == "ensure_link": + jira_type = link_types.get(p["link_type"], p["link_type"]) + return any(l["target_key"] == p["target"] and l["link_type"] == jira_type for l in issue["links_out"]), None + if ctype == "ensure_sprint": + want = None if p["sprint"] == "backlog" else p["sprint"] + return (issue.get("sprint") or None) == want, None + return False, None diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..263f03e --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,88 @@ +import pytest + +from jira_hub import commands, db, mirror +from jira_hub.commands import CommandError + + +def test_validate_ok_and_tail_keys(): + cmds = commands.validate([ + {"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Т"}}, + {"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}}, + {"type": "refresh", "params": {"keys": ["A-1", "new:60"]}}, + ]) + assert [c["type"] for c in cmds] == ["ensure_issue", "ensure_link", "refresh"] + assert commands.referenced_keys(cmds[1]) == ["new:60", "A-1"] + assert commands.temp_keys_used(cmds[1]) == ["new:60"] + assert commands.temp_keys_used(cmds[0]) == [] + assert commands.temp_keys_used(cmds[2]) == ["new:60"] + + +@pytest.mark.parametrize("bad, msg", [ + ([], "без команд"), + ([{"type": "ensure_all"}], "неизвестный тип"), + ([{"type": "ensure_fields", "params": {"key": "A-1"}}], "без полей"), + ([{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23", "foo": 1}}], "лишние параметры"), + ([{"type": "ensure_status", "params": {"key": "A-1"}}], "нет target"), + ([{"type": "refresh", "params": {}}], "без keys и jql"), + ([{"type": "ensure_link", "params": {"source": "new:3", "target": "A-1", "link_type": "связанные"}}], "не объявлен"), + ([{"type": "ensure_issue", "params": {"temp_key": "A-1", "issue_type": "task", "summary": "x"}}], "new:"), + ([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "x"}}], "task или epic"), + ([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "x"}}, + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "y"}}], "уже объявлен"), +]) +def test_validate_errors(bad, msg): + with pytest.raises(CommandError, match=msg): + commands.validate(bad) + + +def test_substitute_and_estimate(): + p = commands.substitute("ensure_link", {"source": "new:60", "target": "A-1", "link_type": "связанные"}, {"new:60": "A-9"}) + assert p["source"] == "A-9" + p = commands.substitute("refresh", {"keys": ["new:60", "A-1"]}, {"new:60": "A-9"}) + assert p["keys"] == ["A-9", "A-1"] + p = commands.substitute("ensure_issue", {"temp_key": "new:60", "issue_type": "task", "summary": "x", "epic_key": "new:2"}, + {"new:2": "A-7", "new:60": "A-9"}) + assert p["temp_key"] == "new:60" and p["epic_key"] == "A-7" + assert commands.estimate(["ensure_fields", "ensure_comment", "refresh"], {"ensure_fields": 120, "ensure_comment": 30, "refresh": 2}) == 152 + + +def _mirror(): + conn = db.connect(":memory:") + mirror.apply_state(conn, [{"key": "A-1", "summary": "Тема", "status": "Готово", "priority": "Высокий", + "duedate": "2026-10-23", "labels": ["AWPCA", "kind-dev"], "sprint": "Спринт 2", + "created": "2026-10-10T10:00:00.000+0300", "assignee": "ivanov"}], + [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}]) + return conn + + +LT = {"связанные": "ASUPR_Связанные", "блокирует начало": "ASUPR_Блокер_Начала"} + + +@pytest.mark.parametrize("cmd, expected", [ + ({"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23", "priority": "Высокий"}}, True), + ({"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}, False), + ({"type": "ensure_fields", "params": {"key": "A-1", "labels_add": ["kind-dev"], "labels_remove": ["fact-done"]}}, True), + ({"type": "ensure_fields", "params": {"key": "A-1", "labels_remove": ["kind-dev"]}}, False), + ({"type": "ensure_status", "params": {"key": "A-1", "target": ["Разработка", "Готово"]}}, True), + ({"type": "ensure_status", "params": {"key": "A-1", "target": "Разработка"}}, False), + ({"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, True), + ({"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "блокирует начало"}}, False), + ({"type": "ensure_link", "params": {"source": "A-2", "target": "A-1", "link_type": "связанные"}}, False), + ({"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}, True), + ({"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "backlog"}}, False), + ({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": "b"}}, False), + ({"type": "refresh", "params": {"keys": ["A-1"]}}, False), + ({"type": "ensure_fields", "params": {"key": "A-404", "duedate": "2026-10-23"}}, False), +]) +def test_check_confirmed(cmd, expected): + ok, _ = commands.check_confirmed(_mirror(), cmd, LT) + assert ok is expected + + +def test_check_confirmed_issue_by_summary(monkeypatch): + conn = _mirror() + conn.execute("UPDATE issue SET created = strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now') WHERE key = 'A-1'") + ok, result = commands.check_confirmed(conn, {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Тема"}}, LT) + assert ok and result == {"key": "A-1"} + ok, _ = commands.check_confirmed(conn, {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Другая"}}, LT) + assert not ok