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:
parent
e2fb7254a0
commit
fbc9ca9048
48
src/agent.py
48
src/agent.py
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user