feat(agent): ensure_sprint по доске из команды, кэш спринтов по доскам (#35)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f3e899ec2c
commit
fc87f54cf9
26
src/agent.py
26
src/agent.py
@ -557,11 +557,11 @@ class Executor:
|
||||
self.jira, self.s, self.hub = jira, settings, hub
|
||||
self.sleep = time.sleep
|
||||
self._symmetric = None
|
||||
self._sprints = None
|
||||
self._sprints = {} # str(доска) → имя → спринт; на одну команду
|
||||
|
||||
def run(self, cmd: dict) -> dict:
|
||||
self.jira.reset_counters()
|
||||
self._sprints = None
|
||||
self._sprints = {}
|
||||
handler = getattr(self, "do_" + str(cmd.get("type")), None)
|
||||
if handler is None:
|
||||
return self._out("failed", error="неизвестный тип команды %s" % cmd.get("type"))
|
||||
@ -835,11 +835,10 @@ class Executor:
|
||||
return self._out("done", {"created": True, "removed": removed})
|
||||
|
||||
# --- ensure_sprint ---------------------------------------------------------
|
||||
def sprints(self) -> Dict[str, dict]:
|
||||
"""Имя → спринт (active и future) доски board_id; без доски — пусто."""
|
||||
if self._sprints is None:
|
||||
def sprints(self, board: Any) -> Dict[str, dict]:
|
||||
"""Имя → спринт (active и future) доски board; без доски — пусто."""
|
||||
if str(board) not in self._sprints:
|
||||
out, start = {}, 0
|
||||
board = self.s.get("board_id")
|
||||
while board:
|
||||
page = self.jira.get(AGILE + "/board/%s/sprint" % board, state="active,future", startAt=start, maxResults=50)
|
||||
for sp in page.get("values", []):
|
||||
@ -847,8 +846,8 @@ class Executor:
|
||||
if page.get("isLast", True) or not page.get("values"):
|
||||
break
|
||||
start += len(page["values"])
|
||||
self._sprints = out
|
||||
return self._sprints
|
||||
self._sprints[str(board)] = out
|
||||
return self._sprints[str(board)]
|
||||
|
||||
def do_ensure_sprint(self, p: dict, cmd: dict) -> dict:
|
||||
key, want = p["key"], p["sprint"]
|
||||
@ -860,14 +859,15 @@ class Executor:
|
||||
return self._out("done", {"sprint": None, "changed": False})
|
||||
self.jira.post(AGILE + "/backlog/issue", {"issues": [key]})
|
||||
return self._out("done", {"sprint": None, "from": cur})
|
||||
if not self.s.get("board_id"):
|
||||
return self._out("failed", error="board_id не задан в настройках хаба — спринты недоступны")
|
||||
board = p.get("board_id") or self.s.get("board_id") # без доски в команде — хаб до проектов (v11)
|
||||
if not board:
|
||||
return self._out("failed", error="board_id нет ни в команде, ни в настройках хаба — спринты недоступны")
|
||||
if cur == want:
|
||||
return self._out("done", {"sprint": want, "changed": False})
|
||||
sp = self.sprints().get(want)
|
||||
sp = self.sprints(board).get(want)
|
||||
if not sp:
|
||||
return self._out("failed", error="нет спринта «%s» среди активных и будущих доски %s: %s"
|
||||
% (want, self.s["board_id"], ", ".join(sorted(self.sprints())) or "—"))
|
||||
% (want, board, ", ".join(sorted(self.sprints(board))) or "—"))
|
||||
self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]})
|
||||
return self._out("done", {"sprint": want, "from": cur})
|
||||
|
||||
@ -1027,7 +1027,7 @@ class Executor:
|
||||
names = {}
|
||||
if not keys or not self.s.get("board_id"):
|
||||
return names
|
||||
for name, sp in self.sprints().items():
|
||||
for name, sp in self.sprints(self.s["board_id"]).items():
|
||||
for i in range(0, len(keys), 100):
|
||||
chunk = keys[i:i + 100]
|
||||
start = 0
|
||||
|
||||
@ -69,6 +69,9 @@ class FakeJira:
|
||||
self.comments = {}
|
||||
self.links = []
|
||||
self.sprints = [{"id": 1, "name": "Спринт 1", "state": "active"}, {"id": 2, "name": "Спринт 2", "state": "future"}]
|
||||
self.boards = {4216: "scrum"} # доска → тип; GET /rest/agile/1.0/board/{id}
|
||||
self.board_sprints = {} # доска → спринты; доски нет — общий self.sprints
|
||||
self.sprint_members = {} # id спринта → ключи задач (иначе — по имени спринта задачи)
|
||||
self.seq, self.link_seq, self.comment_seq = 1000, 100, 10
|
||||
self.id_seq = 10000
|
||||
self.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой
|
||||
@ -349,17 +352,28 @@ class FakeJira:
|
||||
tid = int(body["transition"]["id"]) - 1
|
||||
self.issues[key]["status"] = trs[tid][1]
|
||||
return 204, None
|
||||
m = re.match(r"^/rest/agile/1\.0/board/(\d+)$", p)
|
||||
if m:
|
||||
board = int(m.group(1))
|
||||
if board not in self.boards:
|
||||
return 404, {"errorMessages": ["Board does not exist"]}
|
||||
return 200, {"id": board, "name": "Доска %d" % board, "type": self.boards[board]}
|
||||
m = re.match(r"^/rest/agile/1\.0/board/(\d+)/sprint$", p)
|
||||
if m:
|
||||
board = int(m.group(1))
|
||||
if self.boards.get(board) == "kanban":
|
||||
return 400, {"errorMessages": ["The board does not support sprints"]}
|
||||
states = (q.get("state") or "active,future").split(",")
|
||||
vals = [s for s in self.sprints if s["state"] in states]
|
||||
vals = [s for s in self.board_sprints.get(board, self.sprints) if 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)}
|
||||
m = re.match(r"^/rest/agile/1\.0/sprint/(\d+)/issue$", p)
|
||||
if m:
|
||||
sp = next(s for s in self.sprints if s["id"] == int(m.group(1)))
|
||||
sp = next(s for s in self.all_sprints() if s["id"] == int(m.group(1)))
|
||||
if method == "GET":
|
||||
keys = [k for k in self.search_keys(q.get("jql") or "key in ()") if self.issues[k]["sprint"] == sp["name"]]
|
||||
members = self.sprint_members.get(sp["id"])
|
||||
keys = [k for k in self.search_keys(q.get("jql") or "key in ()")
|
||||
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]]}
|
||||
@ -373,12 +387,18 @@ class FakeJira:
|
||||
m = re.match(r"^/rest/agile/1\.0/issue/([^/]+)$", p)
|
||||
if m:
|
||||
name = self.issues[m.group(1)]["sprint"]
|
||||
sp = next((s for s in self.sprints if s["name"] == name), None)
|
||||
sp = next((s for s in self.all_sprints() if s["name"] == name), None)
|
||||
if sp is None: # как Jira банка (13.10.2026): без спринта ключа fields в ответе нет
|
||||
return 200, {"key": m.group(1)}
|
||||
return 200, {"key": m.group(1), "fields": {"sprint": sp}}
|
||||
return 404, {"errorMessages": ["нет маршрута %s %s" % (method, p)]}
|
||||
|
||||
def all_sprints(self):
|
||||
out = list(self.sprints)
|
||||
for vals in self.board_sprints.values():
|
||||
out += vals
|
||||
return out
|
||||
|
||||
def _type_name(self, ref):
|
||||
ref = ref or {}
|
||||
if ref.get("name"):
|
||||
|
||||
@ -199,6 +199,48 @@ class ExecutorLinksTest(unittest.TestCase):
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("board_id", out["error"])
|
||||
|
||||
def test_sprint_board_from_params(self):
|
||||
self.fake.boards[5120] = "scrum"
|
||||
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт K", board_id=5120)
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"], {"sprint": "Спринт K", "from": None})
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт K")
|
||||
paths = [r[1] for r in self.fake.requests]
|
||||
self.assertIn("/rest/agile/1.0/board/5120/sprint", paths)
|
||||
self.assertNotIn("/rest/agile/1.0/board/4216/sprint", paths) # settings.board_id = 4216 не трогается
|
||||
again = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт K", board_id=5120)
|
||||
self.assertEqual(again["result"], {"sprint": "Спринт K", "changed": False})
|
||||
|
||||
def test_sprint_board_from_params_without_settings(self): # сценарий «Спринт по доске из команды»
|
||||
self.ex.s["board_id"] = None
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=4216)
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2")
|
||||
|
||||
def test_sprint_unknown_name_names_param_board(self):
|
||||
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("5120", out["error"])
|
||||
self.assertIn("Спринт K", out["error"])
|
||||
|
||||
def test_sprint_kanban_board_failed_with_jira_text(self):
|
||||
self.fake.boards[5120] = "kanban"
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("HTTP 400", out["error"])
|
||||
self.assertIn("does not support sprints", out["error"])
|
||||
self.assertFalse(out["retryable"])
|
||||
self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"])
|
||||
|
||||
def test_sprint_backlog_ignores_board(self):
|
||||
self.fake.boards[5120] = "kanban"
|
||||
self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1"
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog", board_id=5120)
|
||||
self.assertEqual(out["result"], {"sprint": None, "from": "Спринт 1"})
|
||||
self.assertFalse(any("/board/" in r[1] for r in self.fake.requests))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user