feat(agent): исполнитель — переходы статусов, связи с заменой, спринты

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:42:29 +04:00
parent 3957e85656
commit a071cc12f8
2 changed files with 237 additions and 0 deletions

View File

@ -482,6 +482,115 @@ class Executor:
c = self.jira.post(API + "/issue/%s/comment" % key, {"body": body})
return self._out("done", {"comment_id": c.get("id"), "created": True})
# --- ensure_status ---------------------------------------------------------
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]
final = path[-1]
cur = self.jira.get(API + "/issue/%s" % key, fields="status")["fields"]["status"]["name"]
start = cur
if cur == final:
return self._out("done", {"from": start, "to": final, "steps": 0})
if cur in path:
path = path[path.index(cur) + 1:]
steps = 0
for step in path:
if cur == step:
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:
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
steps += 1
return self._out("done", {"from": start, "to": cur, "steps": steps})
# --- ensure_link -----------------------------------------------------------
def symmetric_types(self) -> set:
"""Типы, у которых inward == outward («Связанные»): направление не важно."""
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()
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 {}
lt = types.get(p["link_type"], p["link_type"])
managed = set(types.values()) | {lt}
symmetric = self.symmetric_types()
raw = self.jira.get(API + "/issue/%s" % src, fields="issuelinks")["fields"].get("issuelinks") or []
links = [] # (тип, другая задача, направление, id)
for l in raw:
t = (l.get("type") or {}).get("name")
if l.get("outwardIssue"):
links.append((t, l["outwardIssue"]["key"], "out", l.get("id")))
if l.get("inwardIssue"):
links.append((t, l["inwardIssue"]["key"], "in", l.get("id")))
wanted = [x for x in links if x[1] == dst and x[0] == lt and (x[2] == "out" or lt in symmetric)]
conflicts = [x for x in links if x[1] == dst and x[0] in managed and x not in wanted]
if conflicts and not p.get("replace"):
desc = ", ".join("«%s» (%s)" % (t, "обратное направление" if t == lt else "другой тип") for t, _, _, _ in conflicts)
return self._out("failed", error="между %s и %s уже есть: %s; нужен replace" % (src, dst, desc))
removed = []
for t, other, direction, lid in conflicts:
if not lid:
return self._out("failed", {"removed": removed}, "у связи «%s» с %s нет id — снять нельзя" % (t, other))
if removed:
self.gap("ensure_link")
self.jira.delete(API + "/issueLink/%s" % lid)
removed.append(t)
if wanted:
return self._out("done", {"existed": True, "removed": removed})
if removed:
self.gap("ensure_link")
# inwardIssue — источник (получает outward-описание «Блокирует начало…»), outwardIssue — цель
self.jira.post(API + "/issueLink", {"type": {"name": lt}, "inwardIssue": {"key": src}, "outwardIssue": {"key": dst}})
return self._out("done", {"created": True, "removed": removed})
# --- ensure_sprint ---------------------------------------------------------
def sprints(self) -> Dict[str, dict]:
"""Имя → спринт (active и future) доски board_id; без доски — пусто."""
if self._sprints is None:
out, start = {}, 0
board = self.s.get("board_id")
while board:
page = self.jira.get(AGILE + "/board/%s/sprint" % board, state="active,future", startAt=start, maxResults=50)
for sp in page.get("values", []):
out[sp["name"]] = sp
if page.get("isLast", True) or not page.get("values"):
break
start += len(page["values"])
self._sprints = out
return self._sprints
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 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 cur == want:
return self._out("done", {"sprint": want, "changed": False})
sp = self.sprints().get(want)
if not sp:
return self._out("failed", error="нет спринта «%s» среди активных и будущих доски %s: %s"
% (want, self.s["board_id"], ", ".join(sorted(self.sprints())) or "—"))
self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]})
return self._out("done", {"sprint": want, "from": cur})
# ---------------------------------------------------------------------------
# CLI

View File

@ -0,0 +1,128 @@
import unittest
from _common import SETTINGS, agent, jira_cfg, no_sleep
from fake_jira import FakeJira
class ExecutorLinksTest(unittest.TestCase):
def setUp(self):
self.fake = FakeJira()
self.jira = agent.JiraClient(jira_cfg(self.fake))
self.jira.sleep = no_sleep
self.ex = agent.Executor(self.jira, dict(SETTINGS))
self.gaps = []
self.ex.sleep = self.gaps.append
self.fake.add("AURORA-1")
self.fake.add("AURORA-2")
def tearDown(self):
self.fake.stop()
def run_cmd(self, ctype, **params):
return self.ex.run({"id": 1, "type": ctype, "params": params})
# --- статус ---
def test_status_path(self):
out = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"])
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"from": "Ожидание разработки", "to": "Готово", "steps": 2})
self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово")
again = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"])
self.assertEqual(again["result"]["steps"], 0)
self.assertEqual(again["jira_calls"], 1)
def test_status_resumes_from_middle(self):
self.fake.issues["AURORA-1"]["status"] = "Разработка"
out = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово к тестированию"])
self.assertEqual(out["result"]["steps"], 1)
self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово к тестированию")
def test_status_single_and_by_transition_name(self):
out = self.run_cmd("ensure_status", key="AURORA-1", target="Взять в разработку")
self.assertEqual(out["result"]["to"], "Взять в разработку") # имя перехода принято как шаг
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")
self.assertIn("нет перехода из «Ожидание разработки» в «Тестирование»", out["error"])
self.assertIn("Взять в разработку→Разработка", out["error"])
self.assertEqual(out["result"], {"from": "Ожидание разработки", "reached": "Ожидание разработки"})
def test_status_gap_between_steps(self):
self.ex.s["pacing"] = {"ensure_status": 7}
self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"])
self.assertEqual(self.gaps, [7])
# --- связи ---
def test_link_create_and_idempotent(self):
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало")
self.assertEqual(out["result"], {"created": True, "removed": []})
link = self.fake.links[0]
self.assertEqual((link["source"], link["target"], link["type"]), ("AURORA-1", "AURORA-2", "ASUPR_Блокер_Начала"))
again = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало")
self.assertEqual(again["result"], {"existed": True, "removed": []})
self.assertEqual(len(self.fake.links), 1)
def test_link_symmetric_any_direction(self):
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-2", "target": "AURORA-1"})
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные")
self.assertEqual(out["result"], {"existed": True, "removed": []})
def test_link_conflict_without_replace(self):
self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-2", "target": "AURORA-1"})
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало")
self.assertEqual(out["status"], "failed")
self.assertIn("обратное направление", out["error"])
self.assertEqual(len(self.fake.links), 1)
def test_link_replace_reverse_and_other_type(self):
self.ex.s["pacing"] = {"ensure_link": 3}
self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-2", "target": "AURORA-1"})
self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "AURORA-2"})
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало", replace=True)
self.assertEqual(out["status"], "done")
self.assertEqual(sorted(out["result"]["removed"]), ["ASUPR_Блокер_Начала", "ASUPR_Связанные"])
self.assertTrue(out["result"]["created"])
self.assertEqual([(l["source"], l["target"], l["type"]) for l in self.fake.links],
[("AURORA-1", "AURORA-2", "ASUPR_Блокер_Начала")])
self.assertEqual(self.gaps, [3, 3]) # между двумя DELETE и перед POST
self.assertEqual([r[0] for r in self.fake.writes()], ["DELETE", "DELETE", "POST"])
def test_link_unmanaged_type_ignored(self):
self.fake.links.append({"id": 1, "type": "Duplicate", "source": "AURORA-1", "target": "AURORA-2"})
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные")
self.assertEqual(out["result"], {"created": True, "removed": []})
self.assertEqual(len(self.fake.links), 2)
# --- спринты ---
def test_sprint_move_and_idempotent(self):
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2")
self.assertEqual(out["result"], {"sprint": "Спринт 2", "from": None})
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2")
again = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2")
self.assertEqual(again["result"], {"sprint": "Спринт 2", "changed": False})
self.assertEqual(again["jira_calls"], 1)
def test_sprint_backlog(self):
self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1"
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog")
self.assertEqual(out["result"], {"sprint": None, "from": "Спринт 1"})
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_unknown_name(self):
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 9")
self.assertEqual(out["status"], "failed")
self.assertIn("Спринт 9", out["error"])
self.assertIn("Спринт 2", out["error"])
def test_sprint_without_board(self):
self.ex.s["board_id"] = None
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2")
self.assertEqual(out["status"], "failed")
self.assertIn("board_id", out["error"])
if __name__ == "__main__":
unittest.main()