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:
parent
1b3364be13
commit
c64d36b748
48
src/agent.py
48
src/agent.py
@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user