diff --git a/src/agent.py b/src/agent.py index 8b19dea..364baee 100644 --- a/src/agent.py +++ b/src/agent.py @@ -608,6 +608,112 @@ class Executor: self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]}) return self._out("done", {"sprint": want, "from": cur}) + # --- refresh --------------------------------------------------------------- + STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels", + "issuetype", "created", "updated", "issuelinks"] + + 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 {}) + if ep.get("mode", "field") == "field" and ep.get("field_id"): + extra.append(ep["field_id"]) + return ",".join(self.STATE_FIELDS + extra) + + def search_all(self, jql: str) -> List[dict]: + issues, start = [], 0 + while True: + page = self.jira.get(API + "/search", jql=jql, fields=self.state_fields(), startAt=start, maxResults=100) + got = page.get("issues") or [] + issues += got + start += len(got) + if not got or start >= int(page.get("total") or 0): + return issues + + def to_state(self, raw: dict) -> dict: + f = raw.get("fields") or {} + + def name(obj): + return (obj or {}).get("name") + + ep = self.s.get("epic_link") or {} + epic = f.get(ep["field_id"]) if ep.get("mode", "field") == "field" and ep.get("field_id") else None + return {"key": raw["key"], "id": raw.get("id"), "issue_type": name(f.get("issuetype")), "summary": f.get("summary"), + "description": f.get("description"), "status": name(f.get("status")), "priority": name(f.get("priority")), + "assignee": name(f.get("assignee")), "reporter": name(f.get("reporter")), "duedate": _date(f.get("duedate")), + "planned_start": _date(f.get(self.fid("planned_start"))) if self.has_field("planned_start") else None, + "planned_end": _date(f.get(self.fid("planned_end"))) if self.has_field("planned_end") else None, + "epic_key": epic, "labels": list(f.get("labels") or []), "sprint": None, + "created": f.get("created"), "jira_updated": f.get("updated")} + + def links_from(self, raw: dict) -> List[dict]: + key, out = raw["key"], [] + for l in (raw.get("fields") or {}).get("issuelinks") or []: + t = (l.get("type") or {}).get("name") + if l.get("outwardIssue"): + out.append({"source_key": key, "target_key": l["outwardIssue"]["key"], "link_type": t}) + if l.get("inwardIssue"): + out.append({"source_key": l["inwardIssue"]["key"], "target_key": key, "link_type": t}) + return out + + def sprint_names(self, keys: List[str]) -> Dict[str, str]: + 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 + return names + + def do_refresh(self, p: dict, cmd: dict) -> dict: + if p.get("jql"): + raws = self.search_all(p["jql"]) + else: + keys = [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_all("key in (%s)" % ",".join(keys[i:i + 100])) + fetched = {r["key"] for r in raws} + known = set(self.hub.known_keys()) if self.hub else set() + linked = set() + for r in raws: + 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) + neighbors = [] + for i in range(0, len(missing), 100): + neighbors += self.search_all("key in (%s)" % ",".join(missing[i:i + 100])) + everything = raws + neighbors + names = self.sprint_names([r["key"] for r in everything]) + issues, links, seen = [], [], set() + for r in everything: + st = self.to_state(r) + st["sprint"] = names.get(r["key"]) + issues.append(st) + for l in self.links_from(r): + sig = (l["source_key"], l["target_key"], l["link_type"]) + if sig not in seen: + seen.add(sig) + links.append(l) + neighbor_keys = [r["key"] for r in neighbors] + pages = 0 + for i in range(0, len(issues), 100): + chunk = issues[i:i + 100] + chunk_keys = {x["key"] for x in chunk} + pages += 1 + self.hub.state({"command_id": cmd.get("id"), "issues": chunk, + "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}) + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- diff --git a/tests/fake_hub.py b/tests/fake_hub.py new file mode 100644 index 0000000..5e0ec1a --- /dev/null +++ b/tests/fake_hub.py @@ -0,0 +1,108 @@ +"""Фейковый хаб для тестов агента: маршруты /agent/* в памяти.""" +import json +import re +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class FakeHub: + token = "agent-token" + + def __init__(self, settings): + self.settings = dict(settings) + self.queue = [] + self.commands = {} + self.results = {} + self.states = [] + self.known = [] + self.claims = 0 + self.fail_results = 0 + self.reject_results = {} + self.in_flight = None + self.lock = threading.Lock() + hub = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + hub.handle(self, "GET") + + def do_POST(self): + hub.handle(self, "POST") + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + @property + def base_url(self): + return "http://127.0.0.1:%d" % self.server.server_port + + def stop(self): + self.server.shutdown() + self.server.server_close() + + def enqueue(self, ctype, params, pause=0): + cid = len(self.commands) + 1 + self.commands[cid] = {"id": cid, "type": ctype, "params": params, "pause_seconds": pause, "status": "queued"} + self.queue.append(cid) + return cid + + def handle(self, h, method): + length = int(h.headers.get("Content-Length") or 0) + body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {} + if h.headers.get("Authorization") != "Bearer " + self.token: + return self._send(h, 401, {"error": "unauthorized"}) + with self.lock: + code, payload = self.route(method, h.path.split("?")[0], body) + self._send(h, code, payload) + + def _send(self, h, code, payload): + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + h.send_response(code) + h.send_header("Content-Type", "application/json") + h.send_header("Content-Length", str(len(data))) + h.end_headers() + h.wfile.write(data) + + def route(self, method, path, body): + if path == "/agent/claim": + self.claims += 1 + if self.in_flight and self.commands[self.in_flight]["status"] == "claimed": + return 200, {"command": None, "settings_version": self.settings["settings_version"]} + if not self.queue: + return 200, {"command": None, "settings_version": self.settings["settings_version"]} + cid = self.queue.pop(0) + self.commands[cid]["status"] = "claimed" + self.in_flight = cid + c = self.commands[cid] + return 200, {"command": {"id": cid, "type": c["type"], "params": c["params"], "pause_seconds": c["pause_seconds"]}, + "settings_version": self.settings["settings_version"]} + if path == "/agent/settings": + return 200, self.settings + if path == "/agent/known-keys": + return 200, {"keys": list(self.known)} + if path == "/agent/state": + self.states.append(body) + return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])} + m = re.match(r"^/agent/commands/(\d+)$", path) + if m: + c = self.commands.get(int(m.group(1))) + return (200, c) if c else (404, {"error": "not_found"}) + m = re.match(r"^/agent/commands/(\d+)/result$", path) + if m: + cid = int(m.group(1)) + if self.fail_results > 0: + self.fail_results -= 1 + return 503, {"error": "busy"} + if cid in self.reject_results: + return self.reject_results[cid], {"error": "rejected"} + if cid not in self.commands: + return 404, {"error": "not_found"} + self.results[cid] = body + self.commands[cid]["status"] = body.get("status") + if self.in_flight == cid: + self.in_flight = None + return 200, self.commands[cid] + return 404, {"error": "not_found", "detail": path} diff --git a/tests/test_refresh.py b/tests/test_refresh.py new file mode 100644 index 0000000..cf879f5 --- /dev/null +++ b/tests/test_refresh.py @@ -0,0 +1,85 @@ +import unittest + +from _common import SETTINGS, agent, jira_cfg, no_sleep +from fake_hub import FakeHub +from fake_jira import FakeJira + + +class RefreshTest(unittest.TestCase): + def setUp(self): + self.fake = FakeJira() + self.hub = FakeHub(SETTINGS) + cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token) + self.jira = agent.JiraClient(cfg) + self.jira.sleep = no_sleep + self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg)) + self.ex.sleep = no_sleep + + def tearDown(self): + self.fake.stop() + self.hub.stop() + + def test_to_state_and_links(self): + self.fake.add("AURORA-1", assignee="ivanov", customfield_10801="2026-10-20T00:00:00.000+0300", + customfield_15302="AURORA-1350", labels=["AWPCA", "kind-dev"], sprint="Спринт 2") + self.fake.add("AURORA-2") + self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-1", "target": "AURORA-2"}) + raw = self.fake.issue_json("AURORA-1") + st = self.ex.to_state(raw) + self.assertEqual(st["key"], "AURORA-1") + self.assertEqual(st["assignee"], "ivanov") + self.assertEqual(st["planned_start"], "2026-10-20") + self.assertEqual(st["epic_key"], "AURORA-1350") + self.assertEqual(st["labels"], ["AWPCA", "kind-dev"]) + self.assertEqual(st["issue_type"], "Задача") + self.assertEqual(st["jira_updated"], "2026-10-01T10:00:00.000+0300") + self.assertIsNone(st["sprint"]) # спринт заполняет do_refresh + self.assertEqual(self.ex.links_from(raw), [{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "ASUPR_Блокер_Начала"}]) + self.assertEqual(self.ex.links_from(self.fake.issue_json("AURORA-2")), + [{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "ASUPR_Блокер_Начала"}]) + self.assertIn("customfield_15302", self.ex.state_fields()) + + def test_full_refresh_with_neighbors_and_sprints(self): + for i in range(1, 4): + self.fake.add("AURORA-%d" % i, sprint="Спринт 2" if i < 3 else None) + self.fake.add("OTHER-9", in_base=False) + self.fake.add("AURORA-7", in_base=False) + self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "OTHER-9"}) + self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-7", "target": "AURORA-2"}) + self.hub.known = ["AURORA-7"] + out = self.ex.run({"id": 5, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}}) + self.assertEqual(out["status"], "done") + self.assertEqual(out["result"], {"issues": 3, "neighbors": 1, "pages": 1}) + state = self.hub.states[0] + self.assertEqual(state["command_id"], 5) + self.assertEqual(sorted(i["key"] for i in state["issues"]), ["AURORA-1", "AURORA-2", "AURORA-3", "OTHER-9"]) + self.assertEqual(state["neighbor_keys"], ["OTHER-9"]) + self.assertTrue(state["last_page"]) + sprints = {i["key"]: i["sprint"] for i in state["issues"]} + self.assertEqual(sprints["AURORA-1"], "Спринт 2") + 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) + + def test_refresh_by_keys_pages(self): + for i in range(1, 121): + self.fake.add("AURORA-%d" % i) + keys = ["AURORA-%d" % i for i in range(1, 121)] + out = self.ex.run({"id": 6, "type": "refresh", "params": {"keys": keys}}) + self.assertEqual(out["result"], {"issues": 120, "neighbors": 0, "pages": 2}) + self.assertEqual(len(self.hub.states), 2) + self.assertEqual(self.hub.states[0]["page"], 1) + self.assertFalse(self.hub.states[0]["last_page"]) + self.assertTrue(self.hub.states[1]["last_page"]) + self.assertEqual(len(self.hub.states[0]["issues"]) + len(self.hub.states[1]["issues"]), 120) + + def test_refresh_empty_keys(self): + out = self.ex.run({"id": 7, "type": "refresh", "params": {"keys": []}}) + self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0}) + self.assertEqual(self.fake.requests, []) + self.assertEqual(self.hub.states, []) + + +if __name__ == "__main__": + unittest.main()