diff --git a/src/agent.py b/src/agent.py index 364baee..42ff383 100644 --- a/src/agent.py +++ b/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) # --------------------------------------------------------------------------- diff --git a/tests/fake_hub.py b/tests/fake_hub.py index 5e0ec1a..abad0af 100644 --- a/tests/fake_hub.py +++ b/tests/fake_hub.py @@ -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: diff --git a/tests/fake_jira.py b/tests/fake_jira.py index db43736..b4efb55 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -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 diff --git a/tests/test_refresh.py b/tests/test_refresh.py index cf879f5..32729e4 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -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()