diff --git a/src/agent.py b/src/agent.py index 78240fa..0295a29 100644 --- a/src/agent.py +++ b/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 diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 87bd154..7e08bc4 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -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"): diff --git a/tests/test_executor_links.py b/tests/test_executor_links.py index 2b41fbc..228ad88 100644 --- a/tests/test_executor_links.py +++ b/tests/test_executor_links.py @@ -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()