sdlc-hub/src/jira_hub/commands.py
Andrey Epifancev 9b21060d60 feat(commands): команда ensure_attachment — проверка файла при постановке, size при выдаче (#43)
SPEC ensure_attachment {key, file, filename}, пауза 30 с, по состоянию не подтверждается; queue._check_setup: формат file, файл загружен («файл <sha> не загружен: POST /files»), filename без / и \, без кавычек и управляющих символов (R-2); claim, довыдача и GET /agent/commands/{id} дописывают size (null без файла), в базе params без size, retry проходит (решения 17–18).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:46:14 +04:00

224 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Контракт команд: каждая — желаемый результат на одной задаче Jira (specs/commands/spec.md).
Здесь валидация, работа с ключами (в том числе временными new:<n>), оценка длительности и
проверка «результат уже виден в зеркале» для подтверждения по состоянию."""
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"]
DATE = "YYYY-MM-DD"
FIELD_HINTS = {"duedate": DATE, "planned_start": DATE, "planned_end": DATE, "epic_key": "ключ или new:<n>",
"assignee": "логин Jira"}
# hints — формат значения для описания MCP batch_create, note — условие на набор параметров (#44)
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"],
"hints": {"temp_key": "new:<n>", "issue_type": "task|epic|bug", "labels": "список", **FIELD_HINTS}},
"ensure_fields": {"required": ["key"], "optional": FIELD_PARAMS + ["labels_add", "labels_remove"],
"keys": ["key", "epic_key"],
"hints": {**FIELD_HINTS, "labels_add": "список", "labels_remove": "список"},
"note": "хотя бы одно поле"},
"ensure_comment": {"required": ["key", "marker", "body"], "optional": [], "keys": ["key"],
"hints": {"marker": "метка в начале комментария"}},
"ensure_status": {"required": ["key", "target"], "optional": [], "keys": ["key"],
"hints": {"target": "статус или список-путь"}},
"ensure_link": {"required": ["source", "target", "link_type"], "optional": ["replace"], "keys": ["source", "target"],
"hints": {"link_type": "логическое имя из link_types", "replace": "true|false"}},
"ensure_sprint": {"required": ["key", "sprint"], "optional": [], "keys": ["key"],
"hints": {"sprint": "имя спринта или backlog"}},
"ensure_attachment": {"required": ["key", "file", "filename"], "optional": [], "keys": ["key"],
"hints": {"file": "sha256 из POST /files; в MCP — path к локальному файлу",
"filename": "имя вложения"}},
"refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"], "hints": {"keys": "список"},
"note": "keys или jql"},
}
WRITE_TYPES = [t for t in SPEC if t != "refresh"]
LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"}
BOOL_PARAMS = {"replace"}
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 _type_error(ctype, name, value) -> str | None:
"""Проверка типа значения параметра (M-3 #12): иначе .strip() и подстановка ключей падают с 500.
null допустим везде — обязательность проверяется отдельно."""
if value is None:
return None
if name in LIST_PARAMS:
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 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 _is_str_list(value) -> bool:
return isinstance(value, list) and all(isinstance(v, str) for v in value)
def _names(spec, names) -> str:
return ", ".join(f"{n} ({spec['hints'][n]})" if n in spec.get("hints", {}) else n for n in names)
def describe() -> str:
"""Параметры всех типов команд, строка на тип (необязательные — в [ ]): описание MCP batch_create из SPEC (#44)."""
lines = []
for ctype, spec in SPEC.items():
line = f"- {ctype}: {_names(spec, spec['required']) or '—'}"
if spec["optional"]:
line += f"; [{_names(spec, spec['optional'])}]"
if spec.get("note"):
line += f" — {spec['note']}"
lines.append(line)
return "\n".join(lines)
def _expected(spec) -> str:
"""Хвост ошибки «нет …» / «лишние параметры …»: какие параметры тип ожидает (#44)."""
parts = [f"ожидаются {', '.join(spec['required'])}"] if spec["required"] else []
if spec["optional"]:
parts.append(f"необязательные {', '.join(spec['optional'])}")
return "; ".join(parts)
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)}; {_expected(spec)}")
unknown = set(params) - set(spec["required"]) - set(spec["optional"])
if unknown:
raise CommandError(f"команда {i} ({ctype}): лишние параметры {', '.join(sorted(unknown))}; "
f"{_expected(spec)}")
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))
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:<n>")
if params["temp_key"] in defined:
raise CommandError(f"команда {i}: temp_key {params['temp_key']} уже объявлен")
if params["issue_type"] not in ("task", "epic", "bug"):
raise CommandError(f"команда {i}: issue_type — task, epic или bug")
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})
summaries = [c["params"]["summary"].strip() for c in out if c["type"] == "ensure_issue"]
repeated = sorted({s for s in summaries if summaries.count(s) > 1})
if repeated:
raise CommandError("повторяется тема ensure_issue: " + ", ".join(f"«{s}»" for s in repeated)
+ " — в Jira получится одна задача на два временных ключа")
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 _norm_text(value):
"""Значение поля для сравнения с зеркалом: переводы строки CRLF и LF не различаются (описание, правленное в UI Jira,
приходит с CRLF, O-9 #16); null и пустая строка равны (решение 11)."""
if isinstance(value, str):
value = value.replace("\r\n", "\n")
return value or None
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)."""
ctype, p = cmd["type"], cmd["params"]
if ctype in ("ensure_issue", "ensure_comment", "ensure_attachment", "refresh"):
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":
for field in FIELD_PARAMS:
if field in p and _norm_text(issue.get(field)) != _norm_text(p[field]):
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"])
all_types = set(link_types.values())
present = any(l["target_key"] == p["target"] and l["link_type"] == jira_type for l in issue["links_out"])
other_type = any(l["target_key"] == p["target"] and l["link_type"] in all_types - {jira_type}
for l in issue["links_out"])
reverse = any(l["source_key"] == p["target"] and l["link_type"] in all_types for l in issue["links_in"])
return present and not other_type and not reverse, 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