diff --git a/src/agent.py b/src/agent.py index 76c4d4c..be0bc62 100644 --- a/src/agent.py +++ b/src/agent.py @@ -336,6 +336,153 @@ def _parse_at(value) -> float: return dt.timestamp() +# --------------------------------------------------------------------------- +# Исполнитель: команда — желаемый результат; прочитать → сравнить → записать разницу +# --------------------------------------------------------------------------- +def _date(value) -> Optional[str]: + """Даты Jira (duedate — YYYY-MM-DD, кастомные — с временем) сравниваем по дню.""" + return (value or "")[:10] or None + + +class Executor: + def __init__(self, jira: JiraClient, settings: dict, hub: Optional[HubClient] = None): + self.jira, self.s, self.hub = jira, settings, hub + self.sleep = time.sleep + self._symmetric = None + self._sprints = None + + def run(self, cmd: dict) -> dict: + self.jira.reset_counters() + self._sprints = None + handler = getattr(self, "do_" + str(cmd.get("type")), None) + if handler is None: + return self._out("failed", error="неизвестный тип команды %s" % cmd.get("type")) + try: + return handler(cmd.get("params") or {}, cmd) + except HttpError as e: + return self._out("failed", error="HTTP %d %s" % (e.code, e.text[:1500])) + except ConnectionError as e: + return self._out("failed", error=str(e)) + + def _out(self, status, result=None, error=None) -> dict: + return {"status": status, "result": result, "error": error, + "jira_calls": self.jira.calls, "rate_limited": self.jira.rate_limited} + + def gap(self, cmd_type: str) -> None: + """Зазор между записями внутри одной команды — по политике хаба для этого типа.""" + seconds = int((self.s.get("pacing") or {}).get(cmd_type, 0) or 0) + if seconds > 0: + log(" пауза %d с" % seconds) + self.sleep(seconds) + + def fid(self, logical: str) -> str: + return (self.s.get("field_ids") or {}).get(logical, logical) + + def has_field(self, logical: str) -> bool: + return logical in ("summary", "description", "priority", "duedate", "labels") or bool((self.s.get("field_ids") or {}).get(logical)) + + def priority_value(self, name: str) -> dict: + mapped = (self.s.get("priority_map") or {}).get(name, name) + return {self.s.get("priority_value_key") or "name": mapped} + + def issue_type_ref(self, kind: str) -> dict: + it = (self.s.get("issue_types") or {}).get(kind) or {} + return {"id": str(it["id"])} if it.get("id") else {"name": it.get("name")} + + def epic_fields(self, epic_key) -> dict: + ep = self.s.get("epic_link") or {} + if epic_key and ep.get("mode", "field") == "field" and ep.get("field_id"): + return {ep["field_id"]: epic_key} + return {} + + # --- ensure_issue ---------------------------------------------------------- + def do_ensure_issue(self, p: dict, cmd: dict) -> dict: + jql = "project = %s AND reporter = currentUser() AND created >= -7d" % self.s["project_key"] + found = self.jira.get(API + "/search", jql=jql, fields="summary", maxResults=200) + for it in found.get("issues", []): + if (it.get("fields", {}).get("summary") or "") == p["summary"]: + return self._out("done", {"key": it["key"], "existed": True}) + fields = {"project": {"key": self.s["project_key"]}, "issuetype": self.issue_type_ref(p["issue_type"]), + "summary": p["summary"]} + if p.get("description") is not None: + fields[self.fid("description")] = p["description"] + if p.get("priority"): + fields[self.fid("priority")] = self.priority_value(p["priority"]) + if p.get("duedate"): + fields[self.fid("duedate")] = p["duedate"] + for logical in ("planned_start", "planned_end"): + if p.get(logical) and self.has_field(logical): + fields[self.fid(logical)] = p[logical] + if p.get("labels") is not None: + fields[self.fid("labels")] = list(p["labels"]) + if p.get("assignee"): + fields["assignee"] = {"name": p["assignee"]} + fields.update(self.epic_fields(p.get("epic_key"))) + if p["issue_type"] == "epic" and self.s.get("epic_name_field_id"): + fields[self.s["epic_name_field_id"]] = p["summary"] + created = self.jira.post(API + "/issue", {"fields": fields}) + return self._out("done", {"key": created["key"], "created": True}) + + # --- ensure_fields --------------------------------------------------------- + def do_ensure_fields(self, p: dict, cmd: dict) -> dict: + key = p["key"] + cur = self.jira.get(API + "/issue/%s" % key)["fields"] + fields, changed = {}, [] + if "summary" in p and (cur.get("summary") or "") != (p["summary"] or ""): + fields["summary"] = p["summary"] + changed.append("summary") + if "description" in p and (cur.get(self.fid("description")) or "") != (p["description"] or ""): + fields[self.fid("description")] = p["description"] + changed.append("description") + if "priority" in p: + want = self.priority_value(p["priority"]) + vkey = list(want)[0] + if ((cur.get(self.fid("priority")) or {}).get(vkey)) != want[vkey]: + fields[self.fid("priority")] = want + changed.append("priority") + for logical in ("duedate", "planned_start", "planned_end"): + if logical in p and self.has_field(logical) and _date(cur.get(self.fid(logical))) != _date(p[logical]): + fields[self.fid(logical)] = p[logical] + changed.append(logical) + if "epic_key" in p: + ep = self.s.get("epic_link") or {} + if ep.get("field_id") and (cur.get(ep["field_id"]) or None) != (p["epic_key"] or None): + fields[ep["field_id"]] = p["epic_key"] + changed.append("epic_key") + if "assignee" in p and ((cur.get("assignee") or {}).get("name") or None) != (p["assignee"] or None): + fields["assignee"] = {"name": p["assignee"]} if p["assignee"] else None + changed.append("assignee") + if "labels_add" in p or "labels_remove" in p: + labels = list(cur.get(self.fid("labels")) or []) + remove = set(p.get("labels_remove") or []) + new = [l for l in labels if l not in remove] + [l for l in (p.get("labels_add") or []) if l not in labels] + if new != labels: + fields[self.fid("labels")] = new + changed.append("labels") + if not fields: + return self._out("done", {"changed": []}) + self.jira.put(API + "/issue/%s" % key, {"fields": fields}) + return self._out("done", {"changed": changed}) + + # --- ensure_comment -------------------------------------------------------- + def do_ensure_comment(self, p: dict, cmd: dict) -> dict: + key, marker, body = p["key"], p["marker"], p["body"] + if marker not in body[:80]: + body = "*%s.* %s" % (marker, body) + existing = None + for c in self.jira.get(API + "/issue/%s/comment" % key).get("comments", []): + if marker in (c.get("body") or "")[:80]: + existing = c + break + if existing and (existing.get("body") or "") == body: + return self._out("done", {"comment_id": existing["id"], "changed": False}) + if existing: + self.jira.put(API + "/issue/%s/comment/%s" % (key, existing["id"]), {"body": body}) + return self._out("done", {"comment_id": existing["id"], "changed": True}) + c = self.jira.post(API + "/issue/%s/comment" % key, {"body": body}) + return self._out("done", {"comment_id": c.get("id"), "created": True}) + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- diff --git a/tests/test_executor_fields.py b/tests/test_executor_fields.py new file mode 100644 index 0000000..5dd26f5 --- /dev/null +++ b/tests/test_executor_fields.py @@ -0,0 +1,109 @@ +import unittest + +from _common import SETTINGS, agent, jira_cfg, no_sleep +from fake_jira import FakeJira + + +class ExecutorFieldsTest(unittest.TestCase): + def setUp(self): + self.fake = FakeJira() + self.jira = agent.JiraClient(jira_cfg(self.fake)) + self.jira.sleep = no_sleep + self.ex = agent.Executor(self.jira, dict(SETTINGS)) + self.ex.sleep = no_sleep + + def tearDown(self): + self.fake.stop() + + def run_cmd(self, ctype, **params): + return self.ex.run({"id": 1, "type": ctype, "params": params}) + + def test_ensure_issue_creates_then_finds(self): + out = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="task", summary="Новая задача", description="h3. Контекст", + priority="Высокий", duedate="2026-10-23", planned_start="2026-10-20", labels=["AWPCA", "kind-dev"], + epic_key="AURORA-1350", assignee="ivanov") + self.assertEqual(out["status"], "done") + key = out["result"]["key"] + self.assertTrue(out["result"]["created"]) + f = self.fake.issues[key] + self.assertEqual(f["priority"], "Высокий") + self.assertEqual(f["customfield_10801"], "2026-10-20") + self.assertEqual(f["customfield_15302"], "AURORA-1350") + self.assertEqual(f["labels"], ["AWPCA", "kind-dev"]) + self.assertEqual(out["jira_calls"], 2) + again = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="task", summary="Новая задача") + self.assertEqual(again["status"], "done") + self.assertEqual(again["result"], {"key": key, "existed": True}) + self.assertEqual(len(self.fake.issues), 1) + self.assertEqual(again["jira_calls"], 1) + + def test_ensure_issue_epic_sets_epic_name(self): + out = self.run_cmd("ensure_issue", temp_key="new:2", issue_type="epic", summary="Приёмка") + f = self.fake.issues[out["result"]["key"]] + self.assertEqual(f["issuetype"], "Фича") + self.assertEqual(f["customfield_15304"], "Приёмка") + + def test_ensure_issue_priority_mapped(self): + out = self.run_cmd("ensure_issue", temp_key="new:3", issue_type="task", summary="x", priority="Наивысший") + self.assertEqual(self.fake.issues[out["result"]["key"]]["priority"], "Критичный") + + def test_ensure_fields_writes_only_diff(self): + self.fake.add("AURORA-1", duedate="2026-10-20", labels=["AWPCA", "fact-open"], priority="Средний") + out = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-23", priority="Средний", + labels_add=["kind-dev", "AWPCA"], labels_remove=["fact-open"], assignee="ivanov", + planned_end="2026-10-23", epic_key="AURORA-1350") + self.assertEqual(out["status"], "done") + self.assertEqual(out["result"]["changed"], ["duedate", "planned_end", "epic_key", "assignee", "labels"]) + f = self.fake.issues["AURORA-1"] + self.assertEqual(f["labels"], ["AWPCA", "kind-dev"]) + self.assertEqual(f["assignee"], "ivanov") + self.assertEqual(f["customfield_10802"], "2026-10-23") + self.assertEqual(len(self.fake.writes()), 1) + again = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-23", labels_add=["kind-dev"], assignee="ivanov") + self.assertEqual(again["result"], {"changed": []}) + self.assertEqual(len(self.fake.writes()), 1) + + def test_fields_description_exact_compare(self): + text = "h3. Контекст\n* пункт *жирный*\n\nh3. Что сделать" + self.fake.add("AURORA-1", description=text) + out = self.run_cmd("ensure_fields", key="AURORA-1", description=text) + self.assertEqual(out["result"]["changed"], []) + out = self.run_cmd("ensure_fields", key="AURORA-1", description=text + "\n") + self.assertEqual(out["result"]["changed"], ["description"]) + + def test_fields_date_with_time_from_jira(self): + self.fake.add("AURORA-1", customfield_10802="2026-10-23T00:00:00.000+0300") + out = self.run_cmd("ensure_fields", key="AURORA-1", planned_end="2026-10-23") + self.assertEqual(out["result"]["changed"], []) + + def test_fields_clear_assignee_and_epic(self): + self.fake.add("AURORA-1", assignee="ivanov", customfield_15302="AURORA-1350") + out = self.run_cmd("ensure_fields", key="AURORA-1", assignee=None, epic_key=None) + self.assertEqual(out["result"]["changed"], ["epic_key", "assignee"]) + self.assertIsNone(self.fake.issues["AURORA-1"]["assignee"]) + self.assertIsNone(self.fake.issues["AURORA-1"]["customfield_15302"]) + + def test_fields_missing_issue_is_failed(self): + out = self.run_cmd("ensure_fields", key="AURORA-404", duedate="2026-10-23") + self.assertEqual(out["status"], "failed") + self.assertIn("HTTP 404", out["error"]) + + def test_ensure_comment_upsert(self): + self.fake.add("AURORA-1") + out = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: список") + self.assertEqual(out["status"], "done") + self.assertTrue(out["result"]["created"]) + self.assertEqual(self.fake.comments["AURORA-1"][0]["body"], "*Актуализация 10.10.2026.* Сделано: список") + same = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: список") + self.assertEqual(same["result"]["changed"], False) + upd = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: всё") + self.assertEqual(upd["result"]["changed"], True) + self.assertEqual(len(self.fake.comments["AURORA-1"]), 1) + self.assertEqual(self.fake.comments["AURORA-1"][0]["body"], "*Актуализация 10.10.2026.* Сделано: всё") + + def test_unknown_type(self): + self.assertEqual(self.run_cmd("ensure_magic", key="x")["status"], "failed") + + +if __name__ == "__main__": + unittest.main()