feat(agent): исполнитель — ensure_issue, ensure_fields, ensure_comment

Поиск дубля в ensure_issue до создания (автор = currentUser(), точная тема).
ensure_fields пишет только отличающиеся поля, описание сравнивается точно,
даты — по первым 10 символам.

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:39:04 +04:00
parent 5de91d6452
commit 07bee853b4
2 changed files with 256 additions and 0 deletions

View File

@ -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
# ---------------------------------------------------------------------------

View File

@ -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()