diff --git a/src/agent.py b/src/agent.py index 0bd5f0b..8b19dea 100644 --- a/src/agent.py +++ b/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 # --------------------------------------------------------------------------- diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 7477583..db43736 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -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))) diff --git a/tests/test_executor_links.py b/tests/test_executor_links.py index b5de0d7..6fa0073 100644 --- a/tests/test_executor_links.py +++ b/tests/test_executor_links.py @@ -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")