From 21374bef081239fb081ffe4769364901fd9ffd31 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:30:55 +0400 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B3=D0=B5=D0=BD=D1=82:=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=20=D0=BF=D0=BE=D0=BB=D0=B5=D0=B9=20=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B3=D0=B0,=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=20=D0=B8=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=81=20=D0=BA=D1=8D=D1=88=D0=B5=D0=BC=20=D0=B8=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=D0=BC=20field?= =?UTF-8?q?=5Fids=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 56 ++++++++++++++++++++++++++++++++++++++++--- tests/fake_jira.py | 11 ++++++++- tests/test_refresh.py | 54 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/src/agent.py b/src/agent.py index 3007ff5..7c04c37 100644 --- a/src/agent.py +++ b/src/agent.py @@ -669,16 +669,26 @@ def _fmt_size(n: Optional[int]) -> str: return ("%.1f МБ" % (n / 1024 / 1024)).replace(".", ",") +RANK_SCHEMA = "com.pyxis.greenhopper.jira:gh-lexo-rank" # схема поля «Rank» (ранг Lexorank) +SPRINT_SCHEMA = "com.pyxis.greenhopper.jira:gh-sprint" # схема поля «Sprint» + + 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 = {} # str(доска) → имя → спринт; на одну команду + self._field_list = None # GET /field: список полей на одну команду + self._jira_fields = None # ранг, спринт, оценка → id поля; см. jira_fields + self._jira_fields_ok = False # поля прочитаны без ошибки: кэш живёт, пока жив Executor def run(self, cmd: dict) -> dict: self.jira.reset_counters() self._sprints = {} + self._field_list = None + if not self._jira_fields_ok: # неудачный поиск полей — повтор в следующей команде (R-67) + self._jira_fields = None handler = getattr(self, "do_" + str(cmd.get("type")), None) if handler is None: return self._out("failed", error="неизвестный тип команды %s" % cmd.get("type")) @@ -724,6 +734,47 @@ class Executor: 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 field_list(self) -> List[dict]: + """GET /rest/api/2/field — один раз на команду; только элементы-словари. Ошибки Jira и связи — наружу.""" + if self._field_list is None: + self._field_list = [f for f in self.jira.get(API + "/field") or [] if isinstance(f, dict)] + return self._field_list + + def jira_fields(self) -> Dict[str, Optional[str]]: + """Поля ранга, спринта и оценки: {"rank", "sprint", "story_points"} → id или None. Настроенный в field_ids id + берётся как есть; остальные ищутся в field_list: ранг и спринт по схеме, оценка по имени «story points» (регистр + не важен), при нескольких — наименьший (len(id), id). Ошибка HTTP — лог, None, поиск повторится в следующей + команде; связь — наружу.""" + if self._jira_fields is not None: + return self._jira_fields + ids = self.s.get("field_ids") or {} + over = {k: ids.get(k) or None for k in ("rank", "sprint", "story_points")} + if all(over.values()): + self._jira_fields, self._jira_fields_ok = dict(over), True + return self._jira_fields + try: + fields = self.field_list() + except HttpError as e: + log("поля Jira не прочитаны (HTTP %d) — ранг, спринт и оценка неизвестны" % e.code) + self._jira_fields, self._jira_fields_ok = over, False + return self._jira_fields + + def find(match) -> Optional[str]: + cands = sorted((f for f in fields if match(f)), key=lambda f: (len(str(f.get("id"))), str(f.get("id")))) + return cands[0].get("id") if cands else None + + def by_schema(schema: str): + return lambda f: isinstance(f.get("schema"), dict) and f["schema"].get("custom") == schema + + def by_name(name: str): + return lambda f: str(f.get("name") or "").strip().casefold() == name + + found = {"rank": find(by_schema(RANK_SCHEMA)), "sprint": find(by_schema(SPRINT_SCHEMA)), + "story_points": find(by_name("story points"))} + self._jira_fields = {k: over[k] or found[k] for k in found} + self._jira_fields_ok = True + return self._jira_fields + 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} @@ -1392,9 +1443,8 @@ class Executor: names: Dict[str, Any] = {} def field_id_of(name: str) -> Optional[str]: - if "map" not in names: # один GET /field за refresh, только по нужде - names["map"] = {f.get("name"): f.get("id") for f in self.jira.get(API + "/field") or [] - if isinstance(f, dict)} + if "map" not in names: # по списку полей команды — GET /field один раз (field_list) + names["map"] = {f.get("name"): f.get("id") for f in self.field_list()} return names["map"].get(name) items: List[dict] = [] diff --git a/tests/fake_jira.py b/tests/fake_jira.py index bbbe930..9a176e6 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -21,6 +21,9 @@ WORKFLOW = { # статус → [(имя перехода, целевой ст } LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начало заблокировано", "outward": "Блокирует начало"}, {"name": "ASUPR_Связанные", "inward": "Связанные", "outward": "Связанные"}] +SPRINT_FIELD = "customfield_10104" +RANK_FIELD = "customfield_10105" +POINTS_FIELD = "customfield_10106" CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304") @@ -100,7 +103,13 @@ class FakeJira: self.issue_changelog_cap = None # то же только для GET /issue/{key}?expand=changelog (None — как changelog_cap) self.jql_results = {} # JQL → ключи (проверяется в search_keys первым) self.fields = [{"id": "customfield_10201", "name": "Плановое окончание", "custom": True}, - {"id": "customfield_10104", "name": "Sprint", "custom": True}, + {"id": SPRINT_FIELD, "name": "Sprint", "custom": True, + "schema": {"type": "array", "items": "string", + "custom": "com.pyxis.greenhopper.jira:gh-sprint"}}, + {"id": RANK_FIELD, "name": "Rank", "custom": True, + "schema": {"type": "any", "custom": "com.pyxis.greenhopper.jira:gh-lexo-rank"}}, + {"id": POINTS_FIELD, "name": "Story Points", "custom": True, + "schema": {"type": "number", "custom": "com.atlassian.jira.plugin.system.customfieldtypes:float"}}, {"id": "duedate", "name": "Срок исполнения", "custom": False}] # GET /rest/api/2/field self.lock = threading.Lock() fake = self diff --git a/tests/test_refresh.py b/tests/test_refresh.py index 3c336f6..ba0aecd 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -469,3 +469,57 @@ class RefreshTest(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class JiraFieldsTest(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 logged(self, fn): + logs, orig = [], agent.log + agent.log = logs.append + try: + return fn(), logs + finally: + agent.log = orig + + def test_fields_found_by_schema_and_name(self): + want = {"rank": "customfield_10105", "sprint": "customfield_10104", "story_points": "customfield_10106"} + self.assertEqual(self.ex.jira_fields(), want) + self.ex.run({"id": 2, "type": "nope", "params": {}}) # новая команда — кэш процесса жив + self.assertEqual(self.ex.jira_fields(), want) + self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1) + + def test_fields_override_skips_lookup(self): + self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], rank="cf_r", sprint="cf_s", story_points="cf_p") + self.assertEqual(self.ex.jira_fields(), {"rank": "cf_r", "sprint": "cf_s", "story_points": "cf_p"}) + self.assertEqual(self.fake.requests, []) + + def test_fields_partial_override(self): + self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], story_points="customfield_20000") + self.assertEqual(self.ex.jira_fields()["story_points"], "customfield_20000") + self.assertEqual(self.ex.jira_fields()["rank"], "customfield_10105") + + def test_story_points_first_by_numeric_id_any_case(self): + self.fake.fields += [{"id": "customfield_9999", "name": " story points ", "custom": True}, + {"id": "customfield_10050", "name": "Story Points", "custom": True}] + self.assertEqual(self.ex.jira_fields()["story_points"], "customfield_9999") + + def test_field_lookup_error_retried_next_command(self): + self.fake.fail_next.append((403, {})) + out, logs = self.logged(lambda: (self.ex.jira_fields(), self.ex.jira_fields())) # logged — как в RefreshTest + self.assertEqual(out[0], {"rank": None, "sprint": None, "story_points": None}) + self.assertTrue(any("HTTP 403" in l for l in logs), logs) + self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1) # в той же команде — не повторяем + self.ex.run({"id": 2, "type": "nope", "params": {}}) + self.assertEqual(self.ex.jira_fields()["rank"], "customfield_10105")