From a071cc12f8d12e333123b5ad091947e16d8f5ab1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 11 Oct 2026 23:42:29 +0400 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=20=E2=80=94=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D1=85=D0=BE=D0=B4=D1=8B=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=81=D0=BE=D0=B2,=20=D1=81=D0=B2=D1=8F=D0=B7=D0=B8=20?= =?UTF-8?q?=D1=81=20=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D0=BE=D0=B9,=20=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BD=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o --- src/agent.py | 109 +++++++++++++++++++++++++++++ tests/test_executor_links.py | 128 +++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 tests/test_executor_links.py diff --git a/src/agent.py b/src/agent.py index be0bc62..0bd5f0b 100644 --- a/src/agent.py +++ b/src/agent.py @@ -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 diff --git a/tests/test_executor_links.py b/tests/test_executor_links.py new file mode 100644 index 0000000..b5de0d7 --- /dev/null +++ b/tests/test_executor_links.py @@ -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()