Агент: команда ensure_sprint_meta — создание и правка спринта (#49)

Команда ищет спринт доски по имени, создаёт его или записывает только
отличающиеся имя, даты (по дню), цель. Повтор после переименования
находит спринт по новому имени; оба имени заняты — ошибка без записи.
Даты проверяются первыми, доска берётся только из команды.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 19:04:10 +04:00
parent 70825ff4d1
commit 54f5781b98
4 changed files with 196 additions and 1 deletions

View File

@ -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 с повтором,

View File

@ -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",

View File

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

View File

@ -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, [])