diff --git a/src/agent.py b/src/agent.py index 58412d3..f79b6b5 100644 --- a/src/agent.py +++ b/src/agent.py @@ -1117,6 +1117,8 @@ class Executor: return self._out("failed", error=self.many_sprints(rename_to, to)) if found and to: return self._out("failed", error="имя занято") + if not to and any(str(sp.get("state")).lower() == "closed" for sp in self.sprints_named(board, rename_to)): + return self._out("failed", error="имя занято") # M-2: имя только у закрытого спринта cur = found[0] if found else (to[0] if to else None) if cur is not None and str(cur.get("state")).lower() == "closed": return self._out("failed", error="спринт закрыт") @@ -1630,18 +1632,25 @@ class Executor: @staticmethod def sprint_state(sp: dict) -> Optional[dict]: - """Спринт для хаба; без id/name или с состоянием вне active/future/closed — None (R-72).""" + """Спринт для хаба; id не целое > 0, name не непустая строка или состояние вне active/future/closed — None + (R-72, M-3: как check_boards хаба); нестроковые даты и goal — None.""" state = sp.get("state") state = state.lower() if isinstance(state, str) else None - if sp.get("id") is None or not sp.get("name") or state not in ("active", "future", "closed"): + sid, name = sp.get("id"), sp.get("name") + if (not isinstance(sid, int) or isinstance(sid, bool) or sid <= 0 or not isinstance(name, str) or not name + or state not in ("active", "future", "closed")): return None - return {"id": sp["id"], "name": sp["name"], "state": state, "start_date": sp.get("startDate"), - "end_date": sp.get("endDate"), "complete_date": sp.get("completeDate"), "goal": sp.get("goal")} + + def text(v: Any) -> Optional[str]: + return v if isinstance(v, str) else None + return {"id": sid, "name": name, "state": state, "start_date": text(sp.get("startDate")), + "end_date": text(sp.get("endDate")), "complete_date": text(sp.get("completeDate")), + "goal": text(sp.get("goal"))} def board_state(self, board: Any, kind: str) -> Optional[dict]: """Элемент boards первой пачки; ошибка чтения спринтов — доска не отправляется (R-65), колонок — columns null.""" columns = self.board_columns(board) - backlog = self.kanban_backlog(board) if kind == "kanban" else None + backlog = self.kanban_backlog(board) if kind == "kanban" and columns is not None else None # M-1 sprints: List[dict] = [] if kind == "scrum": try: @@ -1652,7 +1661,7 @@ class Executor: for sp in raw: st = self.sprint_state(sp) if st is None: - log("refresh: доска %s — спринт %r пропущен: нет id/name или состояние не active/future/closed" + log("refresh: доска %s — спринт %r пропущен: некорректные id/name или состояние не active/future/closed" % (board, sp.get("id"))) else: sprints.append(st) diff --git a/tests/test_executor_sprints.py b/tests/test_executor_sprints.py index 38c8f0d..53ab5d8 100644 --- a/tests/test_executor_sprints.py +++ b/tests/test_executor_sprints.py @@ -202,6 +202,21 @@ class ExecutorSprintsTest(unittest.TestCase): out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B) self.assertEqual(out["result"], {"sprint_id": 3, "moved": ["AURORA-3001"]}) + def test_meta_rename_to_closed_name_taken(self): # M-2 + self.fake.board_sprints[4216] = [{"id": 1, "name": "T", "state": "closed"}] + out = self.run_cmd("ensure_sprint_meta", sprint="S", rename_to="T", **B) + self.assertEqual((out["status"], out["error"]), ("failed", "имя занято")) + self.fake.board_sprints[4216].append({"id": 9, "name": "S", "state": "future"}) + out = self.run_cmd("ensure_sprint_meta", sprint="S", rename_to="T", **B) + self.assertEqual((out["status"], out["error"]), ("failed", "имя занято")) + self.assertEqual(self.fake.writes(), []) + + def test_meta_repeat_after_rename_with_closed_namesake(self): # M-2 не ломает R-69 + self.fake.board_sprints[4216] = [{"id": 1, "name": "T", "state": "closed"}, {"id": 9, "name": "T", "state": "future"}] + out = self.run_cmd("ensure_sprint_meta", sprint="S", rename_to="T", **B) + self.assertEqual(out["result"], {"sprint_id": 9, "created": False}) + self.assertEqual(self.fake.writes(), []) + # --- ensure_rank --- def test_rank_already(self): # сценарий «Ранг уже такой» self.fake.add("AURORA-1612", customfield_10105="0|i0001:") diff --git a/tests/test_refresh.py b/tests/test_refresh.py index b006d13..f090357 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -564,6 +564,14 @@ class RefreshTest(unittest.TestCase): self.assertEqual((b["kanban_backlog"], b["sprints"], b["columns"]), (want, [], COLS)) self.assertNotIn("/rest/agile/1.0/board/5120/sprint", [r[1] for r in self.fake.requests]) + def test_kanban_config_error_backlog_null(self): # M-1: columns null → kanban_backlog null, запроса бэклога нет + self.fake.boards[5120] = "kanban" + self.fake.fail_paths["/rest/agile/1.0/board/5120/configuration"] = [403] + self.refresh(keys=[], boards=[{"id": 5120, "type": "kanban"}]) + b = self.hub.states[-1]["boards"][0] + self.assertEqual((b["columns"], b["kanban_backlog"]), (None, None)) + self.assertNotIn("/rest/agile/1.0/board/5120/backlog", [r[1] for r in self.fake.requests]) + def test_board_config_error_columns_null(self): self.fake.add("AURORA-1") self.fake.fail_paths["/rest/agile/1.0/board/4216/configuration"] = [403] @@ -608,6 +616,19 @@ class RefreshTest(unittest.TestCase): self.assertEqual(self.hub.states[0]["boards"][0]["columns"], [{"name": "Одна", "statuses": ["Разработка", "Принято"]}]) self.assertEqual([l for l in logs if "не найден" in l], []) + def test_board_bad_types_dropped(self): # M-3: id/name/даты/goal строже, как check_boards хаба + bad = [{"id": "5", "name": "A", "state": "active"}, {"id": True, "name": "B", "state": "active"}, + {"id": 0, "name": "C", "state": "active"}, {"id": 6, "name": 7, "state": "active"}, + {"id": 8, "name": "Ok", "state": "future", "startDate": 123, "endDate": "2026-10-28T09:00:00.000+0300", + "completeDate": [], "goal": 5}] + self.fake.board_sprints[4216] = bad + out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])) + self.assertEqual(out["status"], "done", out) + self.assertEqual(self.hub.states[0]["boards"][0]["sprints"], + [{"id": 8, "name": "Ok", "state": "future", "start_date": None, + "end_date": "2026-10-28T09:00:00.000+0300", "complete_date": None, "goal": None}]) + self.assertEqual(len([l for l in logs if "пропущен" in l]), 4, logs) + def test_column_without_string_name_skipped(self): # M-3 self.fake.board_columns[4216] = [(None, ["10003"]), ("Ок", ["10003"])] out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]))