Агент: команда ensure_sprint_state — запуск и завершение спринта с переносом (#49)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
73cbb727ad
commit
33d8a1e7e3
74
src/agent.py
74
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 с повтором,
|
||||
|
||||
@ -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"]:
|
||||
|
||||
@ -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(), [])
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user