From 54f5781b9800775f7119366ec4457a7a7fdaabf6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:04:10 +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=5Fmeta=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=D1=81=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D1=82=D0=B0=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 | 80 ++++++++++++++++++++++++++++++ tests/_common.py | 3 +- tests/fake_jira.py | 23 +++++++++ tests/test_executor_sprints.py | 91 ++++++++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 tests/test_executor_sprints.py diff --git a/src/agent.py b/src/agent.py index b9a4089..a825aae 100644 --- a/src/agent.py +++ b/src/agent.py @@ -650,6 +650,22 @@ def _same_points(a, b) -> bool: return abs(a - b) < 1e-9 +def _is_day(value: Any) -> bool: + """Строка ГГГГ-ММ-ДД, которую разбирает strptime (28.10.2026 и 2026-13-01 — нет).""" + if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value): + return False + try: + datetime.strptime(value, "%Y-%m-%d") + except ValueError: + return False + return True + + +def _sprint_time(day: str, hour: int) -> str: + """День спринта → время Jira (`ГГГГ-ММ-ДДTЧЧ:00:00.000+ЗЗЗЗ`) со смещением локального часового пояса (R-68).""" + return "%sT%02d:00:00.000%s" % (day, hour, datetime.now().astimezone().strftime("%z")) + + class ConfigGap(Exception): """В настройках хаба нет того, что нужно команде (R-11): команда падает с понятным текстом, а не молча теряет поле.""" @@ -1063,6 +1079,70 @@ class Executor: self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]}) return self._out("done", {"sprint": want, "from": cur}) + # --- ensure_sprint_meta ---------------------------------------------------- + def sprint_board(self, p: dict) -> Any: + """Доска из команды; без неё спринты недоступны (R-70): доска из настроек хаба не подменяет команду.""" + board = p.get("board_id") + if not board: + raise ConfigGap("board_id нет в команде — спринты недоступны") + return board + + def sprints_named(self, board: Any, name: str) -> List[dict]: + """Все спринты доски board (любых состояний) с точным именем name.""" + return [sp for sp in self.all_sprints(board) if sp.get("name") == name] + + @staticmethod + def many_sprints(name: str, found: List[dict]) -> str: + return "несколько спринтов с именем «%s»: %s" % (name, ", ".join(str(sp["id"]) for sp in found)) + + def do_ensure_sprint_meta(self, p: dict, cmd: dict) -> dict: + """Имя, даты и цель спринта доски. Даты проверяются первыми; спринт ищется по `sprint`, а при `rename_to` — + по нему же (R-69): повтор после переименования находит спринт по новому имени; оба имени заняты — ошибка.""" + for field in ("start_date", "end_date"): + if p.get(field) is not None and not _is_day(p[field]): + return self._out("failed", error="%s: дата не в формате ГГГГ-ММ-ДД: %r" % (field, p[field])) + board = self.sprint_board(p) + name, rename_to = p["sprint"], p.get("rename_to") or None + if rename_to == name: + rename_to = None + found = self.sprints_named(board, name) + if len(found) > 1: + return self._out("failed", error=self.many_sprints(name, found)) + to = [] + if rename_to: + to = [sp for sp in self.sprints_named(board, rename_to) if str(sp.get("state")).lower() != "closed"] + 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)) + 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="спринт закрыт") + target = rename_to or name + if cur is None: + body = {"name": target, "originBoardId": int(board)} + if p.get("start_date") is not None: + body["startDate"] = _sprint_time(p["start_date"], 9) + if p.get("end_date") is not None: + body["endDate"] = _sprint_time(p["end_date"], 18) + if p.get("goal") is not None: + body["goal"] = p["goal"] + created = self.jira.post(AGILE + "/sprint", body) + return self._out("done", {"sprint_id": created["id"], "created": True}) + diff = {} + if cur.get("name") != target: + diff["name"] = target + if p.get("start_date") is not None and _date(cur.get("startDate")) != p["start_date"]: + diff["startDate"] = _sprint_time(p["start_date"], 9) + if p.get("end_date") is not None and _date(cur.get("endDate")) != p["end_date"]: + diff["endDate"] = _sprint_time(p["end_date"], 18) + if p.get("goal") is not None and (cur.get("goal") or "") != p["goal"]: + diff["goal"] = p["goal"] + if not diff: + return self._out("done", {"sprint_id": cur["id"], "created": False}) + self.jira.post(AGILE + "/sprint/%s" % cur["id"], diff) + return self._out("done", {"sprint_id": cur["id"], "created": False}) + # --- ensure_attachment ----------------------------------------------------- def hub_file(self, sha: str, size: int) -> Optional[bytes]: """Файл вложения у хаба. None — хаб его не знает (404). Повреждённый или нечитаемый ответ — HubError с повтором, diff --git a/tests/_common.py b/tests/_common.py index b581957..79a21f7 100644 --- a/tests/_common.py +++ b/tests/_common.py @@ -9,7 +9,8 @@ if SRC not in sys.path: import agent # noqa: E402 ZERO_PACING = {"ensure_issue": 0, "ensure_fields": 0, "ensure_comment": 0, "ensure_status": 0, "ensure_link": 0, - "ensure_sprint": 0, "ensure_attachment": 0, "refresh": 0} + "ensure_sprint": 0, "ensure_sprint_meta": 0, "ensure_sprint_state": 0, "ensure_rank": 0, + "ensure_attachment": 0, "fetch_attachment": 0, "refresh": 0} SETTINGS = { "base_jql": "project = AURORA AND labels = AWPCA", diff --git a/tests/fake_jira.py b/tests/fake_jira.py index a43c429..fa7c9a8 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -82,6 +82,8 @@ class FakeJira: self.fail_paths = {} # путь → [коды]: каждый запрос по пути снимает первый и отвечает им self.sprint_field_format = "object" # элемент поля спринтов: словарь или строка greenhopper self.sprint_members = {} # id спринта → ключи задач (иначе — по имени спринта задачи) + self.sprint_seq = 500 # id следующего созданного спринта + self.sprint_posts = [] # (путь, тело) каждой записи спринта: POST /sprint и POST /sprint/{id} self.seq, self.link_seq, self.comment_seq = 1000, 100, 10 self.id_seq = 10000 self.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой @@ -508,6 +510,27 @@ class FakeJira: vals = [s for s in self.board_sprints.get(board, self.sprints) if states is None or 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)} + if p == "/rest/agile/1.0/sprint" and method == "POST": + self.sprint_posts.append((p, body)) + self.sprint_seq += 1 + sp = {"id": self.sprint_seq, "name": body["name"], "state": "future", "originBoardId": body["originBoardId"]} + sp.update({k: body[k] for k in ("startDate", "endDate", "goal") if k in body}) + target = self.board_sprints[body["originBoardId"]] if body["originBoardId"] in self.board_sprints else self.sprints + target.append(sp) + return 201, sp + m = re.match(r"^/rest/agile/1\.0/sprint/(\d+)$", p) + if m and method == "POST": + self.sprint_posts.append((p, body)) + sp = next((s for s in self.all_sprints() if s["id"] == int(m.group(1))), None) + if sp is None: + return 404, {"errorMessages": ["Sprint does not exist"]} + if "state" in body: + st = body["state"] + dated = bool(body.get("startDate", sp.get("startDate")) and body.get("endDate", sp.get("endDate"))) + if (st == "active" and (sp["state"] != "future" or not dated)) or (st == "closed" and sp["state"] != "active"): + return 400, {"errorMessages": ["недопустимый переход состояния спринта"]} + sp.update({k: body[k] for k in ("name", "startDate", "endDate", "goal", "state") if k in body}) + return 200, sp m = re.match(r"^/rest/agile/1\.0/sprint/(\d+)/issue$", p) if m: sp = next(s for s in self.all_sprints() if s["id"] == int(m.group(1))) diff --git a/tests/test_executor_sprints.py b/tests/test_executor_sprints.py new file mode 100644 index 0000000..d21fba7 --- /dev/null +++ b/tests/test_executor_sprints.py @@ -0,0 +1,91 @@ +import unittest +from datetime import datetime + +from _common import SETTINGS, agent, jira_cfg, no_sleep +from fake_jira import FakeJira + +B = {"board_id": 4216} + + +class ExecutorSprintsTest(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") + + def tearDown(self): + self.fake.stop() + + def run_cmd(self, ctype, **params): + return self.ex.run({"id": 1, "type": ctype, "params": params}) + + # --- ensure_sprint_meta --- + def test_meta_create_then_repeat(self): # сценарий «Создание спринта повтором не дублируется» + self.fake.board_sprints[4216] = [{"id": 3, "name": "AWPCA_Sprint_3_Corrections", "state": "active"}] + params = dict(B, sprint="AWPCA_Sprint_4", start_date="2026-10-28", end_date="2026-11-11", goal="свод ВГО") + out = self.run_cmd("ensure_sprint_meta", **params) + self.assertEqual(out["status"], "done", out) + sid = out["result"]["sprint_id"] + self.assertEqual(out["result"], {"sprint_id": sid, "created": True}) + sp = self.fake.board_sprints[4216][-1] + off = datetime.now().astimezone().strftime("%z") + self.assertEqual((sp["id"], sp["name"], sp["state"], sp["originBoardId"], sp["goal"]), + (sid, "AWPCA_Sprint_4", "future", 4216, "свод ВГО")) + self.assertEqual((sp["startDate"], sp["endDate"]), + ("2026-10-28T09:00:00.000" + off, "2026-11-11T18:00:00.000" + off)) + again = self.run_cmd("ensure_sprint_meta", **params) + self.assertEqual(again["result"], {"sprint_id": sid, "created": False}) + self.assertEqual(len(self.fake.writes()), 1) + + def test_meta_updates_only_diff(self): # даты совпадают по дню, отличается цель + self.fake.board_sprints[4216] = [{"id": 9, "name": "S", "state": "future", "goal": "старая", + "startDate": "2026-10-28T10:00:00.000+03:00", + "endDate": "2026-11-11T19:00:00.000+03:00"}] + out = self.run_cmd("ensure_sprint_meta", sprint="S", start_date="2026-10-28", end_date="2026-11-11", + goal="новая", **B) + self.assertEqual(out["result"], {"sprint_id": 9, "created": False}) + self.assertEqual(self.fake.sprint_posts, [("/rest/agile/1.0/sprint/9", {"goal": "новая"})]) + + def test_meta_rename_and_name_taken(self): # S → T: POST {"name": "T"}; есть S и T — «имя занято», без записи + self.fake.board_sprints[4216] = [{"id": 9, "name": "S", "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.sprint_posts, [("/rest/agile/1.0/sprint/9", {"name": "T"})]) + self.fake.sprint_posts, self.fake.requests = [], [] + self.fake.board_sprints[4216] = [{"id": 9, "name": "S", "state": "future"}, + {"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(self.fake.writes(), []) + + def test_meta_repeat_after_rename(self): # Review Focus 3, R-69 + self.fake.board_sprints[4216] = [{"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(), []) + + def test_meta_closed_and_duplicates(self): + self.fake.board_sprints[4216] = [{"id": 1, "name": "C", "state": "closed"}, + {"id": 5, "name": "D", "state": "future"}, + {"id": 6, "name": "D", "state": "active"}] + self.assertEqual(self.run_cmd("ensure_sprint_meta", sprint="C", goal="x", **B)["error"], "спринт закрыт") + self.assertEqual(self.run_cmd("ensure_sprint_meta", sprint="D", **B)["error"], + "несколько спринтов с именем «D»: 5, 6") + self.assertEqual(self.fake.writes(), []) + + def test_meta_bad_date(self): # Review Focus 5 + for bad in ("28.10.2026", "2026-13-01"): + out = self.run_cmd("ensure_sprint_meta", sprint="N", start_date=bad, **B) + self.assertEqual(out["status"], "failed") + self.assertIn("ГГГГ-ММ-ДД", out["error"]) + self.assertEqual(self.fake.writes(), []) + + def test_meta_without_board(self): # R-70 + out = self.run_cmd("ensure_sprint_meta", sprint="N") + self.assertEqual((out["status"], out["error"]), ("failed", "board_id нет в команде — спринты недоступны")) + self.assertEqual(self.fake.requests, [])