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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 19:10:58 +04:00
parent 73cbb727ad
commit 33d8a1e7e3
3 changed files with 169 additions and 4 deletions

View File

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

View File

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

View File

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