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:
parent
1133cee873
commit
8ebf5250b7
22
src/agent.py
22
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)
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user