Агент: команда ensure_sprint_meta — создание и правка спринта (#49)
Команда ищет спринт доски по имени, создаёт его или записывает только отличающиеся имя, даты (по дню), цель. Повтор после переименования находит спринт по новому имени; оба имени заняты — ошибка без записи. Даты проверяются первыми, доска берётся только из команды. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
70825ff4d1
commit
54f5781b98
80
src/agent.py
80
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 с повтором,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)))
|
||||
|
||||
91
tests/test_executor_sprints.py
Normal file
91
tests/test_executor_sprints.py
Normal 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, [])
|
||||
Loading…
x
Reference in New Issue
Block a user