Доработки по финальному ревью v15: kanban без колонок, имя закрытого спринта, строгий фильтр (#49)

M-1: при ошибке чтения настроек kanban-доски kanban_backlog = null и запрос бэклога не делается.
M-2: ensure_sprint_meta с rename_to, имя которого занято только закрытым спринтом, — failed «имя занято».
M-3: sprint_state принимает только целый id > 0 и непустое строковое name, нестроковые
даты и goal дают null (как check_boards хаба).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 19:46:46 +04:00
parent 9d3958e337
commit d71c2f5180
3 changed files with 51 additions and 6 deletions

View File

@ -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)

View File

@ -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:")

View File

@ -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"}]))