From 33d8a1e7e30e4b2d564d553d040954b423e396c6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:10:58 +0400 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B3=D0=B5=D0=BD=D1=82:=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=B0=D0=BD=D0=B4=D0=B0=20ensure=5Fsprint=5Fstate=20?= =?UTF-8?q?=E2=80=94=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20=D0=B8=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D1=82=D0=B0=20=D1=81=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81=D0=BE=D0=BC=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 74 ++++++++++++++++++++++++++- tests/fake_jira.py | 8 ++- tests/test_executor_sprints.py | 91 +++++++++++++++++++++++++++++++++- 3 files changed, 169 insertions(+), 4 deletions(-) diff --git a/src/agent.py b/src/agent.py index a825aae..3a3ab41 100644 --- a/src/agent.py +++ b/src/agent.py @@ -1114,7 +1114,7 @@ class Executor: if len(to) > 1: return self._out("failed", error=self.many_sprints(rename_to, to)) if found and to: - return self._out("failed", error="имя «%s» занято: есть и «%s», и «%s»" % (rename_to, name, rename_to)) + return self._out("failed", error="имя занято") 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="спринт закрыт") @@ -1143,6 +1143,78 @@ class Executor: self.jira.post(AGILE + "/sprint/%s" % cur["id"], diff) return self._out("done", {"sprint_id": cur["id"], "created": False}) + # --- ensure_sprint_state --------------------------------------------------- + def sprint_issues(self, sprint_id: Any) -> List[Tuple[str, Optional[str]]]: + """Все задачи спринта: (ключ, имя статуса), постранично по 50.""" + out: List[Tuple[str, Optional[str]]] = [] + start = 0 + while True: + page = self.jira.get(AGILE + "/sprint/%s/issue" % sprint_id, fields="status", startAt=start, maxResults=50) + items = page.get("issues") or [] + for it in items: + status = (((it.get("fields") or {}).get("status")) or {}).get("name") + out.append((it["key"], status)) + start += len(items) + if not items or start >= int(page.get("total") or 0): + return out + + def do_ensure_sprint_state(self, p: dict, cmd: dict) -> dict: + """Запуск (active) или завершение (closed) спринта. Все проверки — до первой записи (R-71); при завершении + незавершённые задачи (статус не в последней колонке доски) переносятся в `move_open_to` (backlog или другой + активный/будущий спринт той же доски), затем спринт закрывается. Спринт уже в нужном состоянии — ничего не пишем.""" + state = p.get("state") + if state not in ("active", "closed"): + return self._out("failed", error="state — active или closed") + board = self.sprint_board(p) + name = p["sprint"] + found = self.sprints_named(board, name) + if not found: + return self._out("failed", error="нет спринта «%s» на доске %s" % (name, board)) + if len(found) > 1: + return self._out("failed", error=self.many_sprints(name, found)) + sp = found[0] + cur = str(sp.get("state")).lower() + if cur == state: + return self._out("done", {"sprint_id": sp["id"], "moved": []}) + if state == "active": + if cur == "closed": + return self._out("failed", error="спринт закрыт") + if not sp.get("startDate") or not sp.get("endDate"): + return self._out("failed", error="у спринта нет дат: задайте ensure_sprint_meta") + self.jira.post(AGILE + "/sprint/%s" % sp["id"], + {"state": "active", "startDate": sp["startDate"], "endDate": sp["endDate"]}) + return self._out("done", {"sprint_id": sp["id"], "moved": []}) + if cur == "future": + return self._out("failed", error="спринт не начат") + to = p.get("move_open_to") + if not to: + return self._out("failed", error="move_open_to обязателен при state = closed") + target = None + if to != "backlog": + cands = [x for x in self.sprints_named(board, to) + if str(x.get("state")).lower() in ("active", "future") and x.get("id") != sp["id"]] + if len(cands) > 1: + return self._out("failed", error=self.many_sprints(to, cands)) + if not cands: + return self._out("failed", error="нет спринта «%s» среди активных и будущих доски %s" % (to, board)) + target = cands[0] + cols = self.board_columns(board) + if not cols: + return self._out("failed", error="не удалось прочитать колонки доски") + done_statuses = set(cols[-1]["statuses"]) + moved = [k for k, st in self.sprint_issues(sp["id"]) if st not in done_statuses] + first = True + for i in range(0, len(moved), 50): + if not first: + self.gap("ensure_sprint_state") + first = False + path = "/sprint/%s/issue" % target["id"] if target else "/backlog/issue" + self.jira.post(AGILE + path, {"issues": moved[i:i + 50]}) + if not first: + self.gap("ensure_sprint_state") + self.jira.post(AGILE + "/sprint/%s" % sp["id"], {"state": "closed"}) + return self._out("done", {"sprint_id": sp["id"], "moved": moved}) + # --- ensure_attachment ----------------------------------------------------- def hub_file(self, sha: str, size: int) -> Optional[bytes]: """Файл вложения у хаба. None — хаб его не знает (404). Повреждённый или нечитаемый ответ — HubError с повтором, diff --git a/tests/fake_jira.py b/tests/fake_jira.py index fa7c9a8..50ae550 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -536,13 +536,17 @@ class FakeJira: sp = next(s for s in self.all_sprints() if s["id"] == int(m.group(1))) if method == "GET": members = self.sprint_members.get(sp["id"]) - keys = [k for k in self.search_keys(q.get("jql") or "key in ()") + pool = list(self.issues) if "jql" not in q else self.search_keys(q.get("jql") or "key in ()") + keys = [k for k in pool if (k in members if members is not None else self.issues[k]["sprint"] == sp["name"])] start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50)) return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), - "issues": [{"key": k} for k in keys[start:start + maxr]]} + "issues": [{"key": k, "fields": {"status": {"name": self.issues[k]["status"]}}} + for k in keys[start:start + maxr]]} for k in body["issues"]: self.issues[k]["sprint"] = sp["name"] + if self.issues[k]["sprint_ids"] is not None: + self.issues[k]["sprint_ids"].append(sp["id"]) return 204, None if p == "/rest/agile/1.0/backlog/issue" and method == "POST": for k in body["issues"]: diff --git a/tests/test_executor_sprints.py b/tests/test_executor_sprints.py index d21fba7..7a45071 100644 --- a/tests/test_executor_sprints.py +++ b/tests/test_executor_sprints.py @@ -60,7 +60,7 @@ class ExecutorSprintsTest(unittest.TestCase): {"id": 10, "name": "T", "state": "future"}] out = self.run_cmd("ensure_sprint_meta", sprint="S", rename_to="T", **B) self.assertEqual(out["status"], "failed") - self.assertIn("занято", out["error"]) + self.assertEqual(out["error"], "имя занято") self.assertEqual(self.fake.writes(), []) def test_meta_repeat_after_rename(self): # Review Focus 3, R-69 @@ -89,3 +89,92 @@ class ExecutorSprintsTest(unittest.TestCase): out = self.run_cmd("ensure_sprint_meta", sprint="N") self.assertEqual((out["status"], out["error"]), ("failed", "board_id нет в команде — спринты недоступны")) self.assertEqual(self.fake.requests, []) + + # --- ensure_sprint_state --- + def sprint_with(self, n_done, n_open): # спринт 3 активный, спринт 4 будущий, задачи по имени спринта + self.fake.board_sprints[4216] = [{"id": 3, "name": "AWPCA_Sprint_3_Corrections", "state": "active"}, + {"id": 4, "name": "AWPCA_Sprint_4", "state": "future"}] + open_keys = [] + for i in range(n_done + n_open): + key = "AURORA-%d" % (2000 + i) + self.fake.add(key, sprint="AWPCA_Sprint_3_Corrections", status="Готово" if i < n_done else "Разработка") + if i >= n_done: + open_keys.append(key) + return open_keys + + def test_close_with_move(self): # сценарий «Завершение спринта с переносом» + open_keys = self.sprint_with(25, 12) + params = dict(B, sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="AWPCA_Sprint_4") + out = self.run_cmd("ensure_sprint_state", **params) + self.assertEqual(out["result"], {"sprint_id": 3, "moved": open_keys}) + self.assertEqual({self.fake.issues[k]["sprint"] for k in open_keys}, {"AWPCA_Sprint_4"}) + self.assertEqual(self.fake.board_sprints[4216][0]["state"], "closed") + writes = len(self.fake.writes()) + again = self.run_cmd("ensure_sprint_state", **params) + self.assertEqual(again["result"], {"sprint_id": 3, "moved": []}) + self.assertEqual(len(self.fake.writes()), writes) + + def test_close_chunks_and_gap(self): + self.sprint_with(0, 60) + self.ex.s["pacing"] = {"ensure_sprint_state": 30} + self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B) + self.assertEqual([p for _, p in self.fake.writes()], + ["/rest/agile/1.0/backlog/issue"] * 2 + ["/rest/agile/1.0/sprint/3"]) + self.assertEqual(self.gaps, [30, 30]) + self.assertEqual(self.fake.requests.count(("GET", "/rest/agile/1.0/sprint/3/issue")), 2) + + def test_activate_future(self): + self.fake.board_sprints[4216] = [{"id": 4, "name": "S4", "state": "future", + "startDate": "2026-10-28T09:00:00.000+03:00", + "endDate": "2026-11-11T18:00:00.000+03:00"}] + out = self.run_cmd("ensure_sprint_state", sprint="S4", state="active", **B) + self.assertEqual(out["result"], {"sprint_id": 4, "moved": []}) + self.assertEqual(self.fake.sprint_posts, [("/rest/agile/1.0/sprint/4", { + "state": "active", "startDate": "2026-10-28T09:00:00.000+03:00", "endDate": "2026-11-11T18:00:00.000+03:00"})]) + self.assertEqual(self.fake.board_sprints[4216][0]["state"], "active") + writes = len(self.fake.writes()) + again = self.run_cmd("ensure_sprint_state", sprint="S4", state="active", **B) + self.assertEqual(again["result"], {"sprint_id": 4, "moved": []}) + self.assertEqual(len(self.fake.writes()), writes) + self.fake.board_sprints[4216] = [{"id": 5, "name": "S5", "state": "future"}] + self.fake.sprint_posts, self.fake.requests = [], [] + out = self.run_cmd("ensure_sprint_state", sprint="S5", state="active", **B) + self.assertEqual((out["status"], out["error"]), ("failed", "у спринта нет дат: задайте ensure_sprint_meta")) + self.assertEqual(self.fake.writes(), []) + + def test_state_wrong_transitions(self): + self.fake.board_sprints[4216] = [{"id": 4, "name": "F", "state": "future"}, + {"id": 1, "name": "C", "state": "closed"}] + out = self.run_cmd("ensure_sprint_state", sprint="F", state="closed", move_open_to="backlog", **B) + self.assertEqual((out["status"], out["error"]), ("failed", "спринт не начат")) + out = self.run_cmd("ensure_sprint_state", sprint="C", state="active", **B) + self.assertEqual((out["status"], out["error"]), ("failed", "спринт закрыт")) + self.assertEqual(self.fake.writes(), []) + + def test_state_validation(self): + self.sprint_with(0, 1) + out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="future", **B) + self.assertEqual((out["status"], out["error"]), ("failed", "state — active или closed")) + out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", **B) + self.assertEqual((out["status"], out["error"]), ("failed", "move_open_to обязателен при state = closed")) + out = self.run_cmd("ensure_sprint_state", sprint="Нет", state="active", **B) + self.assertEqual((out["status"], out["error"]), ("failed", "нет спринта «Нет» на доске 4216")) + self.fake.board_sprints[4216] += [{"id": 7, "name": "Д", "state": "future"}, {"id": 8, "name": "Д", "state": "future"}] + out = self.run_cmd("ensure_sprint_state", sprint="Д", state="active", **B) + self.assertEqual(out["error"], "несколько спринтов с именем «Д»: 7, 8") + self.assertEqual(self.fake.writes(), []) + + def test_state_bad_move_target_no_writes(self): # Review Focus 4 + self.sprint_with(1, 1) + self.fake.board_sprints[4216].append({"id": 1, "name": "Старый", "state": "closed"}) + for target in ("Нет такого", "Старый", "AWPCA_Sprint_3_Corrections"): + out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to=target, **B) + self.assertEqual(out["status"], "failed", target) + self.assertEqual(self.fake.writes(), []) + + def test_close_columns_unreadable(self): + self.sprint_with(1, 1) + self.fake.fail_paths["/rest/agile/1.0/board/4216/configuration"] = [403] + out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B) + self.assertEqual((out["status"], out["error"]), ("failed", "не удалось прочитать колонки доски")) + self.assertEqual(self.fake.writes(), [])