diff --git a/src/agent.py b/src/agent.py index 42ff383..d9a61e2 100644 --- a/src/agent.py +++ b/src/agent.py @@ -629,9 +629,12 @@ class Executor: if not got or start >= int(page.get("total") or 0): return issues - def search_tolerant(self, keys: List[str], missing: List[str]) -> List[dict]: + def search_tolerant(self, keys: List[str], missing: List[str], errors: List[HttpError]) -> List[dict]: """key in (...): Jira Server отвечает 400 на несуществующий/недоступный ключ. Чанк делим пополам до - одиночного ключа; одиночный с 400 — «не найден» (в missing), не ошибка. Прочие HttpError пробрасываются.""" + одиночного ключа; одиночный с 400 — «не найден» (в missing), не ошибка. Прочие HttpError пробрасываются. + Первый 400 копится в errors: вызывающий пробрасывает его, если не найден НИ ОДИН ключ (неверная конфигурация, + а не удалённые задачи). + Стоимость: до ~2·m·log2(n) вызовов на m отсутствующих ключей из n — принятое ограничение, рулинг R-7.""" if not keys: return [] try: @@ -639,11 +642,13 @@ class Executor: except HttpError as e: if e.code != 400: raise + if not errors: + errors.append(e) if len(keys) == 1: missing.append(keys[0]) return [] mid = len(keys) // 2 - return self.search_tolerant(keys[:mid], missing) + self.search_tolerant(keys[mid:], missing) + return self.search_tolerant(keys[:mid], missing, errors) + self.search_tolerant(keys[mid:], missing, errors) def to_state(self, raw: dict) -> dict: f = raw.get("fields") or {} @@ -694,15 +699,18 @@ class Executor: def do_refresh(self, p: dict, cmd: dict) -> dict: missing: List[str] = [] + errors: List[HttpError] = [] if p.get("jql"): raws = self.search_all(p["jql"]) else: - keys = [k for k in (p.get("keys") or []) if k] + keys = list(dict.fromkeys(k for k in (p.get("keys") or []) if k)) if not keys: return self._out("done", {"issues": 0, "neighbors": 0, "pages": 0}) raws = [] for i in range(0, len(keys), 100): - raws += self.search_tolerant(keys[i:i + 100], missing) + raws += self.search_tolerant(keys[i:i + 100], missing, errors) + if not raws and errors: # ни один ключ не найден: это конфигурация/запрос, а не удалённые задачи + raise errors[0] fetched = {r["key"] for r in raws} known = set(self.hub.known_keys()) if self.hub else set() linked = set() @@ -713,7 +721,7 @@ class Executor: absent = sorted(k for k in linked if k not in fetched and k not in known) neighbors = [] for i in range(0, len(absent), 100): - neighbors += self.search_tolerant(absent[i:i + 100], missing) + neighbors += self.search_tolerant(absent[i:i + 100], missing, errors) everything = raws + neighbors names = self.sprint_names([r["key"] for r in everything]) issues, links, seen = [], [], set() @@ -738,7 +746,7 @@ class Executor: "page": pages, "last_page": i + 100 >= len(issues)}) result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages} if missing: - result["missing"] = sorted(missing)[:50] + result["missing"] = list(dict.fromkeys(missing))[:50] return self._out("done", result) diff --git a/tests/test_refresh.py b/tests/test_refresh.py index 32729e4..2fe2075 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -93,9 +93,30 @@ class RefreshTest(unittest.TestCase): out = self.ex.run({"id": 9, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2", "AURORA-99", "AURORA-3", "AURORA-98"]}}) self.assertEqual(out["status"], "done") - self.assertEqual(out["result"], {"issues": 3, "neighbors": 0, "pages": 1, "missing": ["AURORA-98", "AURORA-99"]}) + self.assertEqual(out["result"], {"issues": 3, "neighbors": 0, "pages": 1, "missing": ["AURORA-99", "AURORA-98"]}) self.assertEqual(sorted(i["key"] for i in self.hub.states[0]["issues"]), ["AURORA-1", "AURORA-2", "AURORA-3"]) + def test_all_keys_400_fails(self): + out = self.ex.run({"id": 14, "type": "refresh", "params": {"keys": ["X-1", "X-2", "X-3"]}}) + self.assertEqual(out["status"], "failed") + self.assertIn("HTTP 400", out["error"]) + self.assertEqual(self.hub.states, []) + + def test_bisect_cost_one_missing_of_100(self): + for i in range(1, 100): + self.fake.add("AURORA-%d" % i) + keys = ["AURORA-%d" % i for i in range(1, 101)] # AURORA-100 отсутствует + out = self.ex.run({"id": 15, "type": "refresh", "params": {"keys": keys}}) + self.assertEqual(out["result"]["missing"], ["AURORA-100"]) + searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"] + self.assertLessEqual(len(searches), 2 * 7 + 1) + + def test_missing_deduped_and_truncated(self): + self.fake.add("AURORA-1") + keys = ["AURORA-1"] + ["GONE-%d" % i for i in range(1, 61)] + ["GONE-1"] + out = self.ex.run({"id": 16, "type": "refresh", "params": {"keys": keys}}) + self.assertEqual(out["result"]["missing"], ["GONE-%d" % i for i in range(1, 51)]) + def test_deleted_neighbor(self): self.fake.add("AURORA-1") self.fake.add("AURORA-2", in_base=False)