fix(agent): refresh — все ключи 400 это ошибка, число вызовов бисекции, missing без дублей

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
Andrey Epifancev 2026-10-11 23:57:31 +04:00
parent 1133cee873
commit 8ebf5250b7
2 changed files with 37 additions and 8 deletions

View File

@ -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)

View File

@ -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)