fix(agent): refresh — бисекция при 400 на key in, 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:54:35 +04:00
parent 1b3364be13
commit c64d36b748
4 changed files with 116 additions and 12 deletions

View File

@ -614,7 +614,7 @@ class Executor:
def state_fields(self) -> str:
extra = [self.fid(x) for x in ("planned_start", "planned_end") if self.has_field(x)]
ep = (self.s.get("epic_link") or {})
ep = (self.s.get("epic_link") or {}) # режим связи с эпиком — только field (mode=link не поддержан)
if ep.get("mode", "field") == "field" and ep.get("field_id"):
extra.append(ep["field_id"])
return ",".join(self.STATE_FIELDS + extra)
@ -629,6 +629,22 @@ 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]:
"""key in (...): Jira Server отвечает 400 на несуществующий/недоступный ключ. Чанк делим пополам до
одиночного ключа; одиночный с 400 — «не найден» (в missing), не ошибка. Прочие HttpError пробрасываются."""
if not keys:
return []
try:
return self.search_all("key in (%s)" % ",".join(keys))
except HttpError as e:
if e.code != 400:
raise
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)
def to_state(self, raw: dict) -> dict:
f = raw.get("fields") or {}
@ -656,19 +672,28 @@ class Executor:
return out
def sprint_names(self, keys: List[str]) -> Dict[str, str]:
# Источник спринта — Agile по активным и будущим спринтам доски (R-6): id поля спринта в настройках хаба нет,
# поэтому у задач закрытых спринтов sprint = None (принятое ограничение).
names = {}
if not keys or not self.s.get("board_id"):
return names
for name, sp in self.sprints().items():
for i in range(0, len(keys), 100):
chunk = keys[i:i + 100]
page = self.jira.get(AGILE + "/sprint/%s/issue" % sp["id"], jql="key in (%s)" % ",".join(chunk),
fields="key", maxResults=200)
for it in page.get("issues") or []:
names[it["key"]] = name
start = 0
while True:
page = self.jira.get(AGILE + "/sprint/%s/issue" % sp["id"], jql="key in (%s)" % ",".join(chunk),
fields="key", startAt=start, maxResults=50)
got = page.get("issues") or []
for it in got:
names[it["key"]] = name
start += len(got)
if not got or start >= int(page.get("total") or 0):
break
return names
def do_refresh(self, p: dict, cmd: dict) -> dict:
missing: List[str] = []
if p.get("jql"):
raws = self.search_all(p["jql"])
else:
@ -677,7 +702,7 @@ class Executor:
return self._out("done", {"issues": 0, "neighbors": 0, "pages": 0})
raws = []
for i in range(0, len(keys), 100):
raws += self.search_all("key in (%s)" % ",".join(keys[i:i + 100]))
raws += self.search_tolerant(keys[i:i + 100], missing)
fetched = {r["key"] for r in raws}
known = set(self.hub.known_keys()) if self.hub else set()
linked = set()
@ -685,10 +710,10 @@ class Executor:
for l in self.links_from(r):
linked.add(l["source_key"])
linked.add(l["target_key"])
missing = sorted(k for k in linked if k not in fetched and k not in known)
absent = sorted(k for k in linked if k not in fetched and k not in known)
neighbors = []
for i in range(0, len(missing), 100):
neighbors += self.search_all("key in (%s)" % ",".join(missing[i:i + 100]))
for i in range(0, len(absent), 100):
neighbors += self.search_tolerant(absent[i:i + 100], missing)
everything = raws + neighbors
names = self.sprint_names([r["key"] for r in everything])
issues, links, seen = [], [], set()
@ -711,7 +736,10 @@ class Executor:
"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)})
return self._out("done", {"issues": len(raws), "neighbors": len(neighbors), "pages": pages})
result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}
if missing:
result["missing"] = sorted(missing)[:50]
return self._out("done", result)
# ---------------------------------------------------------------------------

View File

@ -100,6 +100,8 @@ class FakeHub:
return self.reject_results[cid], {"error": "rejected"}
if cid not in self.commands:
return 404, {"error": "not_found"}
if self.commands[cid]["status"] in ("done", "failed", "cancelled"):
return 200, self.commands[cid] # терминальный статус не перезаписывается (как живой хаб)
self.results[cid] = body
self.commands[cid]["status"] = body.get("status")
if self.in_flight == cid:

View File

@ -141,6 +141,9 @@ class FakeJira:
if p == "/rest/api/2/myself":
return 200, {"name": "me", "displayName": "Me"}
if p == "/rest/api/2/search":
m = re.search(r"key in \(([^)]*)\)", q.get("jql") or "")
if m and any(k.strip() not in self.issues for k in m.group(1).split(",")): # как Jira Server
return 400, {"errorMessages": ["Задача не существует или у вас нет прав на её просмотр."]}
keys = self.search_keys(q.get("jql"))
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
page = keys[start:start + maxr]
@ -220,7 +223,9 @@ class FakeJira:
sp = next(s for s in self.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"]]
return 200, {"issues": [{"key": k} for k in keys]}
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]]}
for k in body["issues"]:
self.issues[k]["sprint"] = sp["name"]
return 204, None

View File

@ -60,7 +60,8 @@ class RefreshTest(unittest.TestCase):
self.assertIsNone(sprints["AURORA-3"])
links = {(l["source_key"], l["target_key"]) for l in state["links"]}
self.assertEqual(links, {("AURORA-1", "OTHER-9"), ("AURORA-7", "AURORA-2")})
self.assertEqual([r[1] for r in self.fake.requests if r[1] == "/rest/api/2/search"].count("/rest/api/2/search"), 2)
searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"]
self.assertEqual(len(searches), 2) # базовая выборка + дочитывание соседей
def test_refresh_by_keys_pages(self):
for i in range(1, 121):
@ -80,6 +81,74 @@ class RefreshTest(unittest.TestCase):
self.assertEqual(self.fake.requests, [])
self.assertEqual(self.hub.states, [])
def test_refresh_empty_jql_result(self):
out = self.ex.run({"id": 8, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0})
self.assertEqual(self.hub.states, [])
def test_keys_with_deleted_key(self):
for i in (1, 2, 3, 4):
self.fake.add("AURORA-%d" % i)
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(sorted(i["key"] for i in self.hub.states[0]["issues"]), ["AURORA-1", "AURORA-2", "AURORA-3"])
def test_deleted_neighbor(self):
self.fake.add("AURORA-1")
self.fake.add("AURORA-2", in_base=False)
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "GONE-1"})
self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "AURORA-2"})
out = self.ex.run({"id": 10, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"issues": 1, "neighbors": 1, "pages": 1, "missing": ["GONE-1"]})
self.assertEqual(self.hub.states[0]["neighbor_keys"], ["AURORA-2"])
def test_non_400_error_fails(self):
self.fake.add("AURORA-1")
def boom(jql):
raise agent.HttpError(500, "oops")
self.ex.search_all = boom
out = self.ex.run({"id": 11, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
self.assertEqual(out["status"], "failed")
self.assertIn("500", out["error"])
def test_link_visible_from_both_ends_deduped(self):
self.fake.add("AURORA-1")
self.fake.add("AURORA-2")
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "AURORA-2"})
self.ex.run({"id": 12, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}})
self.assertEqual(len(self.hub.states[0]["links"]), 1)
def test_many_neighbors_several_requests(self):
self.fake.add("AURORA-1")
for i in range(1, 151):
self.fake.add("NB-%d" % i, in_base=False)
self.fake.links.append({"id": i, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "NB-%d" % i})
out = self.ex.run({"id": 13, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
self.assertEqual(out["result"], {"issues": 1, "neighbors": 150, "pages": 2})
searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"]
self.assertEqual(len(searches), 1 + 2) # база + два чанка соседей
def test_sprint_names_paginated(self):
for i in range(1, 121):
self.fake.add("AURORA-%d" % i, sprint="Спринт 2")
names = self.ex.sprint_names(["AURORA-%d" % i for i in range(1, 101)])
self.assertEqual(len(names), 100)
pages = [r for r in self.fake.requests if r[1] == "/rest/agile/1.0/sprint/2/issue"]
self.assertEqual(len(pages), 2)
def test_fake_hub_terminal_result_not_overwritten(self):
cid = self.hub.enqueue("refresh", {"keys": []})
hub = agent.HubClient(jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token))
hub.result(cid, {"status": "done", "result": {"a": 1}})
hub.result(cid, {"status": "failed", "error": "x"})
self.assertEqual(self.hub.commands[cid]["status"], "done")
self.assertEqual(self.hub.results[cid]["status"], "done")
if __name__ == "__main__":
unittest.main()