diff --git a/src/agent.py b/src/agent.py index b9aef90..0c2ab5e 100644 --- a/src/agent.py +++ b/src/agent.py @@ -679,6 +679,8 @@ class Executor: self.sleep = time.sleep self._symmetric = None self._sprints = {} # str(доска) → имя → спринт; на одну команду + self._all_sprints = {} # str(доска) → все спринты доски (любых состояний); на одну команду + self._status_names = None # id статуса → имя; кэш на экземпляр, пока жив Executor self._field_list = None # GET /field: список полей на одну команду self._jira_fields = None # ранг, спринт, оценка → id поля; см. jira_fields self._jira_fields_ok = False # поля прочитаны без ошибки: кэш живёт, пока жив Executor @@ -686,6 +688,7 @@ class Executor: def run(self, cmd: dict) -> dict: self.jira.reset_counters() self._sprints = {} + self._all_sprints = {} self._field_list = None if not self._jira_fields_ok: # неудачный поиск полей — повтор в следующей команде (R-67) self._jira_fields = None @@ -1191,7 +1194,7 @@ class Executor: i = it.get("id") if isinstance(i, int) and not isinstance(i, bool): sid = i - elif isinstance(i, str) and i.isdigit(): + elif isinstance(i, str) and i.isascii() and i.isdigit(): sid = int(i) elif isinstance(it, str): m = re.search(r"[\[,]id=(\d+)", it) @@ -1313,13 +1316,14 @@ class Executor: log("refresh: доска %s — тип «%s» не scrum/kanban" % (board, t)) return None - def refresh_boards(self, p: dict) -> Tuple[List[Any], Optional[Dict[str, Optional[str]]]]: - """(scrum-доски для имён спринтов, типы запрошенных досок для результата или None). - Без boards в команде — хаб до проектов: доска из настроек, как v11.""" + def refresh_boards(self, p: dict) -> Tuple[List[Any], List[Tuple[Any, str]], Optional[Dict[str, Optional[str]]]]: + """(scrum-доски для имён спринтов, доски с типом scrum/kanban для данных досок, типы запрошенных досок для + результата или None). Без boards в команде — хаб до проектов: доска из настроек, как v11.""" if "boards" not in p: board = self.s.get("board_id") - return ([board] if board else []), None + return ([board] if board else []), [], None scrum: List[Any] = [] + typed: List[Tuple[Any, str]] = [] types: Dict[str, Optional[str]] = {} seen = set() for b in p.get("boards") or []: @@ -1334,7 +1338,89 @@ class Executor: t = types[str(bid)] = self.board_type(bid) if t == "scrum": scrum.append(bid) - return scrum, (types or None) + if t in ("scrum", "kanban"): + typed.append((bid, t)) + return scrum, typed, (types or None) + + # --- данные досок в первой пачке refresh (v15) ------------------------------------------------------------- + def status_names(self) -> Dict[str, str]: + """id статуса → имя (GET /status); кэш на экземпляр, ошибка Jira наружу и не кэшируется.""" + if self._status_names is None: + self._status_names = {str(x["id"]): x["name"] for x in self.jira.get(API + "/status") if x.get("id") and x.get("name")} + return self._status_names + + def board_columns(self, board: Any) -> Optional[List[dict]]: + """Колонки доски: [{name, statuses: [имя]}]; неизвестный id статуса пропускается; ошибка Jira — лог и None.""" + try: + names = self.status_names() + cfg = self.jira.get(AGILE + "/board/%s/configuration" % board) + except HttpError as e: + log("доска %s — колонки не прочитаны (HTTP %d)" % (board, e.code)) + return None + out = [] + for col in (cfg.get("columnConfig") or {}).get("columns") or []: + out.append({"name": col.get("name"), + "statuses": [names[str(st.get("id"))] for st in col.get("statuses") or [] if str(st.get("id")) in names]}) + return out + + def kanban_backlog(self, board: Any) -> Optional[bool]: + """Включён ли бэклог канбан-доски: ответ — True, 400 — False, иная ошибка — None с записью в лог.""" + try: + self.jira.get(AGILE + "/board/%s/backlog" % board, maxResults=0) + except HttpError as e: + if e.code == 400: + return False + log("refresh: доска %s — бэклог не прочитан (HTTP %d)" % (board, e.code)) + return None + return True + + def all_sprints(self, board: Any) -> List[dict]: + """Все спринты доски любых состояний (GET /board/{id}/sprint без state, по 50); кэш на команду. + Заодно заполняет self._sprints именами активных и будущих, чтобы sprints() не спрашивал Jira повторно.""" + key = str(board) + if key not in self._all_sprints: + out, start = [], 0 + while True: + page = self.jira.get(AGILE + "/board/%s/sprint" % board, startAt=start, maxResults=50) + vals = page.get("values") or [] + out += vals + if page.get("isLast", True) or not vals: + break + start += len(vals) + self._all_sprints[key] = out + self._sprints[key] = {sp["name"]: sp for sp in out + if sp.get("name") and str(sp.get("state")).lower() in ("active", "future")} + return self._all_sprints[key] + + @staticmethod + def sprint_state(sp: dict) -> Optional[dict]: + """Спринт для хаба; без id/name или с состоянием вне active/future/closed — None (R-72).""" + state = sp.get("state") + state = state.lower() if isinstance(state, str) else None + if sp.get("id") is None or not sp.get("name") or state not in ("active", "future", "closed"): + return None + return {"id": sp["id"], "name": sp["name"], "state": state, "start_date": sp.get("startDate"), + "end_date": sp.get("endDate"), "complete_date": sp.get("completeDate"), "goal": sp.get("goal")} + + def board_state(self, board: Any, kind: str) -> Optional[dict]: + """Элемент boards первой пачки; ошибка чтения спринтов — доска не отправляется (R-65), колонок — columns null.""" + columns = self.board_columns(board) + backlog = self.kanban_backlog(board) if kind == "kanban" else None + sprints: List[dict] = [] + if kind == "scrum": + try: + raw = self.all_sprints(board) + except HttpError as e: + log("refresh: доска %s — спринты не прочитаны (HTTP %d), данные доски не отправлены" % (board, e.code)) + return None + for sp in raw: + st = self.sprint_state(sp) + if st is None: + log("refresh: доска %s — спринт %r пропущен: нет id/name или состояние не active/future/closed" + % (board, sp.get("id"))) + else: + sprints.append(st) + return {"id": board, "type": kind, "columns": columns, "kanban_backlog": backlog, "sprints": sprints} def board_sprints(self, board: Any, types: Dict[str, Optional[str]]) -> Dict[str, dict]: """Спринты scrum-доски для refresh. Доска не отдала спринты: стала канбаном — тип в types, доска пропускается @@ -1372,16 +1458,26 @@ class Executor: break return names + def send_empty_batch(self, cmd: dict, boards: List[dict]) -> None: + self._hub(self.hub.state, {"command_id": cmd.get("id"), "agent_version": AGENT_VERSION, "issues": [], "links": [], + "neighbor_keys": [], "page": 1, "last_page": True, "boards": boards}) + def do_refresh(self, p: dict, cmd: dict) -> dict: missing: List[str] = [] errors: List[HttpError] = [] - scrum, types = self.refresh_boards(p) # первым шагом: тип досок нужен хабу и при пустых keys + scrum, typed, types = self.refresh_boards(p) # первым шагом: тип досок нужен хабу и при пустых keys + boards = None + if "boards" in p: + boards = [b for b in (self.board_state(bid, kind) for bid, kind in typed) if b is not None] if p.get("jql"): raws = self.search_all(p["jql"]) else: keys = list(dict.fromkeys(k for k in (p.get("keys") or []) if k)) if not keys: empty: Dict[str, Any] = {"issues": 0, "neighbors": 0, "pages": 0} + if boards is not None: # данные досок уходят пустой пачкой (R-66) + self.send_empty_batch(cmd, boards) + empty["pages"] = 1 if types is not None: empty["boards"] = types return self._out("done", empty) @@ -1428,10 +1524,16 @@ class Executor: chunk = issues[i:i + 100] chunk_keys = {x["key"] for x in chunk} pages += 1 - self._hub(self.hub.state, {"command_id": cmd.get("id"), "agent_version": AGENT_VERSION, "issues": chunk, - "links": [l for l in links if l["source_key"] in chunk_keys or l["target_key"] in chunk_keys], - "neighbor_keys": [k for k in neighbor_keys if k in chunk_keys], - "page": pages, "last_page": i + 100 >= len(issues)}) + state = {"command_id": cmd.get("id"), "agent_version": AGENT_VERSION, "issues": chunk, + "links": [l for l in links if l["source_key"] in chunk_keys or l["target_key"] in chunk_keys], + "neighbor_keys": [k for k in neighbor_keys if k in chunk_keys], + "page": pages, "last_page": i + 100 >= len(issues)} + if pages == 1 and boards is not None: + state["boards"] = boards + self._hub(self.hub.state, state) + if not pages and boards is not None: # выборка пуста, а доски прочитаны (R-66) + self.send_empty_batch(cmd, boards) + pages = 1 result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages} history = self.refresh_history(p, cmd, everything) if history is not None: diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 25e172c..a43c429 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -24,6 +24,9 @@ LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начал SPRINT_FIELD = "customfield_10104" RANK_FIELD = "customfield_10105" POINTS_FIELD = "customfield_10106" +STATUSES = {"10000": "Ожидание разработки", "10001": "Ожидает анализа", "10002": "Анализ", "10003": "Разработка", + "10004": "Готово к тестированию", "10005": "Тестирование", "10006": "Готово", "10007": "Снято"} +COLUMNS = [("Бэклог", ["10000", "10001"]), ("В работе", ["10002", "10003", "10004", "10005"]), ("Готово", ["10006", "10007"])] CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304", RANK_FIELD, POINTS_FIELD) @@ -74,6 +77,9 @@ class FakeJira: 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.board_columns = {} # доска → список как COLUMNS (нет доски — COLUMNS) + self.backlog_codes = {} # доска → код ответа GET /board/{id}/backlog (по умолчанию 200) + self.fail_paths = {} # путь → [коды]: каждый запрос по пути снимает первый и отвечает им self.sprint_field_format = "object" # элемент поля спринтов: словарь или строка greenhopper self.sprint_members = {} # id спринта → ключи задач (иначе — по имени спринта задачи) self.seq, self.link_seq, self.comment_seq = 1000, 100, 10 @@ -286,6 +292,9 @@ class FakeJira: if self.fail_next: code, headers = self.fail_next.pop(0) return self._send(h, code, {"errorMessages": ["имитация ошибки"]}, headers) + if self.fail_paths.get(url.path): + code = self.fail_paths[url.path].pop(0) + return self._send(h, code, {"errorMessages": ["имитация ошибки"]}, {}) try: code, payload = self.route(method, url.path, q, body) except KeyError as e: @@ -472,6 +481,22 @@ class FakeJira: if board not in self.boards: return 404, {"errorMessages": ["Board does not exist"]} return 200, {"id": board, "name": "Доска %d" % board, "type": self.boards[board]} + if p == "/rest/api/2/status": + return 200, [{"id": i, "name": n} for i, n in STATUSES.items()] + m = re.match(r"^/rest/agile/1\.0/board/(\d+)/configuration$", p) + if m: + board = int(m.group(1)) + if board not in self.boards: + return 404, {"errorMessages": ["Board does not exist"]} + cols = self.board_columns.get(board, COLUMNS) + return 200, {"id": board, "columnConfig": {"columns": [ + {"name": n, "statuses": [{"id": i} for i in ids]} for n, ids in cols]}} + m = re.match(r"^/rest/agile/1\.0/board/(\d+)/backlog$", p) + if m: + code = self.backlog_codes.get(int(m.group(1)), 200) + if code != 200: + return code, {"errorMessages": ["имитация ошибки"]} + return 200, {"startAt": 0, "maxResults": 0, "total": 0, "issues": []} m = re.match(r"^/rest/agile/1\.0/board/(\d+)/sprint$", p) if m: board = int(m.group(1)) @@ -479,8 +504,8 @@ class FakeJira: return 404, {"errorMessages": ["Board does not exist"]} 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.board_sprints.get(board, self.sprints) if s["state"] in states] + states = q["state"].split(",") if q.get("state") else None # без state — спринты всех состояний + 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)} m = re.match(r"^/rest/agile/1\.0/sprint/(\d+)/issue$", p) diff --git a/tests/test_refresh.py b/tests/test_refresh.py index bd318eb..7783f35 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -5,6 +5,11 @@ from fake_hub import FakeHub from fake_jira import FakeJira +COLS = [{"name": "Бэклог", "statuses": ["Ожидание разработки", "Ожидает анализа"]}, + {"name": "В работе", "statuses": ["Анализ", "Разработка", "Готово к тестированию", "Тестирование"]}, + {"name": "Готово", "statuses": ["Готово", "Снято"]}] + + class RefreshTest(unittest.TestCase): def setUp(self): self.fake = FakeJira() @@ -430,6 +435,8 @@ class RefreshTest(unittest.TestCase): self.assertNotIn("boards", out["result"]) self.assertIn("/rest/agile/1.0/board/4216/sprint", [r[1] for r in self.fake.requests]) self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2") + self.assertNotIn("boards", self.hub.states[0]) + self.assertFalse(any(r[1].endswith("/configuration") for r in self.fake.requests)) def test_known_boards_no_type_requests(self): self.fake.add("AURORA-1", sprint="Спринт 2") @@ -471,7 +478,11 @@ class RefreshTest(unittest.TestCase): self.fake.boards[5120] = "kanban" out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}]) self.assertEqual(out["status"], "done") - self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0, "boards": {"5120": "kanban"}}) + self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 1, "boards": {"5120": "kanban"}}) + self.assertEqual(self.hub.states, [{"command_id": 5, "agent_version": agent.AGENT_VERSION, "issues": [], "links": [], + "neighbor_keys": [], "page": 1, "last_page": True, + "boards": [{"id": 5120, "type": "kanban", "columns": COLS, + "kanban_backlog": True, "sprints": []}]}]) def test_last_found_sprint_wins_across_boards(self): self.fake.boards[5120] = "scrum" @@ -507,6 +518,88 @@ class RefreshTest(unittest.TestCase): self.assertEqual(out["status"], "done", out) self.assertEqual(out["result"]["boards"], {"5120": "kanban"}) + def test_board_with_closed_sprints(self): # сценарий «Доска с закрытыми спринтами» + sp = lambda i, s, **kw: dict({"id": i, "name": "S%d" % i, "state": s}, **kw) + self.fake.board_sprints[4216] = [ + sp(1, "closed", startDate="2026-09-01T09:00:00.000+03:00", endDate="2026-09-14T18:00:00.000+03:00", + completeDate="2026-09-15T10:00:00.000+03:00", goal="цель 1"), + sp(2, "closed"), sp(3, "active"), sp(4, "future")] + for i in range(1, 121): + self.fake.add("AURORA-%d" % i, sprint="S3") + self.fake.issues["AURORA-1"]["sprint_ids"] = [2, 3] + out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}]) + self.assertEqual(out["status"], "done", out) + b = self.hub.states[0]["boards"] + self.assertEqual([(x["id"], x["type"], x["columns"], x["kanban_backlog"]) for x in b], [(4216, "scrum", COLS, None)]) + self.assertEqual([s["id"] for s in b[0]["sprints"]], [1, 2, 3, 4]) + self.assertEqual(b[0]["sprints"][0], {"id": 1, "name": "S1", "state": "closed", + "start_date": "2026-09-01T09:00:00.000+03:00", "end_date": "2026-09-14T18:00:00.000+03:00", + "complete_date": "2026-09-15T10:00:00.000+03:00", "goal": "цель 1"}) + self.assertNotIn("boards", self.hub.states[1]) + by = {i["key"]: i for i in self.hub.states[0]["issues"]} + self.assertEqual((by["AURORA-1"]["sprint_ids"], by["AURORA-1"]["sprint"]), ([2, 3], "S3")) + sprint_reads = [q for (m, path), q in zip(self.fake.requests, self.fake.queries) if path == "/rest/agile/1.0/board/4216/sprint"] + self.assertEqual(len(sprint_reads), 1) # имена спринтов — из того же списка + self.assertNotIn("state", sprint_reads[0]) + + def test_all_sprints_paged(self): + self.fake.board_sprints[4216] = [{"id": i, "name": "S%d" % i, "state": "closed"} for i in range(1, 121)] + self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]) + reads = [q for (m, path), q in zip(self.fake.requests, self.fake.queries) if path == "/rest/agile/1.0/board/4216/sprint"] + self.assertEqual(len(reads), 3) + self.assertEqual(len(self.hub.states[0]["boards"][0]["sprints"]), 120) + + def test_kanban_backlog_flag(self): + self.fake.boards[5120] = "kanban" + for code, want in ((200, True), (400, False), (500, None)): + self.fake.backlog_codes[5120] = code + self.refresh(keys=[], boards=[{"id": 5120, "type": "kanban"}]) + b = self.hub.states[-1]["boards"][0] + self.assertEqual((b["kanban_backlog"], b["sprints"], b["columns"]), (want, [], COLS)) + self.assertNotIn("/rest/agile/1.0/board/5120/sprint", [r[1] for r in self.fake.requests]) + + def test_board_config_error_columns_null(self): + self.fake.add("AURORA-1") + self.fake.fail_paths["/rest/agile/1.0/board/4216/configuration"] = [403] + out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])) + self.assertEqual(out["status"], "done", out) + b = self.hub.states[0]["boards"][0] + self.assertIsNone(b["columns"]) + self.assertEqual([s["id"] for s in b["sprints"]], [1, 2]) + self.assertTrue(any("4216" in l and "HTTP 403" in l for l in logs), logs) + + def test_board_sprints_error_board_omitted(self): # R-65 + self.fake.add("AURORA-1", sprint="Спринт 2") + self.fake.fail_paths["/rest/agile/1.0/board/4216/sprint"] = [403] # второй запрос (имена спринтов) — успешен + out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])) + self.assertEqual(out["status"], "done", out) + self.assertEqual(self.hub.states[0]["boards"], []) + self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2") + + def test_board_bad_sprint_dropped(self): # R-72 + self.fake.board_sprints[4216] = [{"id": 1, "name": "S1", "state": "CLOSED"}, {"id": 2, "name": "S2", "state": "weird"}, + {"id": 3, "state": "active"}] + out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])) + self.assertEqual([(s["id"], s["state"]) for s in self.hub.states[0]["boards"][0]["sprints"]], [(1, "closed")]) + self.assertEqual(len([l for l in logs if "пропущен" in l]), 2, logs) + + def test_unknown_status_id_skipped_and_status_cached(self): + self.fake.board_columns[4216] = [("Одна", ["10003", "99999"])] + self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]) + self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]) + self.assertEqual(self.hub.states[0]["boards"][0]["columns"], [{"name": "Одна", "statuses": ["Разработка"]}]) + self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/status")), 1) + + def test_empty_jql_with_boards_sends_empty_batch(self): # R-66 + out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}]) + self.assertEqual(out["result"]["pages"], 1) + st = self.hub.states[0] + self.assertEqual((st["issues"], st["links"], st["neighbor_keys"], st["page"], st["last_page"]), ([], [], [], 1, True)) + self.assertEqual(st["boards"][0]["id"], 4216) + + def test_sprint_ids_non_ascii_digits(self): + self.assertEqual(self.ex.sprint_ids_of("AURORA-1", [{"id": "²"}, {"id": "٣"}, {"id": "7"}]), [7]) + class JiraFieldsTest(unittest.TestCase): def setUp(self):