fix(agent): исполнитель — ревью Task 4 (валидация target, выбор перехода, кэш типов связей, board_id)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
Andrey Epifancev 2026-10-11 23:45:40 +04:00
parent e2fb7254a0
commit fbc9ca9048
3 changed files with 102 additions and 18 deletions

View File

@ -483,9 +483,27 @@ class Executor:
return self._out("done", {"comment_id": c.get("id"), "created": True})
# --- ensure_status ---------------------------------------------------------
@staticmethod
def _pick_transition(trs: list, step: str) -> Optional[dict]:
"""Переход, ведущий в статус step. При неоднозначности предпочитается он; имя перехода — запасной вариант."""
for t in trs:
if (t.get("to") or {}).get("name") == step:
return t
for t in trs:
if t.get("name") == step:
return t
return None
def do_ensure_status(self, p: dict, cmd: dict) -> dict:
key, target = p["key"], p["target"]
path = list(target) if isinstance(target, list) else [target]
if isinstance(target, str):
path = [target]
elif isinstance(target, list) and all(isinstance(s, str) for s in target):
path = list(target)
else:
return self._out("failed", error="target должен быть строкой или списком строк")
if not path or not all(path):
return self._out("failed", error="пустой путь статусов")
final = path[-1]
cur = self.jira.get(API + "/issue/%s" % key, fields="status")["fields"]["status"]["name"]
start = cur
@ -496,34 +514,33 @@ class Executor:
steps = 0
for step in path:
if cur == step:
continue
continue # повтор того же статуса подряд в пути: перехода не нужно
trs = self.jira.get(API + "/issue/%s/transitions" % key).get("transitions", [])
match = [t for t in trs if (t.get("to") or {}).get("name") == step or t.get("name") == step]
if not match:
hit = self._pick_transition(trs, step)
if hit is None:
avail = ", ".join("%s→%s" % (t.get("name"), (t.get("to") or {}).get("name")) for t in trs)
return self._out("failed", {"from": start, "reached": cur},
"нет перехода из «%s» в «%s»; доступны: %s" % (cur, step, avail or "—"))
if steps:
self.gap("ensure_status")
self.jira.post(API + "/issue/%s/transitions" % key, {"transition": {"id": match[0]["id"]}})
cur = step
self.jira.post(API + "/issue/%s/transitions" % key, {"transition": {"id": hit["id"]}})
cur = (hit.get("to") or {}).get("name") or step
steps += 1
return self._out("done", {"from": start, "to": cur, "steps": steps})
# --- ensure_link -----------------------------------------------------------
def symmetric_types(self) -> set:
"""Типы, у которых inward == outward («Связанные»): направление не важно."""
"""Типы, у которых inward == outward («Связанные»): направление не важно.
Ошибка HTTP пробрасывается наружу и не кэшируется: следующая команда попробует снова."""
if self._symmetric is None:
try:
types = self.jira.get(API + "/issueLinkType").get("issueLinkTypes", [])
self._symmetric = {t["name"] for t in types if t.get("inward") == t.get("outward")}
except HttpError:
self._symmetric = set()
types = self.jira.get(API + "/issueLinkType").get("issueLinkTypes", [])
self._symmetric = {t["name"] for t in types if t.get("inward") == t.get("outward")}
return self._symmetric
def do_ensure_link(self, p: dict, cmd: dict) -> dict:
src, dst = p["source"], p["target"]
types = self.s.get("link_types") or {}
# намеренный fallback (рулинг R-5): имя, которого нет в link_types, уходит в Jira как есть
lt = types.get(p["link_type"], p["link_type"])
managed = set(types.values()) | {lt}
symmetric = self.symmetric_types()
@ -574,14 +591,14 @@ class Executor:
def do_ensure_sprint(self, p: dict, cmd: dict) -> dict:
key, want = p["key"], p["sprint"]
if not self.s.get("board_id"):
return self._out("failed", error="board_id не задан в настройках хаба — спринты недоступны")
cur = ((self.jira.get(AGILE + "/issue/%s" % key, fields="sprint")["fields"].get("sprint") or {}).get("name"))
if want == "backlog":
if want == "backlog": # в бэклог доска не нужна
if cur is None:
return self._out("done", {"sprint": None, "changed": False})
self.jira.post(AGILE + "/backlog/issue", {"issues": [key]})
return self._out("done", {"sprint": None, "from": cur})
if not self.s.get("board_id"):
return self._out("failed", error="board_id не задан в настройках хаба — спринты недоступны")
if cur == want:
return self._out("done", {"sprint": want, "changed": False})
sp = self.sprints().get(want)
@ -591,7 +608,6 @@ class Executor:
self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]})
return self._out("done", {"sprint": want, "from": cur})
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

View File

@ -212,7 +212,9 @@ class FakeJira:
m = re.match(r"^/rest/agile/1\.0/board/(\d+)/sprint$", p)
if m:
states = (q.get("state") or "active,future").split(",")
return 200, {"values": [s for s in self.sprints if s["state"] in states], "isLast": True}
vals = [s for s in self.sprints if s["state"] in states]
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
return 200, {"values": vals[start:start + maxr], "isLast": start + maxr >= len(vals)}
m = re.match(r"^/rest/agile/1\.0/sprint/(\d+)/issue$", p)
if m:
sp = next(s for s in self.sprints if s["id"] == int(m.group(1)))

View File

@ -1,5 +1,7 @@
import unittest
from unittest import mock
import fake_jira
from _common import SETTINGS, agent, jira_cfg, no_sleep
from fake_jira import FakeJira
@ -38,10 +40,19 @@ class ExecutorLinksTest(unittest.TestCase):
self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово к тестированию")
def test_status_single_and_by_transition_name(self):
# имя перехода принято как шаг, но в result.to — реальный статус после перехода
out = self.run_cmd("ensure_status", key="AURORA-1", target="Взять в разработку")
self.assertEqual(out["result"]["to"], "Взять в разработку") # имя перехода принято как шаг
self.assertEqual(out["result"]["to"], "Разработка")
self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Разработка")
def test_status_ambiguous_prefers_target_status(self):
# переход «Готово» ведёт в «Анализ», переход «Старт» ведёт в статус «Готово»: выбирается по целевому статусу
with mock.patch.dict(fake_jira.WORKFLOW, {"Ожидание разработки": [("Готово", "Анализ"), ("Старт", "Готово")]}):
out = self.run_cmd("ensure_status", key="AURORA-1", target="Готово")
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"from": "Ожидание разработки", "to": "Готово", "steps": 1})
self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово")
def test_status_no_transition_is_failed(self):
out = self.run_cmd("ensure_status", key="AURORA-1", target="Тестирование")
self.assertEqual(out["status"], "failed")
@ -49,6 +60,21 @@ class ExecutorLinksTest(unittest.TestCase):
self.assertIn("Взять в разработку→Разработка", out["error"])
self.assertEqual(out["result"], {"from": "Ожидание разработки", "reached": "Ожидание разработки"})
def test_status_bad_target(self):
cases = (([], "пустой путь"), ("", "пустой путь"), ([""], "пустой путь"),
(5, "target должен быть"), (["Готово", 1], "target должен быть"), (None, "target должен быть"))
for bad, msg in cases:
out = self.run_cmd("ensure_status", key="AURORA-1", target=bad)
self.assertEqual(out["status"], "failed", bad)
self.assertIn(msg, out["error"])
self.assertEqual(self.fake.writes(), [])
def test_status_http_error_is_failed(self):
self.fake.fail_next.append((400, {}))
out = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"])
self.assertEqual(out["status"], "failed")
self.assertIn("HTTP 400", out["error"])
def test_status_gap_between_steps(self):
self.ex.s["pacing"] = {"ensure_status": 7}
self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"])
@ -95,6 +121,29 @@ class ExecutorLinksTest(unittest.TestCase):
self.assertEqual(out["result"], {"created": True, "removed": []})
self.assertEqual(len(self.fake.links), 2)
def test_link_unknown_type_sent_raw(self):
# намеренный fallback (рулинг R-5): неизвестное имя типа уходит в Jira как есть
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="Duplicate")
self.assertEqual(out["result"], {"created": True, "removed": []})
self.assertEqual(self.fake.links[-1]["type"], "Duplicate")
def test_link_http_error_is_failed(self):
self.ex.symmetric_types() # тип уже известен, падает чтение задачи
self.fake.fail_next.append((400, {}))
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало")
self.assertEqual(out["status"], "failed")
self.assertIn("HTTP 400", out["error"])
self.assertEqual(self.fake.links, [])
def test_symmetric_not_cached_on_error(self):
self.fake.fail_next.append((400, {})) # падает чтение типов связей
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные")
self.assertEqual(out["status"], "failed")
self.assertIsNone(self.ex._symmetric) # пустой набор в кэш не попал
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-2", "target": "AURORA-1"})
again = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные")
self.assertEqual(again["result"], {"existed": True, "removed": []})
# --- спринты ---
def test_sprint_move_and_idempotent(self):
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2")
@ -104,6 +153,15 @@ class ExecutorLinksTest(unittest.TestCase):
self.assertEqual(again["result"], {"sprint": "Спринт 2", "changed": False})
self.assertEqual(again["jira_calls"], 1)
def test_sprint_pagination(self):
for i in range(3, 58): # 57 спринтов: доска отдаёт по 50, нужны две страницы
self.fake.sprints.append({"id": i, "name": "Спринт %d" % i, "state": "future"})
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 57")
self.assertEqual(out["status"], "done")
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 57")
pages = [r for r in self.fake.requests if r[1].endswith("/board/4216/sprint")]
self.assertEqual(len(pages), 2)
def test_sprint_backlog(self):
self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1"
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog")
@ -111,6 +169,14 @@ class ExecutorLinksTest(unittest.TestCase):
self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"])
self.assertEqual(self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog")["result"], {"sprint": None, "changed": False})
def test_sprint_backlog_without_board(self):
self.ex.s["board_id"] = None
self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1"
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog")
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"sprint": None, "from": "Спринт 1"})
self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"])
def test_sprint_unknown_name(self):
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 9")
self.assertEqual(out["status"], "failed")